@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.
Files changed (63) hide show
  1. package/README.md +68 -0
  2. package/openclaw.plugin.json +32 -0
  3. package/package.json +55 -0
  4. package/skills/tokensmind-agent-network-runtime/SKILL.md +55 -0
  5. package/skills/tokensmind-agent-network-runtime/scripts/action_executor.py +112 -0
  6. package/skills/tokensmind-agent-network-runtime/scripts/action_support.py +106 -0
  7. package/skills/tokensmind-agent-network-runtime/scripts/action_validation.py +38 -0
  8. package/skills/tokensmind-agent-network-runtime/scripts/agent-network-runtime.mjs +54 -0
  9. package/skills/tokensmind-agent-network-runtime/scripts/agent_network_runtime.py +236 -0
  10. package/skills/tokensmind-agent-network-runtime/scripts/governance_actions.py +57 -0
  11. package/skills/tokensmind-agent-network-runtime/scripts/lib/action-context.js +23 -0
  12. package/skills/tokensmind-agent-network-runtime/scripts/lib/action-errors.js +55 -0
  13. package/skills/tokensmind-agent-network-runtime/scripts/lib/action-executor.js +126 -0
  14. package/skills/tokensmind-agent-network-runtime/scripts/lib/action-validation.js +61 -0
  15. package/skills/tokensmind-agent-network-runtime/scripts/lib/agent-actions.js +44 -0
  16. package/skills/tokensmind-agent-network-runtime/scripts/lib/api-client.js +76 -0
  17. package/skills/tokensmind-agent-network-runtime/scripts/lib/contact-action.js +154 -0
  18. package/skills/tokensmind-agent-network-runtime/scripts/lib/governance-actions.js +66 -0
  19. package/skills/tokensmind-agent-network-runtime/scripts/lib/messaging-actions.js +60 -0
  20. package/skills/tokensmind-agent-network-runtime/scripts/lib/runtime/browser.js +26 -0
  21. package/skills/tokensmind-agent-network-runtime/scripts/lib/runtime/connector.js +204 -0
  22. package/skills/tokensmind-agent-network-runtime/scripts/lib/runtime/constants.js +10 -0
  23. package/skills/tokensmind-agent-network-runtime/scripts/lib/runtime/credentialStore.js +162 -0
  24. package/skills/tokensmind-agent-network-runtime/scripts/lib/runtime/crypto.js +25 -0
  25. package/skills/tokensmind-agent-network-runtime/scripts/lib/runtime/deviceAuthorization.js +194 -0
  26. package/skills/tokensmind-agent-network-runtime/scripts/lib/runtime/httpClient.js +54 -0
  27. package/skills/tokensmind-agent-network-runtime/scripts/lib/runtime/portableStore.js +193 -0
  28. package/skills/tokensmind-agent-network-runtime/scripts/lib/runtime/requestPolicy.js +55 -0
  29. package/skills/tokensmind-agent-network-runtime/scripts/lib/runtime/systemCredentialStore.js +176 -0
  30. package/skills/tokensmind-agent-network-runtime/scripts/lib/workflow-store.js +77 -0
  31. package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/__init__.py +1 -0
  32. package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/browser.py +27 -0
  33. package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/connector.py +156 -0
  34. package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/constants.py +12 -0
  35. package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/credential_store.py +135 -0
  36. package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/crypto.py +28 -0
  37. package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/device_authorization.py +165 -0
  38. package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/errors.py +9 -0
  39. package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/http_client.py +50 -0
  40. package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/portable_store.py +195 -0
  41. package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/request_policy.py +85 -0
  42. package/skills/tokensmind-agent-network-runtime/scripts/python_runtime/system_credential_store.py +143 -0
  43. package/src/action-context.js +23 -0
  44. package/src/action-errors.js +55 -0
  45. package/src/action-executor.js +126 -0
  46. package/src/action-validation.js +61 -0
  47. package/src/agent-actions.js +44 -0
  48. package/src/api-client.js +76 -0
  49. package/src/contact-action.js +154 -0
  50. package/src/governance-actions.js +66 -0
  51. package/src/index.js +70 -0
  52. package/src/messaging-actions.js +60 -0
  53. package/src/runtime/browser.js +26 -0
  54. package/src/runtime/connector.js +204 -0
  55. package/src/runtime/constants.js +10 -0
  56. package/src/runtime/credentialStore.js +162 -0
  57. package/src/runtime/crypto.js +25 -0
  58. package/src/runtime/deviceAuthorization.js +194 -0
  59. package/src/runtime/httpClient.js +54 -0
  60. package/src/runtime/portableStore.js +193 -0
  61. package/src/runtime/requestPolicy.js +55 -0
  62. package/src/runtime/systemCredentialStore.js +176 -0
  63. package/src/workflow-store.js +77 -0
@@ -0,0 +1,77 @@
1
+ import { promises as defaultFs } from 'node:fs';
2
+ import { randomUUID } from 'node:crypto';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { originNamespace, resolvePortableStateDir } from './runtime/portableStore.js';
6
+
7
+ const DIRECTORY_MODE = 0o700;
8
+ const FILE_MODE = 0o600;
9
+
10
+ function recordPath({ stateDir, origin, platform, homeDir }) {
11
+ const base = stateDir || resolvePortableStateDir({ platform, homeDir });
12
+ return path.join(path.resolve(base), originNamespace(origin), 'workflow.json');
13
+ }
14
+
15
+ async function ensureDirectory(fs, filePath, platform) {
16
+ const directory = path.dirname(filePath);
17
+ await fs.mkdir(directory, { recursive: true, mode: DIRECTORY_MODE });
18
+ if (platform !== 'win32') await fs.chmod(directory, DIRECTORY_MODE);
19
+ }
20
+
21
+ async function writeCandidate({ fs, filePath, platform, value }) {
22
+ const temporary = path.join(path.dirname(filePath), `.${process.pid}.${randomUUID()}.tmp`);
23
+ try {
24
+ await fs.writeFile(temporary, JSON.stringify(value), {
25
+ encoding: 'utf8', mode: FILE_MODE, flag: 'wx',
26
+ });
27
+ if (platform !== 'win32') await fs.chmod(temporary, FILE_MODE);
28
+ try {
29
+ await fs.link(temporary, filePath);
30
+ } catch (error) {
31
+ if (error?.code !== 'EEXIST') throw error;
32
+ }
33
+ } finally {
34
+ try {
35
+ await fs.unlink(temporary);
36
+ } catch (error) {
37
+ if (error?.code !== 'ENOENT') throw error;
38
+ }
39
+ }
40
+ }
41
+
42
+ export function createWorkflowStore({
43
+ stateDir,
44
+ origin,
45
+ platform = process.platform,
46
+ homeDir = os.homedir(),
47
+ fs = defaultFs,
48
+ } = {}) {
49
+ const filePath = recordPath({ stateDir, origin, platform, homeDir });
50
+ return {
51
+ async read(name = 'workflow') {
52
+ if (name !== 'workflow') throw new Error(`Unsupported workflow record: ${name}`);
53
+ try {
54
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
55
+ } catch (error) {
56
+ if (error?.code === 'ENOENT') return null;
57
+ if (error instanceof SyntaxError) throw new Error('Workflow state is invalid JSON', { cause: error });
58
+ throw error;
59
+ }
60
+ },
61
+ async write(name, value) {
62
+ if (name !== 'workflow') throw new Error(`Unsupported workflow record: ${name}`);
63
+ await ensureDirectory(fs, filePath, platform);
64
+ await writeCandidate({ fs, filePath, platform, value });
65
+ },
66
+ async remove(name = 'workflow') {
67
+ if (name !== 'workflow') throw new Error(`Unsupported workflow record: ${name}`);
68
+ try {
69
+ await fs.unlink(filePath);
70
+ return true;
71
+ } catch (error) {
72
+ if (error?.code === 'ENOENT') return false;
73
+ throw error;
74
+ }
75
+ },
76
+ };
77
+ }
@@ -0,0 +1 @@
1
+ """TokensMind Agent Network portable Python runtime."""
@@ -0,0 +1,27 @@
1
+ import webbrowser
2
+
3
+
4
+ class BrowserOpener:
5
+ def __init__(self, open_impl=webbrowser.open):
6
+ self._open_impl = open_impl
7
+
8
+ def open(self, url):
9
+ if not self._open_impl(url, new=2):
10
+ raise RuntimeError("Unable to open the TokensMind authorization page")
11
+
12
+
13
+ class ReportingBrowser:
14
+ def __init__(self, browser, write_event):
15
+ self._browser = browser
16
+ self._write_event = write_event
17
+
18
+ def open(self, url):
19
+ try:
20
+ self._browser.open(url)
21
+ self._write_event({"event": "authorization_opened", "verificationUrl": url})
22
+ except Exception:
23
+ self._write_event({
24
+ "event": "authorization_required",
25
+ "verificationUrl": url,
26
+ "message": "Open this URL to approve the pending Agent Network connection.",
27
+ })
@@ -0,0 +1,156 @@
1
+ import json
2
+ import threading
3
+ import uuid
4
+
5
+ from .device_authorization import (
6
+ create_pending_authorization,
7
+ create_remote_authorization,
8
+ exchange_authorization,
9
+ wait_for_approval,
10
+ )
11
+ from .errors import AgentNetworkHttpError
12
+ from .http_client import UNSET
13
+ from .request_policy import (
14
+ build_business_url,
15
+ is_public_discovery_request,
16
+ validate_business_request,
17
+ )
18
+ from action_support import AuthorizationRequired
19
+
20
+ INVALID_CREDENTIAL_CODES = frozenset((
21
+ "AGENT_CREDENTIAL_EXPIRED",
22
+ "AGENT_CREDENTIAL_INVALID",
23
+ "AGENT_CREDENTIAL_REVOKED",
24
+ ))
25
+ TERMINAL_AUTHORIZATION_CODES = frozenset((
26
+ "DEVICE_AUTHORIZATION_DENIED",
27
+ "DEVICE_AUTHORIZATION_EXCHANGED",
28
+ "DEVICE_AUTHORIZATION_EXPIRED",
29
+ ))
30
+
31
+
32
+ def _operations_match(left, right):
33
+ options = {"sort_keys": True, "separators": (",", ":"), "ensure_ascii": False}
34
+ return json.dumps(left, **options) == json.dumps(right, **options)
35
+
36
+
37
+ def _is_http_error(error, codes):
38
+ return isinstance(error, AgentNetworkHttpError) and error.code in codes
39
+
40
+
41
+ class AgentNetworkConnector:
42
+ def __init__(self, store, browser, base_url, *, http, client):
43
+ self._store = store
44
+ self._browser = browser
45
+ self._base_url = base_url
46
+ self._http = http
47
+ self._client = client
48
+ self._lock = threading.Lock()
49
+
50
+ def execute(self, params):
51
+ with self._lock:
52
+ return self._execute_once(params)
53
+
54
+ def _execute_once(self, params):
55
+ operation = validate_business_request(params)
56
+ if "body" in params:
57
+ operation["body"] = params["body"]
58
+ reauthorize = params.get("reauthorize") is True
59
+ pending = self._store.read("pending")
60
+ active = self._store.read("active")
61
+ if pending:
62
+ return self._resume_pending(pending, active, operation)
63
+ if is_public_discovery_request(operation) and not reauthorize:
64
+ return self._execute_business_request(operation)
65
+ if not active or reauthorize:
66
+ active = self._authorize(operation)
67
+ return self._execute_and_finalize(self._store.read("pending"), active)
68
+ try:
69
+ return self._execute_business_request(operation, active["token"])
70
+ except AgentNetworkHttpError as error:
71
+ if error.code not in INVALID_CREDENTIAL_CODES:
72
+ raise
73
+ self._store.remove("active")
74
+ active = self._authorize(operation)
75
+ return self._execute_and_finalize(self._store.read("pending"), active)
76
+
77
+ def _resume_pending(self, pending, active, operation):
78
+ if not _operations_match(pending["operation"], operation):
79
+ raise ValueError(
80
+ "A different Agent Network operation is pending; retry the original operation first",
81
+ )
82
+ active = self._authorize(pending["operation"])
83
+ return self._execute_and_finalize(self._store.read("pending"), active)
84
+
85
+ def _prepare_pending(self, operation):
86
+ existing = self._store.read("pending")
87
+ if existing:
88
+ return existing
89
+ instance = self._store.read("instance")
90
+ if not instance:
91
+ instance = {"id": str(uuid.uuid4())}
92
+ self._store.write("instance", instance)
93
+ pending = create_pending_authorization(operation, instance["id"])
94
+ self._store.write("pending", pending)
95
+ return pending
96
+
97
+ def _authorize(self, operation):
98
+ try:
99
+ pending = self._prepare_pending(operation)
100
+ if pending["phase"] == "prepared":
101
+ pending = create_remote_authorization(
102
+ pending, self._base_url, self._http, client=self._client,
103
+ )
104
+ self._store.write("pending", pending)
105
+ self._browser.open(pending["authorization"]["verificationUrl"])
106
+ raise AuthorizationRequired(pending["authorization"]["verificationUrl"])
107
+ if pending["phase"] == "authorizing":
108
+ return self._complete_authorization(pending)
109
+ return self._store.read("active")
110
+ except AgentNetworkHttpError as error:
111
+ if error.code in TERMINAL_AUTHORIZATION_CODES:
112
+ self._store.remove("pending")
113
+ raise
114
+
115
+ def _complete_authorization(self, pending):
116
+ wait_for_approval(pending, self._base_url, self._http)
117
+ exchange = exchange_authorization(pending, self._base_url, self._http)
118
+ active = {
119
+ "token": pending["token"],
120
+ "agentId": (exchange.get("agent") or {}).get("id"),
121
+ "credentialId": exchange["credential"]["id"],
122
+ "tokenPrefix": exchange["credential"]["tokenPrefix"],
123
+ }
124
+ self._store.write("active", active)
125
+ updated = dict(pending)
126
+ updated["phase"] = "authorized"
127
+ self._store.write("pending", updated)
128
+ return active
129
+
130
+ def _execute_business_request(self, operation, token=None):
131
+ headers = {"Content-Type": "application/json"}
132
+ if token:
133
+ headers["Authorization"] = "Bearer %s" % token
134
+ if operation.get("idempotencyKey"):
135
+ headers["Idempotency-Key"] = operation["idempotencyKey"]
136
+ body = operation["body"] if "body" in operation else UNSET
137
+ return self._http.request(
138
+ build_business_url(self._base_url, operation["path"]),
139
+ method=operation["method"],
140
+ headers=headers,
141
+ body=body,
142
+ )
143
+
144
+ def _execute_and_finalize(self, pending, active):
145
+ if not active or not active.get("token"):
146
+ raise ValueError("Authorized Agent Network operation has no active stored credential")
147
+ try:
148
+ result = self._execute_business_request(pending["operation"], active["token"])
149
+ self._store.remove("pending")
150
+ return result
151
+ except AgentNetworkHttpError as error:
152
+ if error.status < 500 and error.status != 429:
153
+ self._store.remove("pending")
154
+ if error.code in INVALID_CREDENTIAL_CODES:
155
+ self._store.remove("active")
156
+ raise
@@ -0,0 +1,12 @@
1
+ import re
2
+
3
+ DEFAULT_BASE_URL = "https://tokensmind.ai"
4
+ DEVICE_AUTHORIZATION_PATH = "/agent-network-api/device-authorizations"
5
+ DEVICE_CODE_PREFIX = "tm_device_"
6
+ AGENT_TOKEN_PREFIX = "tm_agent_"
7
+ SUPPORTED_METHODS = frozenset(("GET", "POST", "PATCH", "DELETE"))
8
+ MUTATION_METHODS = frozenset(("POST", "PATCH", "DELETE"))
9
+ INTERNAL_PATH_PATTERNS = (
10
+ re.compile(r"^/agent-network-api/device-authorizations(?:/|$)"),
11
+ re.compile(r"^/agent-network-api/agents/[^/]+/credentials(?:/|$)"),
12
+ )
@@ -0,0 +1,135 @@
1
+ import os
2
+ from pathlib import Path
3
+
4
+ from .portable_store import (
5
+ PortableStore,
6
+ RECORD_NAMES,
7
+ _origin_namespace,
8
+ resolve_portable_state_dir,
9
+ )
10
+ from .system_credential_store import resolve_system_credential_store
11
+
12
+ AUTO_SECURE_STORE = object()
13
+
14
+
15
+ def resolve_legacy_state_dirs(platform, env=None, home_dir=None):
16
+ environment = os.environ if env is None else env
17
+ home = str(Path.home() if home_dir is None else home_dir).strip()
18
+ if not home:
19
+ raise ValueError("Unable to resolve the current user home directory")
20
+ if platform == "darwin":
21
+ return [os.path.join(
22
+ home, "Library", "Application Support", "TokensMind", "AgentNetwork",
23
+ )]
24
+ if platform == "win32":
25
+ local_app_data = str(environment.get("LOCALAPPDATA", "")).strip()
26
+ if not local_app_data:
27
+ return []
28
+ import ntpath
29
+ return [ntpath.join(local_app_data, "TokensMind", "AgentNetwork")]
30
+ if platform != "linux":
31
+ return []
32
+ default_dir = os.path.join(home, ".local", "state", "tokensmind", "agent-network")
33
+ xdg_state_home = str(environment.get("XDG_STATE_HOME", "")).strip()
34
+ directories = (
35
+ [os.path.join(xdg_state_home, "tokensmind", "agent-network"), default_dir]
36
+ if xdg_state_home else [default_dir]
37
+ )
38
+ return list(dict.fromkeys(directories))
39
+
40
+
41
+ def _collect_source_records(sources):
42
+ candidates = {}
43
+ for source in sources:
44
+ for name in RECORD_NAMES:
45
+ value = source.read(name)
46
+ if value is None:
47
+ continue
48
+ if name in candidates and candidates[name]["value"] != value:
49
+ raise ValueError(
50
+ "Conflicting Agent Network %s records exist in known local stores" % name,
51
+ )
52
+ candidate = candidates.setdefault(name, {"value": value, "sources": []})
53
+ candidate["sources"].append(source)
54
+ return candidates
55
+
56
+
57
+ def _validate_migration_target(target, candidates):
58
+ missing = []
59
+ for name, candidate in candidates.items():
60
+ current = target.read(name)
61
+ if current is None:
62
+ missing.append((name, candidate["value"]))
63
+ elif current != candidate["value"]:
64
+ raise ValueError(
65
+ "Stored Agent Network %s conflicts with a known legacy record" % name,
66
+ )
67
+ return missing
68
+
69
+
70
+ def migrate_credential_stores(target, sources):
71
+ candidates = _collect_source_records(sources)
72
+ if not candidates:
73
+ return
74
+ for name, value in _validate_migration_target(target, candidates):
75
+ target.write(name, value)
76
+ for name, candidate in candidates.items():
77
+ if target.read(name) != candidate["value"]:
78
+ raise RuntimeError("Agent Network %s migration could not be verified" % name)
79
+ for name, candidate in candidates.items():
80
+ for source in candidate["sources"]:
81
+ source.remove(name)
82
+
83
+
84
+ def _file_stores(options):
85
+ return [
86
+ PortableStore(
87
+ origin=options["origin"],
88
+ platform=options["platform"],
89
+ state_dir=directory,
90
+ env=options["env"],
91
+ home_dir=options["home_dir"],
92
+ )
93
+ for directory in options["directories"]
94
+ ]
95
+
96
+
97
+ def create_credential_store(
98
+ origin, platform, *, state_dir=None, env=None, home_dir=None,
99
+ secure_store=AUTO_SECURE_STORE,
100
+ secure_store_resolver=resolve_system_credential_store,
101
+ command_runner=None):
102
+ environment = os.environ if env is None else env
103
+ if state_dir:
104
+ return PortableStore(
105
+ origin, platform, state_dir, env=environment, home_dir=home_dir,
106
+ )
107
+ fallback_dir = resolve_portable_state_dir(platform, environment, home_dir)
108
+ fallback = PortableStore(
109
+ origin, platform, fallback_dir, env=environment, home_dir=home_dir,
110
+ )
111
+ if secure_store is AUTO_SECURE_STORE:
112
+ resolver_options = {
113
+ "platform": platform,
114
+ "namespace": _origin_namespace(origin),
115
+ "env": environment,
116
+ }
117
+ if command_runner is not None:
118
+ resolver_options["runner"] = command_runner
119
+ system_store = secure_store_resolver(**resolver_options)
120
+ else:
121
+ system_store = secure_store
122
+ target = system_store or fallback
123
+ source_dirs = []
124
+ if system_store:
125
+ source_dirs.append(fallback_dir)
126
+ source_dirs.extend(resolve_legacy_state_dirs(platform, environment, home_dir))
127
+ sources = _file_stores({
128
+ "directories": list(dict.fromkeys(source_dirs)),
129
+ "origin": origin,
130
+ "platform": platform,
131
+ "env": environment,
132
+ "home_dir": home_dir,
133
+ })
134
+ migrate_credential_stores(target, sources)
135
+ return target
@@ -0,0 +1,28 @@
1
+ import base64
2
+ import hashlib
3
+ import secrets
4
+ import uuid
5
+
6
+
7
+ def sha256(value):
8
+ return hashlib.sha256(value.encode("utf-8")).hexdigest()
9
+
10
+
11
+ def _random_credential(prefix):
12
+ encoded = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("ascii").rstrip("=")
13
+ return prefix + encoded
14
+
15
+
16
+ def create_authorization_material(device_prefix, token_prefix):
17
+ device_code = _random_credential(device_prefix)
18
+ token = _random_credential(token_prefix)
19
+ return {
20
+ "authorizationId": str(uuid.uuid4()),
21
+ "createIdempotencyKey": str(uuid.uuid4()),
22
+ "exchangeIdempotencyKey": str(uuid.uuid4()),
23
+ "deviceCode": device_code,
24
+ "deviceCodeHash": sha256(device_code),
25
+ "token": token,
26
+ "tokenHash": sha256(token),
27
+ "tokenPrefix": token[:20],
28
+ }
@@ -0,0 +1,165 @@
1
+ import time
2
+ from urllib.parse import quote, urlsplit
3
+
4
+ from .constants import AGENT_TOKEN_PREFIX, DEVICE_AUTHORIZATION_PATH, DEVICE_CODE_PREFIX
5
+ from .crypto import create_authorization_material
6
+ from .errors import AgentNetworkHttpError
7
+ from .portable_store import parse_datetime_millis
8
+
9
+ DEVICE_STATUSES = frozenset(("pending", "approved", "denied", "expired", "exchanged"))
10
+ CLIENT_FIELDS = ("name", "version", "deviceName", "platform")
11
+
12
+
13
+ def _headers(bearer=None, idempotency_key=None):
14
+ result = {"Content-Type": "application/json"}
15
+ if bearer:
16
+ result["Authorization"] = "Bearer %s" % bearer
17
+ if idempotency_key:
18
+ result["Idempotency-Key"] = idempotency_key
19
+ return result
20
+
21
+
22
+ def _assert_client_metadata(client):
23
+ if not isinstance(client, dict):
24
+ raise ValueError("Agent Network client metadata is incomplete")
25
+ if any(not isinstance(client.get(field), str) or not client[field].strip()
26
+ for field in CLIENT_FIELDS):
27
+ raise ValueError("Agent Network client metadata is incomplete")
28
+
29
+
30
+ def _assert_create_identity(response, pending):
31
+ if not isinstance(response, dict) or response.get("authorizationId") != pending["authorizationId"]:
32
+ raise ValueError("Agent Network authorization response has a mismatched identifier")
33
+
34
+
35
+ def _assert_verification_url(response, pending, base_url):
36
+ parsed = urlsplit(str(response.get("verificationUrl", "")))
37
+ expected_path = "/console/agent-network/authorize/%s" % quote(pending["authorizationId"], safe="")
38
+ origin = "%s://%s" % (parsed.scheme, parsed.netloc)
39
+ if origin != base_url or parsed.path != expected_path or parsed.query or parsed.fragment:
40
+ raise ValueError("Agent Network returned an invalid verification URL")
41
+
42
+
43
+ def _assert_authorization_timing(response):
44
+ try:
45
+ expires_at = parse_datetime_millis(response.get("expiresAt"))
46
+ except ValueError as error:
47
+ raise ValueError("Agent Network returned an invalid authorization expiry") from error
48
+ if expires_at <= int(time.time() * 1000):
49
+ raise ValueError("Agent Network returned an invalid authorization expiry")
50
+ interval = response.get("intervalSeconds")
51
+ if not isinstance(interval, int) or isinstance(interval, bool) or interval <= 0:
52
+ raise ValueError("Agent Network returned an invalid polling interval")
53
+ if not isinstance(response.get("userCode"), str) or not response["userCode"]:
54
+ raise ValueError("Agent Network authorization response is missing its user code")
55
+
56
+
57
+ def _assert_create_response(response, pending, base_url):
58
+ _assert_create_identity(response, pending)
59
+ _assert_verification_url(response, pending, base_url)
60
+ _assert_authorization_timing(response)
61
+
62
+
63
+ def _assert_status_response(status, pending):
64
+ if not isinstance(status, dict):
65
+ raise ValueError("Agent Network returned an invalid authorization status")
66
+ if status.get("authorizationId") != pending["authorizationId"]:
67
+ raise ValueError("Agent Network returned an invalid authorization status")
68
+ if status.get("status") not in DEVICE_STATUSES:
69
+ raise ValueError("Agent Network returned an invalid authorization status")
70
+ actual = parse_datetime_millis(status.get("expiresAt"))
71
+ expected = parse_datetime_millis(pending["authorization"]["expiresAt"])
72
+ if actual != expected:
73
+ raise ValueError("Agent Network authorization expiry changed unexpectedly")
74
+
75
+
76
+ def _exchange_credential(exchange, pending):
77
+ if not isinstance(exchange, dict) or exchange.get("status") != "exchanged":
78
+ raise ValueError("Agent Network credential exchange did not complete")
79
+ if exchange.get("authorizationId") != pending["authorizationId"]:
80
+ raise ValueError("Agent Network credential exchange has a mismatched identifier")
81
+ credential = exchange.get("credential")
82
+ if not isinstance(credential, dict):
83
+ raise ValueError("Agent Network exchange response is missing credential metadata")
84
+ return credential
85
+
86
+
87
+ def _assert_exchange(exchange, pending):
88
+ credential = _exchange_credential(exchange, pending)
89
+ if credential.get("tokenPrefix") != pending["tokenPrefix"]:
90
+ raise ValueError("Agent Network credential prefix verification failed")
91
+ agent = exchange.get("agent")
92
+ agent_id = agent.get("id") if isinstance(agent, dict) else None
93
+ if not credential.get("id") or credential.get("agentId") != agent_id:
94
+ raise ValueError("Agent Network exchange response is missing credential metadata")
95
+ forbidden = "apiToken" in exchange or "tokenHash" in credential or "apiToken" in credential
96
+ if forbidden:
97
+ raise ValueError("Agent Network exchange response exposed forbidden credential material")
98
+
99
+
100
+ def create_pending_authorization(operation, instance_id):
101
+ pending = {"phase": "prepared", "operation": operation, "instanceId": instance_id}
102
+ pending.update(create_authorization_material(DEVICE_CODE_PREFIX, AGENT_TOKEN_PREFIX))
103
+ return pending
104
+
105
+
106
+ def create_remote_authorization(pending, base_url, http, *, client):
107
+ _assert_client_metadata(client)
108
+ client_metadata = dict(client)
109
+ client_metadata["instanceId"] = pending["instanceId"]
110
+ response = http.request(
111
+ base_url + DEVICE_AUTHORIZATION_PATH,
112
+ method="POST",
113
+ headers=_headers(idempotency_key=pending["createIdempotencyKey"]),
114
+ body={
115
+ "authorizationId": pending["authorizationId"],
116
+ "deviceCodeHash": pending["deviceCodeHash"],
117
+ "client": client_metadata,
118
+ "credential": {
119
+ "tokenHash": pending["tokenHash"],
120
+ "tokenPrefix": pending["tokenPrefix"],
121
+ },
122
+ },
123
+ )
124
+ _assert_create_response(response, pending, base_url)
125
+ updated = dict(pending)
126
+ updated.update({"phase": "authorizing", "authorization": response})
127
+ return updated
128
+
129
+
130
+ def wait_for_approval(pending, base_url, http):
131
+ expiry = parse_datetime_millis(pending["authorization"]["expiresAt"])
132
+ interval = pending["authorization"]["intervalSeconds"]
133
+ url = "%s%s/%s" % (base_url, DEVICE_AUTHORIZATION_PATH, pending["authorizationId"])
134
+ while int(time.time() * 1000) < expiry:
135
+ status = http.request(url, headers=_headers(bearer=pending["deviceCode"]))
136
+ _assert_status_response(status, pending)
137
+ if status["status"] in ("approved", "exchanged"):
138
+ return status
139
+ if status["status"] != "pending":
140
+ denied = status["status"] == "denied"
141
+ raise AgentNetworkHttpError(
142
+ 403 if denied else 410,
143
+ "DEVICE_AUTHORIZATION_DENIED" if denied else "DEVICE_AUTHORIZATION_EXPIRED",
144
+ "Agent Network authorization ended with status %s" % status["status"],
145
+ )
146
+ time.sleep(interval)
147
+ raise AgentNetworkHttpError(
148
+ 410, "DEVICE_AUTHORIZATION_EXPIRED", "Agent Network authorization expired",
149
+ )
150
+
151
+
152
+ def exchange_authorization(pending, base_url, http):
153
+ url = "%s%s/%s/exchange" % (
154
+ base_url, DEVICE_AUTHORIZATION_PATH, pending["authorizationId"],
155
+ )
156
+ exchange = http.request(
157
+ url,
158
+ method="POST",
159
+ headers=_headers(
160
+ bearer=pending["deviceCode"],
161
+ idempotency_key=pending["exchangeIdempotencyKey"],
162
+ ),
163
+ )
164
+ _assert_exchange(exchange, pending)
165
+ return exchange
@@ -0,0 +1,9 @@
1
+ class AgentNetworkHttpError(Exception):
2
+ def __init__(self, status, code=None, message=None, *, response=None, headers=None):
3
+ super().__init__(message or "Agent Network request failed with HTTP %s" % status)
4
+ self.status = status
5
+ self.code = code
6
+ self.response = response
7
+ self.headers = headers or {}
8
+ self.correction = response.get("correction") if isinstance(response, dict) else None
9
+ self.retry_after = self.headers.get("retryAfter")
@@ -0,0 +1,50 @@
1
+ import json
2
+ from urllib.error import HTTPError
3
+ from urllib.request import Request, urlopen
4
+
5
+ from .errors import AgentNetworkHttpError
6
+
7
+ UNSET = object()
8
+
9
+
10
+ def _parse_response(source, status):
11
+ if not source:
12
+ return None
13
+ try:
14
+ return json.loads(source.decode("utf-8"))
15
+ except (UnicodeDecodeError, json.JSONDecodeError) as error:
16
+ raise ValueError("Agent Network returned non-JSON HTTP %s" % status) from error
17
+
18
+
19
+ def _unwrap_response(payload, status, successful, *, headers=None):
20
+ if successful and isinstance(payload, dict):
21
+ if payload.get("success") is True and "data" in payload:
22
+ return payload["data"]
23
+ raise ValueError("Agent Network returned an invalid success response envelope")
24
+ if successful:
25
+ raise ValueError("Agent Network returned an invalid success response envelope")
26
+ code = payload.get("code") if isinstance(payload, dict) else None
27
+ message = payload.get("message") if isinstance(payload, dict) else None
28
+ raise AgentNetworkHttpError(status, code, message, response=payload, headers=headers)
29
+
30
+
31
+ class HttpClient:
32
+ def __init__(self, opener=urlopen):
33
+ self._opener = opener
34
+
35
+ def request(self, url, method="GET", headers=None, *, body=UNSET):
36
+ data = None
37
+ if body is not UNSET:
38
+ data = json.dumps(body, separators=(",", ":")).encode("utf-8")
39
+ request = Request(url, data=data, headers=headers or {}, method=method)
40
+ try:
41
+ response = self._opener(request)
42
+ with response:
43
+ status = response.getcode()
44
+ headers = {"retryAfter": response.headers.get("Retry-After")}
45
+ payload = _parse_response(response.read(), status)
46
+ return _unwrap_response(payload, status, 200 <= status < 300, headers=headers)
47
+ except HTTPError as error:
48
+ headers = {"retryAfter": error.headers.get("Retry-After")}
49
+ payload = _parse_response(error.read(), error.code)
50
+ return _unwrap_response(payload, error.code, False, headers=headers)