@tokensmind/agent-network 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -0
- package/openclaw.plugin.json +32 -0
- package/package.json +55 -0
- package/skills/tokensmind-agent-network-runtime/SKILL.md +55 -0
- package/skills/tokensmind-agent-network-runtime/scripts/action_executor.py +112 -0
- package/skills/tokensmind-agent-network-runtime/scripts/action_support.py +106 -0
- package/skills/tokensmind-agent-network-runtime/scripts/action_validation.py +38 -0
- package/skills/tokensmind-agent-network-runtime/scripts/agent-network-runtime.mjs +54 -0
- package/skills/tokensmind-agent-network-runtime/scripts/agent_network_runtime.py +236 -0
- package/skills/tokensmind-agent-network-runtime/scripts/governance_actions.py +57 -0
- package/skills/tokensmind-agent-network-runtime/scripts/lib/action-context.js +23 -0
- package/skills/tokensmind-agent-network-runtime/scripts/lib/action-errors.js +55 -0
- package/skills/tokensmind-agent-network-runtime/scripts/lib/action-executor.js +126 -0
- package/skills/tokensmind-agent-network-runtime/scripts/lib/action-validation.js +61 -0
- package/skills/tokensmind-agent-network-runtime/scripts/lib/agent-actions.js +44 -0
- package/skills/tokensmind-agent-network-runtime/scripts/lib/api-client.js +76 -0
- package/skills/tokensmind-agent-network-runtime/scripts/lib/contact-action.js +154 -0
- package/skills/tokensmind-agent-network-runtime/scripts/lib/governance-actions.js +66 -0
- package/skills/tokensmind-agent-network-runtime/scripts/lib/messaging-actions.js +60 -0
- package/skills/tokensmind-agent-network-runtime/scripts/lib/runtime/browser.js +26 -0
- package/skills/tokensmind-agent-network-runtime/scripts/lib/runtime/connector.js +204 -0
- package/skills/tokensmind-agent-network-runtime/scripts/lib/runtime/constants.js +10 -0
- package/skills/tokensmind-agent-network-runtime/scripts/lib/runtime/credentialStore.js +162 -0
- package/skills/tokensmind-agent-network-runtime/scripts/lib/runtime/crypto.js +25 -0
- package/skills/tokensmind-agent-network-runtime/scripts/lib/runtime/deviceAuthorization.js +194 -0
- package/skills/tokensmind-agent-network-runtime/scripts/lib/runtime/httpClient.js +54 -0
- package/skills/tokensmind-agent-network-runtime/scripts/lib/runtime/portableStore.js +193 -0
- package/skills/tokensmind-agent-network-runtime/scripts/lib/runtime/requestPolicy.js +55 -0
- package/skills/tokensmind-agent-network-runtime/scripts/lib/runtime/systemCredentialStore.js +176 -0
- package/skills/tokensmind-agent-network-runtime/scripts/lib/workflow-store.js +77 -0
- package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/__init__.py +1 -0
- package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/browser.py +27 -0
- package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/connector.py +156 -0
- package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/constants.py +12 -0
- package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/credential_store.py +135 -0
- package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/crypto.py +28 -0
- package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/device_authorization.py +165 -0
- package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/errors.py +9 -0
- package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/http_client.py +50 -0
- package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/portable_store.py +195 -0
- package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/request_policy.py +85 -0
- package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/system_credential_store.py +143 -0
- package/src/action-context.js +23 -0
- package/src/action-errors.js +55 -0
- package/src/action-executor.js +126 -0
- package/src/action-validation.js +61 -0
- package/src/agent-actions.js +44 -0
- package/src/api-client.js +76 -0
- package/src/contact-action.js +154 -0
- package/src/governance-actions.js +66 -0
- package/src/index.js +70 -0
- package/src/messaging-actions.js +60 -0
- package/src/runtime/browser.js +26 -0
- package/src/runtime/connector.js +204 -0
- package/src/runtime/constants.js +10 -0
- package/src/runtime/credentialStore.js +162 -0
- package/src/runtime/crypto.js +25 -0
- package/src/runtime/deviceAuthorization.js +194 -0
- package/src/runtime/httpClient.js +54 -0
- package/src/runtime/portableStore.js +193 -0
- package/src/runtime/requestPolicy.js +55 -0
- package/src/runtime/systemCredentialStore.js +176 -0
- package/src/workflow-store.js +77 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import tempfile
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
from hashlib import sha256
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from urllib.parse import urlsplit
|
|
9
|
+
|
|
10
|
+
DIRECTORY_MODE = 0o700
|
|
11
|
+
FILE_MODE = 0o600
|
|
12
|
+
RECORD_NAMES = frozenset(("active", "pending", "instance"))
|
|
13
|
+
PENDING_PHASES = frozenset(("prepared", "authorizing", "authorized"))
|
|
14
|
+
UUID_PATTERN = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$", re.I)
|
|
15
|
+
SHA256_PATTERN = re.compile(r"^[a-f0-9]{64}$")
|
|
16
|
+
DEVICE_CODE_PATTERN = re.compile(r"^tm_device_[A-Za-z0-9_-]{43}$")
|
|
17
|
+
AGENT_TOKEN_PATTERN = re.compile(r"^tm_agent_[A-Za-z0-9_-]{43}$")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _require_home_directory(home_dir):
|
|
21
|
+
value = str(home_dir or "").strip()
|
|
22
|
+
if not value:
|
|
23
|
+
raise ValueError("Unable to resolve the current user home directory")
|
|
24
|
+
return value
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_portable_state_dir(platform, env=None, home_dir=None):
|
|
28
|
+
home_dir = Path.home() if home_dir is None else home_dir
|
|
29
|
+
if platform == "win32":
|
|
30
|
+
import ntpath
|
|
31
|
+
return ntpath.join(_require_home_directory(home_dir), ".tokensmind", "agent-network")
|
|
32
|
+
return os.path.join(_require_home_directory(home_dir), ".tokensmind", "agent-network")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _is_non_empty_string(value):
|
|
36
|
+
return isinstance(value, str) and bool(value)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _is_operation(value):
|
|
40
|
+
return (
|
|
41
|
+
isinstance(value, dict)
|
|
42
|
+
and value.get("method") in ("GET", "POST", "PATCH", "DELETE")
|
|
43
|
+
and _is_non_empty_string(value.get("path"))
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _is_instance(value):
|
|
48
|
+
return isinstance(value, dict) and bool(UUID_PATTERN.match(str(value.get("id", ""))))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _is_active(value):
|
|
52
|
+
if not isinstance(value, dict):
|
|
53
|
+
return False
|
|
54
|
+
token = value.get("token", "")
|
|
55
|
+
prefix = value.get("tokenPrefix", "")
|
|
56
|
+
return (
|
|
57
|
+
bool(AGENT_TOKEN_PATTERN.match(token))
|
|
58
|
+
and (value.get("agentId") is None or _is_non_empty_string(value.get("agentId")))
|
|
59
|
+
and _is_non_empty_string(value.get("credentialId"))
|
|
60
|
+
and _is_non_empty_string(prefix)
|
|
61
|
+
and token.startswith(prefix)
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _has_pending_secrets(value):
|
|
66
|
+
token = value.get("token", "")
|
|
67
|
+
prefix = value.get("tokenPrefix", "")
|
|
68
|
+
return (
|
|
69
|
+
bool(UUID_PATTERN.match(str(value.get("authorizationId", ""))))
|
|
70
|
+
and bool(UUID_PATTERN.match(str(value.get("instanceId", ""))))
|
|
71
|
+
and bool(UUID_PATTERN.match(str(value.get("createIdempotencyKey", ""))))
|
|
72
|
+
and bool(UUID_PATTERN.match(str(value.get("exchangeIdempotencyKey", ""))))
|
|
73
|
+
and bool(DEVICE_CODE_PATTERN.match(value.get("deviceCode", "")))
|
|
74
|
+
and bool(SHA256_PATTERN.match(value.get("deviceCodeHash", "")))
|
|
75
|
+
and bool(AGENT_TOKEN_PATTERN.match(token))
|
|
76
|
+
and bool(SHA256_PATTERN.match(value.get("tokenHash", "")))
|
|
77
|
+
and _is_non_empty_string(prefix)
|
|
78
|
+
and token.startswith(prefix)
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def parse_datetime_millis(value):
|
|
83
|
+
if not isinstance(value, str):
|
|
84
|
+
raise ValueError("Invalid Agent Network timestamp")
|
|
85
|
+
normalized = value[:-1] + "+00:00" if value.endswith("Z") else value
|
|
86
|
+
try:
|
|
87
|
+
return int(datetime.fromisoformat(normalized).timestamp() * 1000)
|
|
88
|
+
except ValueError as error:
|
|
89
|
+
raise ValueError("Invalid Agent Network timestamp") from error
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _has_authorization(value):
|
|
93
|
+
authorization = value.get("authorization")
|
|
94
|
+
if not isinstance(authorization, dict):
|
|
95
|
+
return False
|
|
96
|
+
interval = authorization.get("intervalSeconds")
|
|
97
|
+
try:
|
|
98
|
+
parse_datetime_millis(authorization.get("expiresAt"))
|
|
99
|
+
except ValueError:
|
|
100
|
+
return False
|
|
101
|
+
return (
|
|
102
|
+
authorization.get("authorizationId") == value.get("authorizationId")
|
|
103
|
+
and _is_non_empty_string(authorization.get("verificationUrl"))
|
|
104
|
+
and isinstance(interval, int) and not isinstance(interval, bool) and interval > 0
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _is_pending(value):
|
|
109
|
+
if not isinstance(value, dict) or value.get("phase") not in PENDING_PHASES:
|
|
110
|
+
return False
|
|
111
|
+
if not _is_operation(value.get("operation")) or not _has_pending_secrets(value):
|
|
112
|
+
return False
|
|
113
|
+
return value["phase"] == "prepared" or _has_authorization(value)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _validate_record(name, value):
|
|
117
|
+
valid = _is_active(value) if name == "active" else _is_pending(value)
|
|
118
|
+
if name == "instance":
|
|
119
|
+
valid = _is_instance(value)
|
|
120
|
+
if not valid:
|
|
121
|
+
raise ValueError("Portable Agent Network %s state has an invalid structure" % name)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _validate_record_name(name):
|
|
125
|
+
if name not in RECORD_NAMES:
|
|
126
|
+
raise ValueError("Unsupported portable state record: %s" % name)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _origin_namespace(origin):
|
|
130
|
+
parsed = urlsplit(origin)
|
|
131
|
+
normalized = "%s://%s" % (parsed.scheme, parsed.netloc)
|
|
132
|
+
if parsed.scheme not in ("http", "https") or normalized != origin:
|
|
133
|
+
raise ValueError("Portable Agent Network store requires a normalized HTTP origin")
|
|
134
|
+
return sha256(origin.encode("utf-8")).hexdigest()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
class PortableStore:
|
|
138
|
+
def __init__(self, origin, platform, state_dir=None, *, env=None, home_dir=None):
|
|
139
|
+
base = state_dir or resolve_portable_state_dir(platform, env, home_dir)
|
|
140
|
+
self._directory = os.path.join(os.path.abspath(str(base)), _origin_namespace(origin))
|
|
141
|
+
self._platform = platform
|
|
142
|
+
|
|
143
|
+
def _record_path(self, name):
|
|
144
|
+
return os.path.join(self._directory, "%s.json" % name)
|
|
145
|
+
|
|
146
|
+
def read(self, name):
|
|
147
|
+
_validate_record_name(name)
|
|
148
|
+
try:
|
|
149
|
+
with open(self._record_path(name), "r", encoding="utf-8") as source:
|
|
150
|
+
value = json.load(source)
|
|
151
|
+
except FileNotFoundError:
|
|
152
|
+
return None
|
|
153
|
+
except json.JSONDecodeError as error:
|
|
154
|
+
raise ValueError("Portable Agent Network %s state is invalid JSON" % name) from error
|
|
155
|
+
_validate_record(name, value)
|
|
156
|
+
return value
|
|
157
|
+
|
|
158
|
+
def write(self, name, value):
|
|
159
|
+
_validate_record_name(name)
|
|
160
|
+
_validate_record(name, value)
|
|
161
|
+
os.makedirs(self._directory, mode=DIRECTORY_MODE, exist_ok=True)
|
|
162
|
+
if self._platform != "win32":
|
|
163
|
+
os.chmod(self._directory, DIRECTORY_MODE)
|
|
164
|
+
temporary = self._write_temporary(name, value)
|
|
165
|
+
try:
|
|
166
|
+
os.replace(temporary, self._record_path(name))
|
|
167
|
+
if self._platform != "win32":
|
|
168
|
+
os.chmod(self._record_path(name), FILE_MODE)
|
|
169
|
+
finally:
|
|
170
|
+
if os.path.exists(temporary):
|
|
171
|
+
os.unlink(temporary)
|
|
172
|
+
|
|
173
|
+
def _write_temporary(self, name, value):
|
|
174
|
+
descriptor, temporary = tempfile.mkstemp(
|
|
175
|
+
prefix=".%s.%s." % (name, os.getpid()), suffix=".tmp", dir=self._directory,
|
|
176
|
+
)
|
|
177
|
+
try:
|
|
178
|
+
if self._platform != "win32":
|
|
179
|
+
os.fchmod(descriptor, FILE_MODE)
|
|
180
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as target:
|
|
181
|
+
json.dump(value, target, separators=(",", ":"))
|
|
182
|
+
target.flush()
|
|
183
|
+
os.fsync(target.fileno())
|
|
184
|
+
return temporary
|
|
185
|
+
except Exception:
|
|
186
|
+
if os.path.exists(temporary):
|
|
187
|
+
os.unlink(temporary)
|
|
188
|
+
raise
|
|
189
|
+
|
|
190
|
+
def remove(self, name):
|
|
191
|
+
_validate_record_name(name)
|
|
192
|
+
try:
|
|
193
|
+
os.unlink(self._record_path(name))
|
|
194
|
+
except FileNotFoundError:
|
|
195
|
+
return
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
from urllib.parse import parse_qsl, urljoin, urlsplit, urlunsplit
|
|
2
|
+
|
|
3
|
+
from .constants import INTERNAL_PATH_PATTERNS, MUTATION_METHODS, SUPPORTED_METHODS
|
|
4
|
+
|
|
5
|
+
PUBLIC_AGENT_DIRECTORY_PATH = "/agent-network-api/agents"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def normalize_base_url(value):
|
|
9
|
+
parsed = urlsplit(str(value))
|
|
10
|
+
if parsed.scheme not in ("http", "https") or not parsed.hostname:
|
|
11
|
+
raise ValueError("Agent Network baseUrl must use http or https")
|
|
12
|
+
if parsed.username or parsed.password:
|
|
13
|
+
raise ValueError("Agent Network baseUrl must not contain credentials")
|
|
14
|
+
host = parsed.hostname.lower()
|
|
15
|
+
if ":" in host:
|
|
16
|
+
host = "[%s]" % host
|
|
17
|
+
port = parsed.port
|
|
18
|
+
default_port = 80 if parsed.scheme == "http" else 443
|
|
19
|
+
authority = host if port in (None, default_port) else "%s:%s" % (host, port)
|
|
20
|
+
return urlunsplit((parsed.scheme.lower(), authority, "", "", ""))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _is_internal_path(path):
|
|
24
|
+
return any(pattern.search(path) for pattern in INTERNAL_PATH_PATTERNS)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _validated_method(request):
|
|
28
|
+
method = str(request.get("method", "undefined")).upper()
|
|
29
|
+
if method not in SUPPORTED_METHODS:
|
|
30
|
+
raise ValueError("Unsupported Agent Network method: %s" % method)
|
|
31
|
+
return method
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _validated_path(request):
|
|
35
|
+
path = request.get("path")
|
|
36
|
+
if not isinstance(path, str) or not path.startswith("/agent-network-api/"):
|
|
37
|
+
raise ValueError("Agent Network path must start with /agent-network-api/")
|
|
38
|
+
if _is_internal_path(path):
|
|
39
|
+
raise ValueError("This endpoint is not available through the ordinary-user connector")
|
|
40
|
+
return path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _validated_key(request, method):
|
|
44
|
+
key = request.get("idempotencyKey")
|
|
45
|
+
normalized_key = key.strip() if isinstance(key, str) else None
|
|
46
|
+
if method in MUTATION_METHODS and not normalized_key:
|
|
47
|
+
raise ValueError("Mutating Agent Network requests require a stable Idempotency-Key")
|
|
48
|
+
return normalized_key
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _validate_reauthorize(request):
|
|
52
|
+
reauthorize = request.get("reauthorize")
|
|
53
|
+
if "reauthorize" in request and not isinstance(reauthorize, bool):
|
|
54
|
+
raise ValueError("Agent Network reauthorize must be a boolean")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def validate_business_request(request):
|
|
58
|
+
method = _validated_method(request)
|
|
59
|
+
path = _validated_path(request)
|
|
60
|
+
normalized_key = _validated_key(request, method)
|
|
61
|
+
_validate_reauthorize(request)
|
|
62
|
+
operation = {"method": method, "path": path}
|
|
63
|
+
if normalized_key:
|
|
64
|
+
operation["idempotencyKey"] = normalized_key
|
|
65
|
+
return operation
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def build_business_url(base_url, path):
|
|
69
|
+
url = urljoin(base_url + "/", path)
|
|
70
|
+
parsed = urlsplit(url)
|
|
71
|
+
origin = normalize_base_url(url)
|
|
72
|
+
if origin != base_url or not parsed.path.startswith("/agent-network-api/"):
|
|
73
|
+
raise ValueError("Agent Network request must remain on the configured origin")
|
|
74
|
+
if _is_internal_path(parsed.path):
|
|
75
|
+
raise ValueError("This endpoint is not available through the ordinary-user connector")
|
|
76
|
+
return url
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_public_discovery_request(operation):
|
|
80
|
+
if operation.get("method") != "GET":
|
|
81
|
+
return False
|
|
82
|
+
parsed = urlsplit(operation.get("path", ""))
|
|
83
|
+
query = parse_qsl(parsed.query)
|
|
84
|
+
mine_requested = any(key == "mine" and value == "1" for key, value in query)
|
|
85
|
+
return parsed.path == PUBLIC_AGENT_DIRECTORY_PATH and not mine_requested
|
package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/system_credential_store.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import shutil
|
|
4
|
+
import subprocess
|
|
5
|
+
|
|
6
|
+
from .portable_store import RECORD_NAMES, _validate_record
|
|
7
|
+
|
|
8
|
+
SERVICE_NAME = "TokensMind Agent Network"
|
|
9
|
+
APPLICATION_NAME = "tokensmind-agent-network"
|
|
10
|
+
MACOS_SECURITY_PATH = "/usr/bin/security"
|
|
11
|
+
MACOS_ITEM_NOT_FOUND = 44
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _validate_record_name(name):
|
|
15
|
+
if name not in RECORD_NAMES:
|
|
16
|
+
raise ValueError("Unsupported portable state record: %s" % name)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parse_record(name, source):
|
|
20
|
+
try:
|
|
21
|
+
value = json.loads(source)
|
|
22
|
+
except json.JSONDecodeError as error:
|
|
23
|
+
raise ValueError(
|
|
24
|
+
"System Agent Network %s credential is invalid JSON" % name,
|
|
25
|
+
) from error
|
|
26
|
+
_validate_record(name, value)
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _command_failure(backend, operation, result):
|
|
31
|
+
return RuntimeError(
|
|
32
|
+
"%s %s failed with exit code %s" % (backend, operation, result.returncode),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run_credential_command(args, input_text=""):
|
|
37
|
+
return subprocess.run(
|
|
38
|
+
args,
|
|
39
|
+
input=input_text,
|
|
40
|
+
text=True,
|
|
41
|
+
stdout=subprocess.PIPE,
|
|
42
|
+
stderr=subprocess.PIPE,
|
|
43
|
+
check=False,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class MacOSCredentialStore:
|
|
48
|
+
def __init__(self, namespace, runner=run_credential_command):
|
|
49
|
+
self._namespace = namespace
|
|
50
|
+
self._runner = runner
|
|
51
|
+
|
|
52
|
+
def _account(self, name):
|
|
53
|
+
_validate_record_name(name)
|
|
54
|
+
return "%s:%s" % (self._namespace, name)
|
|
55
|
+
|
|
56
|
+
def read(self, name):
|
|
57
|
+
result = self._runner([
|
|
58
|
+
MACOS_SECURITY_PATH, "find-generic-password",
|
|
59
|
+
"-a", self._account(name), "-s", SERVICE_NAME, "-w",
|
|
60
|
+
])
|
|
61
|
+
if result.returncode == MACOS_ITEM_NOT_FOUND:
|
|
62
|
+
return None
|
|
63
|
+
if result.returncode != 0:
|
|
64
|
+
raise _command_failure("macOS Keychain", "read", result)
|
|
65
|
+
return _parse_record(name, result.stdout.strip())
|
|
66
|
+
|
|
67
|
+
def write(self, name, value):
|
|
68
|
+
_validate_record(name, value)
|
|
69
|
+
result = self._runner([
|
|
70
|
+
MACOS_SECURITY_PATH, "add-generic-password",
|
|
71
|
+
"-a", self._account(name), "-s", SERVICE_NAME, "-U", "-w",
|
|
72
|
+
], json.dumps(value, separators=(",", ":")) + "\n")
|
|
73
|
+
if result.returncode != 0:
|
|
74
|
+
raise _command_failure("macOS Keychain", "write", result)
|
|
75
|
+
|
|
76
|
+
def remove(self, name):
|
|
77
|
+
result = self._runner([
|
|
78
|
+
MACOS_SECURITY_PATH, "delete-generic-password",
|
|
79
|
+
"-a", self._account(name), "-s", SERVICE_NAME,
|
|
80
|
+
])
|
|
81
|
+
if result.returncode not in (0, MACOS_ITEM_NOT_FOUND):
|
|
82
|
+
raise _command_failure("macOS Keychain", "remove", result)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class LinuxSecretServiceStore:
|
|
86
|
+
def __init__(self, namespace, command, runner=run_credential_command):
|
|
87
|
+
self._namespace = namespace
|
|
88
|
+
self._command = command
|
|
89
|
+
self._runner = runner
|
|
90
|
+
|
|
91
|
+
def _attributes(self, name):
|
|
92
|
+
_validate_record_name(name)
|
|
93
|
+
return [
|
|
94
|
+
"application", APPLICATION_NAME,
|
|
95
|
+
"origin", self._namespace,
|
|
96
|
+
"record", name,
|
|
97
|
+
]
|
|
98
|
+
|
|
99
|
+
def read(self, name):
|
|
100
|
+
result = self._runner([self._command, "lookup"] + self._attributes(name))
|
|
101
|
+
missing = (
|
|
102
|
+
result.returncode == 1
|
|
103
|
+
and not result.stdout.strip()
|
|
104
|
+
and not result.stderr.strip()
|
|
105
|
+
)
|
|
106
|
+
if missing:
|
|
107
|
+
return None
|
|
108
|
+
if result.returncode != 0:
|
|
109
|
+
raise _command_failure("Linux Secret Service", "read", result)
|
|
110
|
+
return _parse_record(name, result.stdout.strip())
|
|
111
|
+
|
|
112
|
+
def write(self, name, value):
|
|
113
|
+
_validate_record(name, value)
|
|
114
|
+
result = self._runner(
|
|
115
|
+
[self._command, "store", "--label=%s" % SERVICE_NAME]
|
|
116
|
+
+ self._attributes(name),
|
|
117
|
+
json.dumps(value, separators=(",", ":")) + "\n",
|
|
118
|
+
)
|
|
119
|
+
if result.returncode != 0:
|
|
120
|
+
raise _command_failure("Linux Secret Service", "write", result)
|
|
121
|
+
|
|
122
|
+
def remove(self, name):
|
|
123
|
+
if self.read(name) is None:
|
|
124
|
+
return
|
|
125
|
+
result = self._runner([self._command, "clear"] + self._attributes(name))
|
|
126
|
+
if result.returncode != 0:
|
|
127
|
+
raise _command_failure("Linux Secret Service", "remove", result)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def resolve_system_credential_store(
|
|
131
|
+
platform, namespace, env=None, *, runner=run_credential_command):
|
|
132
|
+
if platform == "darwin":
|
|
133
|
+
if not os.path.isfile(MACOS_SECURITY_PATH):
|
|
134
|
+
return None
|
|
135
|
+
if not os.access(MACOS_SECURITY_PATH, os.X_OK):
|
|
136
|
+
return None
|
|
137
|
+
return MacOSCredentialStore(namespace, runner)
|
|
138
|
+
if platform == "linux":
|
|
139
|
+
environment = os.environ if env is None else env
|
|
140
|
+
command = shutil.which("secret-tool", path=environment.get("PATH"))
|
|
141
|
+
if command:
|
|
142
|
+
return LinuxSecretServiceStore(namespace, command, runner)
|
|
143
|
+
return None
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
function mutationKey(workflow, label) {
|
|
2
|
+
return `${workflow.id}:${label}`;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function createActionContext({ api, workflow }) {
|
|
6
|
+
return {
|
|
7
|
+
input: workflow.input,
|
|
8
|
+
get(path) {
|
|
9
|
+
return api.request({ method: 'GET', path });
|
|
10
|
+
},
|
|
11
|
+
mutate({ method, path, body, label }) {
|
|
12
|
+
return api.request({
|
|
13
|
+
method,
|
|
14
|
+
path,
|
|
15
|
+
body,
|
|
16
|
+
idempotencyKey: mutationKey(workflow, label),
|
|
17
|
+
});
|
|
18
|
+
},
|
|
19
|
+
messageId(label) {
|
|
20
|
+
return mutationKey(workflow, `${label}:message`);
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
export class ActionState extends Error {
|
|
2
|
+
constructor(status, data) {
|
|
3
|
+
super(data?.message || status);
|
|
4
|
+
this.name = 'ActionState';
|
|
5
|
+
this.status = status;
|
|
6
|
+
this.data = data || {};
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export class ActionFailure extends Error {
|
|
11
|
+
constructor(code, message, details = {}) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = 'ActionFailure';
|
|
14
|
+
this.code = code;
|
|
15
|
+
this.details = details;
|
|
16
|
+
this.retryable = false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function requireInput(fields, message = 'Agent Network action needs more input.') {
|
|
21
|
+
throw new ActionState('input_required', { fields, message });
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function requireSelection(candidates, message) {
|
|
25
|
+
throw new ActionState('selection_required', { candidates, message });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function actionFailure(code, message, details) {
|
|
29
|
+
throw new ActionFailure(code, message, details);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function isRetryable(error) {
|
|
33
|
+
if (error?.retryable === true) return true;
|
|
34
|
+
if (error?.status === 429) return true;
|
|
35
|
+
if (Number(error?.status) >= 500) return true;
|
|
36
|
+
return error?.code === 'AGENT_NETWORK_TRANSPORT_ERROR';
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function optionalFailureFields(error) {
|
|
40
|
+
const fields = {};
|
|
41
|
+
if (Number.isInteger(error?.status)) fields.httpStatus = error.status;
|
|
42
|
+
if (error?.correction) fields.correction = error.correction;
|
|
43
|
+
if (error?.retryAfter) fields.retryAfter = error.retryAfter;
|
|
44
|
+
if (error?.details) fields.details = error.details;
|
|
45
|
+
return fields;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function serializeFailure(error) {
|
|
49
|
+
return {
|
|
50
|
+
code: error?.code || 'AGENT_NETWORK_ACTION_ERROR',
|
|
51
|
+
message: error instanceof Error ? error.message : String(error),
|
|
52
|
+
retryable: isRetryable(error),
|
|
53
|
+
...optionalFailureFields(error),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { ActionState, serializeFailure } from './action-errors.js';
|
|
3
|
+
import { createActionContext } from './action-context.js';
|
|
4
|
+
import { validateActionRequest } from './action-validation.js';
|
|
5
|
+
import { ensureAgent, getMyAgent, searchAgents } from './agent-actions.js';
|
|
6
|
+
import { contactAgent } from './contact-action.js';
|
|
7
|
+
import { appeal, blockAgent, report, unblockAgent } from './governance-actions.js';
|
|
8
|
+
import {
|
|
9
|
+
getConversation,
|
|
10
|
+
listInbox,
|
|
11
|
+
markRead,
|
|
12
|
+
reply,
|
|
13
|
+
withdrawMessage,
|
|
14
|
+
} from './messaging-actions.js';
|
|
15
|
+
|
|
16
|
+
const ACTIONS = Object.freeze({
|
|
17
|
+
appeal,
|
|
18
|
+
block_agent: blockAgent,
|
|
19
|
+
contact_agent: contactAgent,
|
|
20
|
+
ensure_agent: ensureAgent,
|
|
21
|
+
get_conversation: getConversation,
|
|
22
|
+
get_my_agent: getMyAgent,
|
|
23
|
+
list_inbox: listInbox,
|
|
24
|
+
mark_read: markRead,
|
|
25
|
+
reply,
|
|
26
|
+
report,
|
|
27
|
+
search_agents: searchAgents,
|
|
28
|
+
unblock_agent: unblockAgent,
|
|
29
|
+
withdraw_message: withdrawMessage,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
function canonicalize(value) {
|
|
33
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
34
|
+
if (!value || typeof value !== 'object') return value;
|
|
35
|
+
return Object.fromEntries(
|
|
36
|
+
Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function requestsMatch(workflow, request) {
|
|
41
|
+
return JSON.stringify(canonicalize({
|
|
42
|
+
operation: workflow.operation,
|
|
43
|
+
input: workflow.input,
|
|
44
|
+
})) === JSON.stringify(canonicalize(request));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function loadWorkflow({ request, store, randomId }) {
|
|
48
|
+
let current = await store.read('workflow');
|
|
49
|
+
if (!current) {
|
|
50
|
+
const candidate = { version: 1, id: randomId(), ...request };
|
|
51
|
+
await store.write('workflow', candidate);
|
|
52
|
+
current = await store.read('workflow');
|
|
53
|
+
}
|
|
54
|
+
if (!current) throw new Error('Workflow claim returned no state');
|
|
55
|
+
if (current && !requestsMatch(current, request)) {
|
|
56
|
+
const error = new Error(`Another ${current.operation} action is pending.`);
|
|
57
|
+
error.code = 'AGENT_NETWORK_ACTION_PENDING';
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
return current;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function handleFailure(error, store) {
|
|
64
|
+
if (error instanceof ActionState) {
|
|
65
|
+
if (error.status !== 'authorization_required') await store.remove('workflow');
|
|
66
|
+
return { status: error.status, ...error.data };
|
|
67
|
+
}
|
|
68
|
+
if (error?.status === 'authorization_required') {
|
|
69
|
+
return {
|
|
70
|
+
status: 'authorization_required',
|
|
71
|
+
message: error.message,
|
|
72
|
+
verificationUrl: error.verificationUrl,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
const serialized = serializeFailure(error);
|
|
76
|
+
if (!serialized.retryable) await store.remove('workflow');
|
|
77
|
+
return { status: 'failed', ...serialized };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function serializeWithoutCleanup(error) {
|
|
81
|
+
if (error instanceof ActionState) return { status: error.status, ...error.data };
|
|
82
|
+
return { status: 'failed', ...serializeFailure(error) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function abandonWorkflow(store) {
|
|
86
|
+
const abandoned = await store.remove('workflow');
|
|
87
|
+
return {
|
|
88
|
+
status: 'completed',
|
|
89
|
+
operation: 'abandon_action',
|
|
90
|
+
data: { abandoned },
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function createActionExecutor({ api, workflowStore, randomId = randomUUID }) {
|
|
95
|
+
return {
|
|
96
|
+
async execute(value) {
|
|
97
|
+
let request;
|
|
98
|
+
try {
|
|
99
|
+
request = validateActionRequest(value);
|
|
100
|
+
} catch (error) {
|
|
101
|
+
return serializeWithoutCleanup(error);
|
|
102
|
+
}
|
|
103
|
+
if (request.operation === 'abandon_action') {
|
|
104
|
+
try {
|
|
105
|
+
return await abandonWorkflow(workflowStore);
|
|
106
|
+
} catch (error) {
|
|
107
|
+
return serializeWithoutCleanup(error);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
let workflow;
|
|
111
|
+
try {
|
|
112
|
+
workflow = await loadWorkflow({ request, store: workflowStore, randomId });
|
|
113
|
+
} catch (error) {
|
|
114
|
+
return serializeWithoutCleanup(error);
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
const context = createActionContext({ api, workflow });
|
|
118
|
+
const data = await ACTIONS[request.operation](context);
|
|
119
|
+
await workflowStore.remove('workflow');
|
|
120
|
+
return { status: 'completed', operation: request.operation, data };
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return handleFailure(error, workflowStore);
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { requireInput } from './action-errors.js';
|
|
2
|
+
|
|
3
|
+
export const OPERATIONS = Object.freeze([
|
|
4
|
+
'abandon_action',
|
|
5
|
+
'search_agents',
|
|
6
|
+
'get_my_agent',
|
|
7
|
+
'ensure_agent',
|
|
8
|
+
'contact_agent',
|
|
9
|
+
'list_inbox',
|
|
10
|
+
'get_conversation',
|
|
11
|
+
'reply',
|
|
12
|
+
'mark_read',
|
|
13
|
+
'withdraw_message',
|
|
14
|
+
'block_agent',
|
|
15
|
+
'unblock_agent',
|
|
16
|
+
'report',
|
|
17
|
+
'appeal',
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
const OPERATION_SET = new Set(OPERATIONS);
|
|
21
|
+
|
|
22
|
+
export function isObject(value) {
|
|
23
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function text(value) {
|
|
27
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function requiredText(input, field) {
|
|
31
|
+
const value = text(input?.[field]);
|
|
32
|
+
if (!value) requireInput([field]);
|
|
33
|
+
return value;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function requiredId(input, field) {
|
|
37
|
+
return requiredText(input, field);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function optionalLimit(value, fallback = 20) {
|
|
41
|
+
if (value === undefined || value === null || value === '') return fallback;
|
|
42
|
+
const number = Number(value);
|
|
43
|
+
if (!Number.isInteger(number) || number <= 0 || number > 100) {
|
|
44
|
+
requireInput(['limit'], 'limit must be an integer from 1 to 100.');
|
|
45
|
+
}
|
|
46
|
+
return number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function validateActionRequest(value) {
|
|
50
|
+
if (!isObject(value)) requireInput(['operation', 'input']);
|
|
51
|
+
const unexpected = Object.keys(value).filter((key) => !['operation', 'input'].includes(key));
|
|
52
|
+
if (unexpected.length) {
|
|
53
|
+
requireInput(['operation', 'input'], `Unsupported top-level fields: ${unexpected.join(', ')}`);
|
|
54
|
+
}
|
|
55
|
+
const operation = text(value.operation);
|
|
56
|
+
if (!OPERATION_SET.has(operation)) {
|
|
57
|
+
requireInput(['operation'], `Unsupported Agent Network operation: ${operation || '(empty)'}`);
|
|
58
|
+
}
|
|
59
|
+
if (value.input !== undefined && !isObject(value.input)) requireInput(['input']);
|
|
60
|
+
return { operation, input: value.input || {} };
|
|
61
|
+
}
|