@agentsdock/server 1.0.7-beta.5

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 (113) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +19 -0
  3. package/README.md +60 -0
  4. package/npm/cli.cjs +289 -0
  5. package/package.json +32 -0
  6. package/server/LICENSE +202 -0
  7. package/server/NOTICE +19 -0
  8. package/server/VERSION +1 -0
  9. package/server/activation_transaction.py +2458 -0
  10. package/server/agent_server.py +93226 -0
  11. package/server/agentsdock_chats.py +1237 -0
  12. package/server/agentsdock_emergency.py +338 -0
  13. package/server/agentsdock_jobs.py +640 -0
  14. package/server/agentsdock_mail.py +325 -0
  15. package/server/agentsdock_publish.py +404 -0
  16. package/server/agentsdock_team.py +793 -0
  17. package/server/agentsdock_team_hub/__init__.py +33 -0
  18. package/server/agentsdock_team_hub/auth.py +710 -0
  19. package/server/agentsdock_team_hub/cli.py +647 -0
  20. package/server/agentsdock_team_hub/database.py +310 -0
  21. package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
  22. package/server/agentsdock_team_hub/mail_hints.py +201 -0
  23. package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
  24. package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
  25. package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
  26. package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
  27. package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
  28. package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
  29. package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
  30. package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
  31. package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
  32. package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
  33. package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
  34. package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
  35. package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
  36. package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
  37. package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
  38. package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
  39. package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
  40. package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
  41. package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
  42. package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
  43. package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
  44. package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
  45. package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
  46. package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
  47. package/server/agentsdock_team_hub/notification_hints.py +306 -0
  48. package/server/agentsdock_team_hub/secure_peer.py +11215 -0
  49. package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
  50. package/server/agentsdock_team_hub/security.py +318 -0
  51. package/server/agentsdock_team_hub/service.py +1737 -0
  52. package/server/agentsdock_team_hub/store.py +16276 -0
  53. package/server/chat_mailbox.py +448 -0
  54. package/server/claude_background_reconciliation.py +92 -0
  55. package/server/claude_goals.py +245 -0
  56. package/server/claude_history_provenance.py +137 -0
  57. package/server/claude_history_repair.py +1601 -0
  58. package/server/claude_sdk_client.py +3731 -0
  59. package/server/claude_side_question.py +212 -0
  60. package/server/codex_app_server.py +3030 -0
  61. package/server/codex_auth.py +93 -0
  62. package/server/codex_history_repair.py +1247 -0
  63. package/server/codex_provider.py +1120 -0
  64. package/server/codex_side_question.py +301 -0
  65. package/server/cursor_agent_client.py +654 -0
  66. package/server/cursor_process_guard.py +347 -0
  67. package/server/execution_activation.py +610 -0
  68. package/server/execution_control.py +233 -0
  69. package/server/execution_durability.py +184 -0
  70. package/server/execution_http.py +256 -0
  71. package/server/execution_install.py +792 -0
  72. package/server/execution_legacy_runner.py +406 -0
  73. package/server/execution_maintenance.py +137 -0
  74. package/server/execution_manage.py +615 -0
  75. package/server/execution_ownership.py +83 -0
  76. package/server/execution_preparation.py +362 -0
  77. package/server/execution_recovery.py +845 -0
  78. package/server/execution_recovery_status.py +195 -0
  79. package/server/execution_service.py +387 -0
  80. package/server/execution_transport.py +622 -0
  81. package/server/execution_uninstall.py +406 -0
  82. package/server/execution_update_status.py +49 -0
  83. package/server/install.sh +7074 -0
  84. package/server/interactive_chat_controls.py +153 -0
  85. package/server/interactive_chat_native.py +66 -0
  86. package/server/interactive_chat_projection.py +152 -0
  87. package/server/interactive_chat_runtime.py +132 -0
  88. package/server/interactive_chat_share_routes.py +534 -0
  89. package/server/interactive_chat_share_web.py +12 -0
  90. package/server/interactive_chat_shares.py +238 -0
  91. package/server/provider_commands.py +425 -0
  92. package/server/public_chat_share_routes.py +426 -0
  93. package/server/public_chat_shares.py +759 -0
  94. package/server/public_chat_transcript.py +357 -0
  95. package/server/pyproject.toml +29 -0
  96. package/server/release-public-key.pem +3 -0
  97. package/server/secure_peer_delivery.py +1236 -0
  98. package/server/secure_peer_runtime.py +7694 -0
  99. package/server/shared_chat_video_stream.py +262 -0
  100. package/server/shared_chat_videos.py +155 -0
  101. package/server/side_questions.py +594 -0
  102. package/server/team_hub_host.py +1354 -0
  103. package/server/team_mail_grants.py +136 -0
  104. package/server/team_mail_runtime.py +302 -0
  105. package/server/team_mail_websocket.py +211 -0
  106. package/server/title_generation.py +333 -0
  107. package/server/uninstall.sh +359 -0
  108. package/server/update_handoff.py +237 -0
  109. package/server/update_preparation.py +264 -0
  110. package/server/update_recovery.py +140 -0
  111. package/server/update_runner.py +1819 -0
  112. package/server/uv.lock +1101 -0
  113. package/server/workspace_git.py +545 -0
@@ -0,0 +1,238 @@
1
+ """Durable reusable access tokens and browser capabilities for one exact chat.
2
+
3
+ This ledger grants no provider or native API authority. Raw invitation/browser
4
+ tokens are returned once and never persisted. Upload references stay private.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import hashlib
9
+ import hmac
10
+ import json
11
+ import os
12
+ from pathlib import Path
13
+ import re
14
+ import secrets
15
+ import time
16
+
17
+ from public_chat_shares import (
18
+ PublicChatShareStore, PublicChatShareUnavailable, PublicChatShareValidationError,
19
+ TOKEN_PATTERN, _timestamp, _utf8_size,
20
+ )
21
+
22
+ Unavailable = PublicChatShareUnavailable
23
+ ValidationError = PublicChatShareValidationError
24
+ SHARE_ID = re.compile(r"interactive_[a-f0-9]{32}\Z")
25
+ UPLOAD_ID = re.compile(r"upload_[a-f0-9]{32}\Z")
26
+ MAX_UPLOAD_BYTES = 8 * 1024 * 1024
27
+ MAX_SHARE_UPLOAD_BYTES = 64 * 1024 * 1024
28
+ MAX_PROMPT_BYTES = 64 * 1024
29
+
30
+
31
+ class Conflict(ValueError):
32
+ """A stable request ID already names different or indeterminate work."""
33
+
34
+
35
+ def token_hash(token):
36
+ if not isinstance(token, str) or TOKEN_PATTERN.fullmatch(token) is None:
37
+ raise Unavailable()
38
+ return hashlib.sha256(token.encode("ascii")).digest()
39
+
40
+
41
+ def csrf_token(browser_token):
42
+ token_hash(browser_token)
43
+ return hmac.new(browser_token.encode("ascii"), b"interactive-chat-csrf-v1", hashlib.sha256).hexdigest()
44
+
45
+
46
+ class InteractiveChatShareStore(PublicChatShareStore):
47
+ """Reuse the existing owner-only SQLite connection checks, not snapshot data."""
48
+
49
+ def __init__(self, storage_root, *, now=time.time):
50
+ self.storage_root = Path(storage_root)
51
+ if not self.storage_root.is_absolute():
52
+ raise ValidationError("Share storage must be an absolute private directory")
53
+ self.storage_root.mkdir(mode=0o700, parents=True, exist_ok=True)
54
+ self.database_path = self.storage_root / "interactive.sqlite3"
55
+ self._now = now
56
+ self._check_directory()
57
+ try:
58
+ fd = os.open(self.database_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0), 0o600)
59
+ except FileExistsError:
60
+ self._check_database_stat(self.database_path.lstat())
61
+ else:
62
+ os.close(fd)
63
+ with self._connection(write=True) as db:
64
+ db.execute("BEGIN IMMEDIATE")
65
+ if db.execute("PRAGMA user_version").fetchone()[0] not in (0, 1):
66
+ raise OSError("Unsupported interactive share database version")
67
+ db.execute("""CREATE TABLE IF NOT EXISTS interactive_shares(
68
+ id TEXT PRIMARY KEY,session_id TEXT NOT NULL,title TEXT NOT NULL,
69
+ created_at REAL NOT NULL,expires_at REAL,revoked_at REAL,redeemed_at REAL,
70
+ invite_hash BLOB NOT NULL UNIQUE,browser_hash BLOB UNIQUE)""")
71
+ db.execute("CREATE INDEX IF NOT EXISTS interactive_share_session ON interactive_shares(session_id,created_at DESC)")
72
+ # Additive migration: old grants keep their configured-origin behavior.
73
+ # Never run migrations from anonymous read/open_existing requests.
74
+ if "public_origin" not in {row[1] for row in db.execute("PRAGMA table_info(interactive_shares)")}:
75
+ db.execute("ALTER TABLE interactive_shares ADD COLUMN public_origin TEXT NOT NULL DEFAULT ''")
76
+ db.execute("""CREATE TABLE IF NOT EXISTS interactive_uploads(
77
+ id TEXT PRIMARY KEY,share_id TEXT NOT NULL REFERENCES interactive_shares(id),
78
+ name TEXT NOT NULL,media_type TEXT NOT NULL,byte_size INTEGER NOT NULL,
79
+ private_ref TEXT,created_at REAL NOT NULL)""")
80
+ db.execute("CREATE INDEX IF NOT EXISTS interactive_upload_share ON interactive_uploads(share_id)")
81
+ db.execute("""CREATE TABLE IF NOT EXISTS interactive_submissions(
82
+ share_id TEXT NOT NULL REFERENCES interactive_shares(id),request_id TEXT NOT NULL,
83
+ fingerprint BLOB NOT NULL,receipt_json TEXT,created_at REAL NOT NULL,
84
+ PRIMARY KEY(share_id,request_id))""")
85
+ db.execute("PRAGMA user_version=1")
86
+
87
+ @classmethod
88
+ def open_existing(cls, storage_root, *, now=time.time):
89
+ instance = cls.__new__(cls)
90
+ instance.storage_root = Path(storage_root)
91
+ instance.database_path = instance.storage_root / "interactive.sqlite3"
92
+ instance._now = now
93
+ with instance._connection() as db:
94
+ if db.execute("PRAGMA user_version").fetchone()[0] != 1:
95
+ raise OSError("Unsupported interactive share database version")
96
+ return instance
97
+
98
+ @staticmethod
99
+ def metadata(row):
100
+ return {key: row[key] for key in ("id", "title", "created_at", "expires_at", "revoked_at", "redeemed_at")}
101
+
102
+ def create_share(self, session_id, *, title=None, expires_at=None, public_origin=""):
103
+ if not isinstance(session_id, str) or re.fullmatch(r"[A-Za-z0-9_-]{1,128}", session_id) is None:
104
+ raise ValidationError("Invalid chat")
105
+ title = "Shared conversation" if title is None else title
106
+ if not isinstance(title, str) or not title.strip() or len(title) > 256:
107
+ raise ValidationError("Title must contain 1 to 256 characters")
108
+ _utf8_size(title, "Title", 1024)
109
+ now = _timestamp(self._now(), "current time")
110
+ if expires_at is not None and _timestamp(expires_at, "expires_at") <= now:
111
+ raise ValidationError("Expiry must be in the future")
112
+ invite = secrets.token_urlsafe(32)
113
+ share_id = "interactive_" + secrets.token_hex(16)
114
+ with self._connection(write=True) as db:
115
+ db.execute("INSERT INTO interactive_shares(id,session_id,title,created_at,expires_at,invite_hash,public_origin) VALUES(?,?,?,?,?,?,?)",
116
+ (share_id, session_id, title, now, expires_at, token_hash(invite), public_origin))
117
+ row = db.execute("SELECT * FROM interactive_shares WHERE id=?", (share_id,)).fetchone()
118
+ return {**self.metadata(row), "invitation_token": invite}
119
+
120
+ def share_origin(self, share_id):
121
+ with self._connection() as db:
122
+ row = db.execute("SELECT * FROM interactive_shares WHERE id=?", (share_id,)).fetchone()
123
+ if row is None:
124
+ raise Unavailable()
125
+ return row["public_origin"] if "public_origin" in row.keys() else ""
126
+
127
+ def list_shares(self, session_id):
128
+ with self._connection() as db:
129
+ return [self.metadata(row) for row in db.execute(
130
+ "SELECT * FROM interactive_shares WHERE session_id=? ORDER BY created_at DESC,id DESC LIMIT 100", (session_id,))]
131
+
132
+ def revoke_share(self, share_id, *, session_id):
133
+ with self._connection(write=True) as db:
134
+ return db.execute("UPDATE interactive_shares SET revoked_at=COALESCE(revoked_at,?) WHERE id=? AND session_id=?",
135
+ (self._now(), share_id, session_id)).rowcount == 1
136
+
137
+ def redeem(self, share_id, invite):
138
+ digest = token_hash(invite)
139
+ now = self._now()
140
+ with self._connection(write=True) as db:
141
+ db.execute("BEGIN IMMEDIATE")
142
+ # One reusable token admits multiple browsers. Preserve browser_hash
143
+ # so cookies issued by older releases remain valid after an upgrade.
144
+ updated = db.execute("""UPDATE interactive_shares SET redeemed_at=COALESCE(redeemed_at,?)
145
+ WHERE id=? AND invite_hash=? AND revoked_at IS NULL
146
+ AND (expires_at IS NULL OR expires_at>?)""", (now, share_id, digest, now))
147
+ if updated.rowcount != 1:
148
+ raise Unavailable()
149
+ return invite
150
+
151
+ def _authorized(self, db, share_id, browser_token):
152
+ digest = token_hash(browser_token)
153
+ row = db.execute("""SELECT * FROM interactive_shares WHERE id=? AND (browser_hash=? OR invite_hash=?)
154
+ AND redeemed_at IS NOT NULL AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at>?)""",
155
+ (share_id, digest, digest, self._now())).fetchone()
156
+ if row is None:
157
+ raise Unavailable()
158
+ return row
159
+
160
+ def authenticate(self, share_id, browser_token):
161
+ with self._connection() as db:
162
+ row = self._authorized(db, share_id, browser_token)
163
+ return {**self.metadata(row), "session_id": row["session_id"]}
164
+
165
+ def reserve_upload(self, share_id, browser_token, *, name, media_type, byte_size):
166
+ if (not isinstance(name, str) or not 1 <= len(name) <= 160 or name in {".", ".."}
167
+ or any(ord(c) < 32 or c in "/\\" for c in name)):
168
+ raise ValidationError("Use a simple filename without directories")
169
+ _utf8_size(name, "Filename", 640)
170
+ if not isinstance(media_type, str) or re.fullmatch(r"[a-zA-Z0-9!#$&^_.+-]+/[a-zA-Z0-9!#$&^_.+-]+", media_type) is None:
171
+ raise ValidationError("Invalid upload media type")
172
+ if type(byte_size) is not int or not 1 <= byte_size <= MAX_UPLOAD_BYTES:
173
+ raise ValidationError("Files must contain 1 byte to 8 MiB")
174
+ upload_id = "upload_" + secrets.token_hex(16)
175
+ with self._connection(write=True) as db:
176
+ db.execute("BEGIN IMMEDIATE")
177
+ self._authorized(db, share_id, browser_token)
178
+ used = db.execute("SELECT COALESCE(SUM(byte_size),0) FROM interactive_uploads WHERE share_id=?", (share_id,)).fetchone()[0]
179
+ if used + byte_size > MAX_SHARE_UPLOAD_BYTES:
180
+ raise ValidationError("This share has reached its 64 MiB upload storage bound")
181
+ db.execute("INSERT INTO interactive_uploads VALUES(?,?,?,?,?,?,?)",
182
+ (upload_id, share_id, name, media_type, byte_size, None, self._now()))
183
+ return upload_id
184
+
185
+ def complete_upload(self, share_id, browser_token, upload_id, private_ref):
186
+ if not isinstance(private_ref, str) or not private_ref or len(private_ref) > 1024:
187
+ raise ValidationError("Upload was not accepted")
188
+ with self._connection(write=True) as db:
189
+ db.execute("BEGIN IMMEDIATE")
190
+ self._authorized(db, share_id, browser_token)
191
+ if db.execute("UPDATE interactive_uploads SET private_ref=? WHERE id=? AND share_id=? AND private_ref IS NULL",
192
+ (private_ref, upload_id, share_id)).rowcount != 1:
193
+ raise Unavailable()
194
+
195
+ def abandon_upload(self, share_id, upload_id):
196
+ with self._connection(write=True) as db:
197
+ db.execute("DELETE FROM interactive_uploads WHERE id=? AND share_id=? AND private_ref IS NULL", (upload_id, share_id))
198
+
199
+ def upload_refs(self, share_id, browser_token, upload_ids):
200
+ if (not isinstance(upload_ids, list) or len(upload_ids) > 4 or len(set(str(i) for i in upload_ids)) != len(upload_ids)
201
+ or any(not isinstance(i, str) or UPLOAD_ID.fullmatch(i) is None for i in upload_ids)):
202
+ raise ValidationError("Select at most four distinct uploads from this share")
203
+ with self._connection() as db:
204
+ self._authorized(db, share_id, browser_token)
205
+ refs = []
206
+ for upload_id in upload_ids:
207
+ row = db.execute("SELECT private_ref FROM interactive_uploads WHERE id=? AND share_id=? AND private_ref IS NOT NULL",
208
+ (upload_id, share_id)).fetchone()
209
+ if row is None:
210
+ raise Unavailable()
211
+ refs.append(row["private_ref"])
212
+ return refs
213
+
214
+ def reserve_submission(self, share_id, browser_token, request_id, prompt, refs, *, operation="prompt"):
215
+ fingerprint = hashlib.sha256(json.dumps([operation, prompt, refs], ensure_ascii=False, separators=(",", ":")).encode("utf-8")).digest()
216
+ with self._connection(write=True) as db:
217
+ db.execute("BEGIN IMMEDIATE")
218
+ self._authorized(db, share_id, browser_token)
219
+ row = db.execute("SELECT fingerprint,receipt_json FROM interactive_submissions WHERE share_id=? AND request_id=?",
220
+ (share_id, request_id)).fetchone()
221
+ if row is not None:
222
+ if not hmac.compare_digest(bytes(row["fingerprint"]), fingerprint):
223
+ raise Conflict("This request ID was already used for a different message")
224
+ if row["receipt_json"] is None:
225
+ raise Conflict("Message acceptance is indeterminate. Inspect the chat before sending another message; this request will not be retried")
226
+ return json.loads(row["receipt_json"])
227
+ db.execute("INSERT INTO interactive_submissions VALUES(?,?,?,?,?)",
228
+ (share_id, request_id, fingerprint, None, self._now()))
229
+ return None
230
+
231
+ def accept_submission(self, share_id, request_id, receipt):
232
+ # Persist an acceptance fact even if expiry occurred during the callback.
233
+ # A failure leaves the existing pending receipt indeterminate, not retryable.
234
+ encoded = json.dumps(receipt, separators=(",", ":"), allow_nan=False)
235
+ with self._connection(write=True) as db:
236
+ if db.execute("UPDATE interactive_submissions SET receipt_json=? WHERE share_id=? AND request_id=? AND receipt_json IS NULL",
237
+ (encoded, share_id, request_id)).rowcount != 1:
238
+ raise Conflict("Message receipt could not be recorded")
@@ -0,0 +1,425 @@
1
+ """Bounded, display-safe provider command inventories.
2
+
3
+ Provider discovery returns filesystem paths and other provider-owned metadata.
4
+ This module keeps that material on AgentsServer and projects only the small
5
+ allowlist needed by clients. A client selects an opaque id plus the inventory
6
+ revision; it never sends a path or provider command name back to the server.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import hashlib
12
+ import hmac
13
+ import json
14
+ import re
15
+ import unicodedata
16
+ from dataclasses import dataclass
17
+ from pathlib import Path
18
+ from typing import Any, Iterable
19
+
20
+
21
+ MAX_PROVIDER_COMMANDS = 512
22
+ MAX_PROVIDER_COMMAND_NAME_CHARS = 128
23
+ MAX_PROVIDER_COMMAND_LABEL_CHARS = 160
24
+ MAX_PROVIDER_COMMAND_DESCRIPTION_CHARS = 800
25
+ MAX_PROVIDER_COMMAND_PATH_CHARS = 4096
26
+
27
+ _COMMAND_NAME_RE = re.compile(
28
+ rf"[A-Za-z0-9_][A-Za-z0-9_.:-]{{0,{MAX_PROVIDER_COMMAND_NAME_CHARS - 1}}}"
29
+ )
30
+ _LOCATION_RE = re.compile(
31
+ # Provider metadata is optional display copy, so redact the entire suffix
32
+ # after a URL/path marker. Local paths may contain spaces and punctuation;
33
+ # trying to guess their endpoint can expose the unredacted tail.
34
+ r"(?P<url>[A-Za-z][A-Za-z0-9+.-]*://[\s\S]*)"
35
+ r"|(?P<path>(?<![A-Za-z0-9_])(?:~[/\\]|\.\.?[/\\]|[A-Za-z]:[/\\]|/|\\\\)[\s\S]*)"
36
+ )
37
+ _EMAIL_RE = re.compile(
38
+ r"(?<![A-Za-z0-9.!#$%&'*+/=?^_`{|}~-])"
39
+ r"[A-Za-z0-9.!#$%&'*+/=?^_`{|}~-]+@"
40
+ r"[A-Za-z0-9](?:[A-Za-z0-9.-]{0,251}[A-Za-z0-9])?"
41
+ r"(?![A-Za-z0-9.-])"
42
+ )
43
+ _UNSAFE_CATEGORIES = frozenset({"Cc", "Cf", "Cs", "Co", "Cn", "Zl", "Zp"})
44
+ _BIDI_CONTROLS = frozenset({
45
+ "\u061c",
46
+ "\u200e",
47
+ "\u200f",
48
+ "\u202a",
49
+ "\u202b",
50
+ "\u202c",
51
+ "\u202d",
52
+ "\u202e",
53
+ "\u2066",
54
+ "\u2067",
55
+ "\u2068",
56
+ "\u2069",
57
+ })
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class ProviderCommandRecord:
62
+ public: dict[str, Any]
63
+ native: dict[str, str]
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class ProviderCommandInventory:
68
+ backend: str
69
+ revision: str
70
+ records: tuple[ProviderCommandRecord, ...]
71
+ truncated: bool = False
72
+
73
+ @property
74
+ def commands(self) -> list[dict[str, Any]]:
75
+ return [dict(record.public) for record in self.records]
76
+
77
+ def resolve(self, command_id: str) -> ProviderCommandRecord | None:
78
+ for record in self.records:
79
+ if record.public["id"] == command_id:
80
+ return record
81
+ return None
82
+
83
+
84
+ def canonical_provider_command_name(value: Any) -> str | None:
85
+ if not isinstance(value, str):
86
+ return None
87
+ candidate = unicodedata.normalize("NFC", value)
88
+ if candidate != value or candidate != candidate.strip():
89
+ return None
90
+ if _COMMAND_NAME_RE.fullmatch(candidate) is None:
91
+ return None
92
+ return candidate
93
+
94
+
95
+ def _replace_local_path(match: re.Match[str]) -> str:
96
+ url = match.group("url")
97
+ if url is not None:
98
+ # Even HTTP(S) user info, hosts, paths and query parameters can contain
99
+ # credentials or personally identifying local infrastructure data.
100
+ return "<url>"
101
+ return "<path>"
102
+
103
+
104
+ def sanitize_provider_command_text(value: Any, max_chars: int) -> str:
105
+ if not isinstance(value, str):
106
+ return ""
107
+ normalized = unicodedata.normalize("NFC", value)
108
+ safe = "".join(
109
+ character
110
+ for character in normalized
111
+ if character not in _BIDI_CONTROLS
112
+ and (
113
+ character in {"\n", "\r", "\t"}
114
+ or unicodedata.category(character) not in _UNSAFE_CATEGORIES
115
+ )
116
+ )
117
+ safe = _EMAIL_RE.sub("<email>", safe)
118
+ safe = _LOCATION_RE.sub(_replace_local_path, safe)
119
+ safe = " ".join(safe.split())
120
+ if len(safe) <= max_chars:
121
+ return safe
122
+ return safe[: max(0, max_chars - 1)].rstrip() + "…"
123
+
124
+
125
+ def _opaque_command_id(
126
+ selector_secret: str,
127
+ binding_context: str,
128
+ backend: str,
129
+ cwd: str,
130
+ name: str,
131
+ native_identity: str,
132
+ ) -> str:
133
+ payload = json.dumps(
134
+ [
135
+ "provider-command-v1",
136
+ binding_context,
137
+ backend,
138
+ cwd,
139
+ name,
140
+ native_identity,
141
+ ],
142
+ ensure_ascii=False,
143
+ separators=(",", ":"),
144
+ ).encode("utf-8")
145
+ return "pcmd_" + hmac.new(
146
+ selector_secret.encode("utf-8"),
147
+ payload,
148
+ hashlib.sha256,
149
+ ).hexdigest()[:32]
150
+
151
+
152
+ def _inventory_revision(
153
+ selector_secret: str,
154
+ binding_context: str,
155
+ backend: str,
156
+ cwd: str,
157
+ records: Iterable[ProviderCommandRecord],
158
+ *,
159
+ truncated: bool,
160
+ ) -> str:
161
+ payload = {
162
+ "version": 1,
163
+ "binding_context": binding_context,
164
+ "backend": backend,
165
+ "cwd": cwd,
166
+ "truncated": truncated,
167
+ # Provider order is presentation-only and may change across otherwise
168
+ # identical cold connects. A semantic inventory revision must not
169
+ # invalidate a queued selection solely because that order changed.
170
+ "commands": sorted(
171
+ (record.public for record in records),
172
+ key=lambda item: str(item.get("id") or ""),
173
+ ),
174
+ }
175
+ encoded = json.dumps(
176
+ payload,
177
+ ensure_ascii=False,
178
+ sort_keys=True,
179
+ separators=(",", ":"),
180
+ ).encode("utf-8")
181
+ return "pcmdrev_" + hmac.new(
182
+ selector_secret.encode("utf-8"),
183
+ encoded,
184
+ hashlib.sha256,
185
+ ).hexdigest()[:32]
186
+
187
+
188
+ def _inventory(
189
+ selector_secret: str,
190
+ binding_context: str,
191
+ backend: str,
192
+ cwd: str,
193
+ records: list[ProviderCommandRecord],
194
+ *,
195
+ truncated: bool,
196
+ ) -> ProviderCommandInventory:
197
+ frozen = tuple(records)
198
+ return ProviderCommandInventory(
199
+ backend=backend,
200
+ revision=_inventory_revision(
201
+ selector_secret,
202
+ binding_context,
203
+ backend,
204
+ cwd,
205
+ frozen,
206
+ truncated=truncated,
207
+ ),
208
+ records=frozen,
209
+ truncated=truncated,
210
+ )
211
+
212
+
213
+ def codex_provider_command_inventory(
214
+ value: Any,
215
+ *,
216
+ cwd: str,
217
+ selector_secret: str,
218
+ binding_context: str,
219
+ limit: int = MAX_PROVIDER_COMMANDS,
220
+ ) -> ProviderCommandInventory:
221
+ """Project one cwd entry from Codex ``skills/list``.
222
+
223
+ The provider-resolved SKILL.md path remains only in ``record.native`` and
224
+ is never part of ``record.public``.
225
+ """
226
+
227
+ raw_entries = value.get("data") if isinstance(value, dict) else None
228
+ matching: dict[str, Any] | None = None
229
+ if isinstance(raw_entries, list):
230
+ normalized_cwd = str(Path(cwd).resolve())
231
+ for entry in raw_entries[:32]:
232
+ if not isinstance(entry, dict):
233
+ continue
234
+ raw_cwd = entry.get("cwd")
235
+ if not isinstance(raw_cwd, str):
236
+ continue
237
+ try:
238
+ candidate_cwd = str(Path(raw_cwd).resolve())
239
+ except (OSError, RuntimeError):
240
+ continue
241
+ if candidate_cwd == normalized_cwd:
242
+ matching = entry
243
+ break
244
+ raw_skills = matching.get("skills") if isinstance(matching, dict) else None
245
+ if not isinstance(raw_skills, list):
246
+ raw_skills = []
247
+ bounded_limit = max(1, min(int(limit), MAX_PROVIDER_COMMANDS))
248
+ records: list[ProviderCommandRecord] = []
249
+ seen_ids: set[str] = set()
250
+ scanned = 0
251
+ for raw in raw_skills:
252
+ if scanned >= bounded_limit:
253
+ break
254
+ scanned += 1
255
+ if not isinstance(raw, dict) or raw.get("enabled") is False:
256
+ continue
257
+ name = canonical_provider_command_name(raw.get("name"))
258
+ raw_path = raw.get("path")
259
+ if (
260
+ name is None
261
+ or not isinstance(raw_path, str)
262
+ or not raw_path
263
+ or len(raw_path) > MAX_PROVIDER_COMMAND_PATH_CHARS
264
+ or "\x00" in raw_path
265
+ or not Path(raw_path).is_absolute()
266
+ ):
267
+ continue
268
+ interface = raw.get("interface")
269
+ if not isinstance(interface, dict):
270
+ interface = {}
271
+ label = sanitize_provider_command_text(
272
+ interface.get("displayName") or name,
273
+ MAX_PROVIDER_COMMAND_LABEL_CHARS,
274
+ ) or name
275
+ description = sanitize_provider_command_text(
276
+ interface.get("shortDescription") or raw.get("description"),
277
+ MAX_PROVIDER_COMMAND_DESCRIPTION_CHARS,
278
+ )
279
+ raw_scope = str(raw.get("scope") or "").strip().lower()
280
+ scope = raw_scope if raw_scope in {"project", "user", "admin", "system"} else None
281
+ command_id = _opaque_command_id(
282
+ selector_secret,
283
+ binding_context,
284
+ "codex",
285
+ cwd,
286
+ name,
287
+ raw_path,
288
+ )
289
+ if command_id in seen_ids:
290
+ continue
291
+ seen_ids.add(command_id)
292
+ public = {
293
+ "id": command_id,
294
+ "name": name,
295
+ "label": label,
296
+ "description": description,
297
+ "scope": scope,
298
+ "source": "plugin" if raw.get("pluginId") else "codex",
299
+ "kind": "skill",
300
+ # AgentsDock presents one slash-command palette for every provider.
301
+ # The server converts this slash token into Codex's native text +
302
+ # structured skill input only after validating the opaque id.
303
+ "invocation": f"/{name}",
304
+ }
305
+ records.append(
306
+ ProviderCommandRecord(
307
+ public=public,
308
+ native={"name": name, "path": raw_path},
309
+ )
310
+ )
311
+ return _inventory(
312
+ selector_secret,
313
+ binding_context,
314
+ "codex",
315
+ cwd,
316
+ records,
317
+ truncated=len(raw_skills) > bounded_limit,
318
+ )
319
+
320
+
321
+ def claude_provider_command_inventory(
322
+ value: Any,
323
+ *,
324
+ cwd: str,
325
+ selector_secret: str,
326
+ binding_context: str,
327
+ control_generation: str | None = None,
328
+ limit: int = MAX_PROVIDER_COMMANDS,
329
+ ) -> ProviderCommandInventory:
330
+ """Project only Claude's provider-reported command metadata.
331
+
332
+ ``get_server_info`` also contains account data. Callers should already
333
+ project it inside the SDK actor, and this second allowlist prevents an
334
+ accidental raw top-level return from reaching the API.
335
+ """
336
+
337
+ raw_commands = value.get("commands") if isinstance(value, dict) else None
338
+ if not isinstance(raw_commands, list):
339
+ raw_commands = []
340
+ bounded_limit = max(1, min(int(limit), MAX_PROVIDER_COMMANDS))
341
+ records: list[ProviderCommandRecord] = []
342
+ seen_names: set[str] = set()
343
+ scanned = 0
344
+ for raw in raw_commands:
345
+ if scanned >= bounded_limit:
346
+ break
347
+ scanned += 1
348
+ if not isinstance(raw, dict):
349
+ continue
350
+ name = canonical_provider_command_name(raw.get("name"))
351
+ if name is None or name in seen_names:
352
+ continue
353
+ seen_names.add(name)
354
+ description = sanitize_provider_command_text(
355
+ raw.get("description"),
356
+ MAX_PROVIDER_COMMAND_DESCRIPTION_CHARS,
357
+ )
358
+ argument_hint = sanitize_provider_command_text(
359
+ raw.get("argumentHint"),
360
+ MAX_PROVIDER_COMMAND_LABEL_CHARS,
361
+ )
362
+ if argument_hint:
363
+ description = (
364
+ f"{description} {argument_hint}".strip()
365
+ )[:MAX_PROVIDER_COMMAND_DESCRIPTION_CHARS]
366
+ command_id = _opaque_command_id(
367
+ selector_secret,
368
+ binding_context,
369
+ "claude",
370
+ cwd,
371
+ name,
372
+ name,
373
+ )
374
+ public = {
375
+ "id": command_id,
376
+ "name": name,
377
+ "label": name,
378
+ "description": description,
379
+ "scope": None,
380
+ "source": "claude",
381
+ "kind": "command",
382
+ "invocation": f"/{name}",
383
+ }
384
+ records.append(
385
+ ProviderCommandRecord(
386
+ public=public,
387
+ native={
388
+ "name": name,
389
+ **(
390
+ {"control_generation": control_generation}
391
+ if control_generation
392
+ else {}
393
+ ),
394
+ },
395
+ )
396
+ )
397
+ return _inventory(
398
+ selector_secret,
399
+ binding_context,
400
+ "claude",
401
+ cwd,
402
+ records,
403
+ truncated=bool(
404
+ isinstance(value, dict)
405
+ and value.get("_agentsdock_provider_commands_truncated")
406
+ )
407
+ or len(raw_commands) > bounded_limit,
408
+ )
409
+
410
+
411
+ def empty_provider_command_inventory(
412
+ backend: str,
413
+ *,
414
+ cwd: str,
415
+ selector_secret: str,
416
+ binding_context: str,
417
+ ) -> ProviderCommandInventory:
418
+ return _inventory(
419
+ selector_secret,
420
+ binding_context,
421
+ backend,
422
+ cwd,
423
+ [],
424
+ truncated=False,
425
+ )