@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.
- package/LICENSE +202 -0
- package/NOTICE +19 -0
- package/README.md +60 -0
- package/npm/cli.cjs +289 -0
- package/package.json +32 -0
- package/server/LICENSE +202 -0
- package/server/NOTICE +19 -0
- package/server/VERSION +1 -0
- package/server/activation_transaction.py +2458 -0
- package/server/agent_server.py +93226 -0
- package/server/agentsdock_chats.py +1237 -0
- package/server/agentsdock_emergency.py +338 -0
- package/server/agentsdock_jobs.py +640 -0
- package/server/agentsdock_mail.py +325 -0
- package/server/agentsdock_publish.py +404 -0
- package/server/agentsdock_team.py +793 -0
- package/server/agentsdock_team_hub/__init__.py +33 -0
- package/server/agentsdock_team_hub/auth.py +710 -0
- package/server/agentsdock_team_hub/cli.py +647 -0
- package/server/agentsdock_team_hub/database.py +310 -0
- package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
- package/server/agentsdock_team_hub/mail_hints.py +201 -0
- package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
- package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
- package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
- package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
- package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
- package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
- package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
- package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
- package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
- package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
- package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
- package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
- package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
- package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
- package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
- package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
- package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
- package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
- package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
- package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
- package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
- package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
- package/server/agentsdock_team_hub/notification_hints.py +306 -0
- package/server/agentsdock_team_hub/secure_peer.py +11215 -0
- package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
- package/server/agentsdock_team_hub/security.py +318 -0
- package/server/agentsdock_team_hub/service.py +1737 -0
- package/server/agentsdock_team_hub/store.py +16276 -0
- package/server/chat_mailbox.py +448 -0
- package/server/claude_background_reconciliation.py +92 -0
- package/server/claude_goals.py +245 -0
- package/server/claude_history_provenance.py +137 -0
- package/server/claude_history_repair.py +1601 -0
- package/server/claude_sdk_client.py +3731 -0
- package/server/claude_side_question.py +212 -0
- package/server/codex_app_server.py +3030 -0
- package/server/codex_auth.py +93 -0
- package/server/codex_history_repair.py +1247 -0
- package/server/codex_provider.py +1120 -0
- package/server/codex_side_question.py +301 -0
- package/server/cursor_agent_client.py +654 -0
- package/server/cursor_process_guard.py +347 -0
- package/server/execution_activation.py +610 -0
- package/server/execution_control.py +233 -0
- package/server/execution_durability.py +184 -0
- package/server/execution_http.py +256 -0
- package/server/execution_install.py +792 -0
- package/server/execution_legacy_runner.py +406 -0
- package/server/execution_maintenance.py +137 -0
- package/server/execution_manage.py +615 -0
- package/server/execution_ownership.py +83 -0
- package/server/execution_preparation.py +362 -0
- package/server/execution_recovery.py +845 -0
- package/server/execution_recovery_status.py +195 -0
- package/server/execution_service.py +387 -0
- package/server/execution_transport.py +622 -0
- package/server/execution_uninstall.py +406 -0
- package/server/execution_update_status.py +49 -0
- package/server/install.sh +7074 -0
- package/server/interactive_chat_controls.py +153 -0
- package/server/interactive_chat_native.py +66 -0
- package/server/interactive_chat_projection.py +152 -0
- package/server/interactive_chat_runtime.py +132 -0
- package/server/interactive_chat_share_routes.py +534 -0
- package/server/interactive_chat_share_web.py +12 -0
- package/server/interactive_chat_shares.py +238 -0
- package/server/provider_commands.py +425 -0
- package/server/public_chat_share_routes.py +426 -0
- package/server/public_chat_shares.py +759 -0
- package/server/public_chat_transcript.py +357 -0
- package/server/pyproject.toml +29 -0
- package/server/release-public-key.pem +3 -0
- package/server/secure_peer_delivery.py +1236 -0
- package/server/secure_peer_runtime.py +7694 -0
- package/server/shared_chat_video_stream.py +262 -0
- package/server/shared_chat_videos.py +155 -0
- package/server/side_questions.py +594 -0
- package/server/team_hub_host.py +1354 -0
- package/server/team_mail_grants.py +136 -0
- package/server/team_mail_runtime.py +302 -0
- package/server/team_mail_websocket.py +211 -0
- package/server/title_generation.py +333 -0
- package/server/uninstall.sh +359 -0
- package/server/update_handoff.py +237 -0
- package/server/update_preparation.py +264 -0
- package/server/update_recovery.py +140 -0
- package/server/update_runner.py +1819 -0
- package/server/uv.lock +1101 -0
- 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
|
+
)
|