@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,83 @@
|
|
|
1
|
+
"""One process owns a state directory across legacy and split entry points.
|
|
2
|
+
|
|
3
|
+
The descriptor stays open for the process lifetime, including bounded shutdown
|
|
4
|
+
stragglers. Versions released before this lease existed do not participate;
|
|
5
|
+
initial migration must separately prove that the old service has stopped.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
import fcntl
|
|
11
|
+
import os
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
import stat
|
|
14
|
+
import threading
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass(frozen=True)
|
|
18
|
+
class StateOwnership:
|
|
19
|
+
path: Path
|
|
20
|
+
descriptor: int
|
|
21
|
+
pid: int
|
|
22
|
+
identity: tuple[int, int]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
_OWNERS: dict[Path, StateOwnership] = {}
|
|
26
|
+
_LOCK = threading.Lock()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _owned_directory(path: Path) -> None:
|
|
30
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
31
|
+
info = path.lstat()
|
|
32
|
+
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid()
|
|
33
|
+
or stat.S_IMODE(info.st_mode) & 0o022):
|
|
34
|
+
raise RuntimeError("State ownership directory must be owned and not writable by other users")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _validate_file(info: os.stat_result) -> tuple[int, int]:
|
|
38
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
|
|
39
|
+
or stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1):
|
|
40
|
+
raise RuntimeError("State ownership lock must be an owned 0600 regular file")
|
|
41
|
+
return info.st_dev, info.st_ino
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def acquire_state_ownership(state_directory: Path) -> StateOwnership:
|
|
45
|
+
"""Acquire before loading state/providers; repeated acquisition is harmless."""
|
|
46
|
+
root = Path(state_directory).expanduser().resolve()
|
|
47
|
+
path = root / "admin" / "state-owner.lock"
|
|
48
|
+
with _LOCK:
|
|
49
|
+
existing = _OWNERS.get(path)
|
|
50
|
+
if existing is not None:
|
|
51
|
+
if existing.pid != os.getpid():
|
|
52
|
+
raise RuntimeError("A forked process cannot reuse its parent's state ownership")
|
|
53
|
+
if (_validate_file(path.lstat()) != existing.identity
|
|
54
|
+
or _validate_file(os.fstat(existing.descriptor)) != existing.identity):
|
|
55
|
+
raise RuntimeError("State ownership lock changed while held")
|
|
56
|
+
return existing
|
|
57
|
+
_owned_directory(root)
|
|
58
|
+
_owned_directory(path.parent)
|
|
59
|
+
descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
60
|
+
try:
|
|
61
|
+
identity = _validate_file(os.fstat(descriptor))
|
|
62
|
+
os.set_inheritable(descriptor, False)
|
|
63
|
+
try:
|
|
64
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
65
|
+
except BlockingIOError as error:
|
|
66
|
+
raise RuntimeError("Another AgentsServer process already owns this state directory") from error
|
|
67
|
+
if _validate_file(path.lstat()) != identity:
|
|
68
|
+
raise RuntimeError("State ownership lock changed during acquisition")
|
|
69
|
+
owner = StateOwnership(path, descriptor, os.getpid(), identity)
|
|
70
|
+
_OWNERS[path] = owner
|
|
71
|
+
return owner
|
|
72
|
+
except BaseException:
|
|
73
|
+
os.close(descriptor)
|
|
74
|
+
raise
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _release_state_ownership_for_tests(owner: StateOwnership) -> None:
|
|
78
|
+
"""Isolated unit-test cleanup only; production must retain its descriptor."""
|
|
79
|
+
with _LOCK:
|
|
80
|
+
if owner.pid != os.getpid() or _OWNERS.get(owner.path) is not owner:
|
|
81
|
+
raise RuntimeError("Test cannot release another process's state ownership")
|
|
82
|
+
del _OWNERS[owner.path]
|
|
83
|
+
os.close(owner.descriptor)
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"""Read-only activation proof for a completely prepared, inactive release.
|
|
2
|
+
|
|
3
|
+
The caller verifies the signed archive and supplies its digest plus a source
|
|
4
|
+
inventory captured from that verified archive *before* dependency installation.
|
|
5
|
+
This receipt is local staging evidence, not a replacement for signature checks.
|
|
6
|
+
Writing it never changes current, configuration, service jobs, or chat state.
|
|
7
|
+
Validate again while holding the installation lock immediately before activation.
|
|
8
|
+
Use ``python -B`` when executing a helper inside the candidate release.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from pathlib import Path, PurePosixPath
|
|
18
|
+
import re
|
|
19
|
+
import secrets
|
|
20
|
+
import stat
|
|
21
|
+
import sys
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
from activation_transaction import _volume_uuid
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
FORMAT = 1
|
|
28
|
+
MAX_RECEIPT_BYTES = 32 * 1024 * 1024
|
|
29
|
+
MAX_FILE_BYTES = 512 * 1024 * 1024
|
|
30
|
+
MAX_TREE_BYTES = 8 * 1024 * 1024 * 1024
|
|
31
|
+
MAX_ENTRIES = 250_000
|
|
32
|
+
_DIGEST = re.compile(r"[0-9a-f]{64}")
|
|
33
|
+
_VERSION = re.compile(r"[0-9]+\.[0-9]+\.[0-9]+(?:-beta\.[0-9]+)?")
|
|
34
|
+
_REQUIRED = {"VERSION", "agent_server.py", "execution_service.py", "pyproject.toml", "uv.lock"}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _path(value: str | Path) -> Path:
|
|
38
|
+
if not isinstance(value, (str, Path)):
|
|
39
|
+
raise ValueError("preparation path must be text")
|
|
40
|
+
path = Path(value)
|
|
41
|
+
if (not path.is_absolute() or ".." in path.parts or any(ord(c) < 32 for c in str(path))
|
|
42
|
+
or path.resolve(strict=False) != path):
|
|
43
|
+
raise ValueError("preparation path must be absolute without symlink ancestors")
|
|
44
|
+
return path
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _owned_directory(path: Path, *, private: bool = False) -> os.stat_result:
|
|
48
|
+
info = path.lstat()
|
|
49
|
+
mode = stat.S_IMODE(info.st_mode)
|
|
50
|
+
if (not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid()
|
|
51
|
+
or mode & 0o022 or mode & 0o500 != 0o500 or (private and mode != 0o700)):
|
|
52
|
+
raise PermissionError("preparation directory is not safely owned")
|
|
53
|
+
return info
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _stable(info: os.stat_result) -> tuple[int, ...]:
|
|
57
|
+
return (info.st_dev, info.st_ino, info.st_mode, info.st_uid, info.st_size,
|
|
58
|
+
info.st_mtime_ns, info.st_ctime_ns, info.st_nlink)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _file(path: Path, *, private: bool = False, dependency: bool = False,
|
|
62
|
+
external: bool = False, contents: bool = False,
|
|
63
|
+
uv_lock: bool = False) -> tuple[dict, bytes]:
|
|
64
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK)
|
|
65
|
+
try:
|
|
66
|
+
before = os.fstat(fd)
|
|
67
|
+
mode = stat.S_IMODE(before.st_mode)
|
|
68
|
+
maximum = MAX_RECEIPT_BYTES if private else MAX_FILE_BYTES
|
|
69
|
+
owners = {os.getuid(), 0} if external else {os.getuid()}
|
|
70
|
+
if (not stat.S_ISREG(before.st_mode) or before.st_uid not in owners
|
|
71
|
+
or (mode & 0o022 and not uv_lock) or mode & 0o7000 or (private and mode != 0o600)
|
|
72
|
+
or (uv_lock and (before.st_size != 0 or before.st_nlink != 1))
|
|
73
|
+
or (not dependency and before.st_nlink != 1) or before.st_size > maximum):
|
|
74
|
+
raise PermissionError("preparation file is unsafe or too large")
|
|
75
|
+
digest = hashlib.sha256()
|
|
76
|
+
data = bytearray()
|
|
77
|
+
size = 0
|
|
78
|
+
while chunk := os.read(fd, 1024 * 1024):
|
|
79
|
+
size += len(chunk)
|
|
80
|
+
if size > maximum:
|
|
81
|
+
raise ValueError("preparation file exceeds size limit")
|
|
82
|
+
digest.update(chunk)
|
|
83
|
+
if contents:
|
|
84
|
+
data.extend(chunk)
|
|
85
|
+
if (_stable(before) != _stable(os.fstat(fd)) or _stable(before) != _stable(path.lstat())
|
|
86
|
+
or size != before.st_size):
|
|
87
|
+
raise RuntimeError("preparation file changed while reading")
|
|
88
|
+
return {"kind": "file", "mode": mode, "size": size, "sha256": digest.hexdigest()}, bytes(data)
|
|
89
|
+
finally:
|
|
90
|
+
os.close(fd)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _json(path: Path, *, private: bool = True) -> dict:
|
|
94
|
+
_metadata, data = _file(path, private=private, contents=True)
|
|
95
|
+
def unique(pairs):
|
|
96
|
+
value = {}
|
|
97
|
+
for key, item in pairs:
|
|
98
|
+
if key in value:
|
|
99
|
+
raise ValueError("duplicate preparation JSON field")
|
|
100
|
+
value[key] = item
|
|
101
|
+
return value
|
|
102
|
+
value = json.loads(data, object_pairs_hook=unique)
|
|
103
|
+
if not isinstance(value, dict):
|
|
104
|
+
raise ValueError("preparation JSON must be an object")
|
|
105
|
+
return value
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _source_inventory(value: Any) -> dict[str, dict]:
|
|
109
|
+
if not isinstance(value, dict) or set(value) != {"format", "files"} or type(value["format"]) is not int or value["format"] != FORMAT:
|
|
110
|
+
raise ValueError("invalid source inventory format")
|
|
111
|
+
files = value["files"]
|
|
112
|
+
if not isinstance(files, dict) or not _REQUIRED <= files.keys() or len(files) > MAX_ENTRIES:
|
|
113
|
+
raise ValueError("source inventory is incomplete")
|
|
114
|
+
for name, entry in files.items():
|
|
115
|
+
if (not isinstance(name, str) or not name or PurePosixPath(name).is_absolute()
|
|
116
|
+
or str(PurePosixPath(name)) != name or any(part in {"..", ".venv", "__pycache__"} for part in PurePosixPath(name).parts)
|
|
117
|
+
or any(ord(c) < 32 for c in name) or "\\" in name):
|
|
118
|
+
raise ValueError("unsafe source inventory member")
|
|
119
|
+
if (not isinstance(entry, dict) or set(entry) != {"sha256", "size"}
|
|
120
|
+
or not isinstance(entry["sha256"], str) or not _DIGEST.fullmatch(entry["sha256"])
|
|
121
|
+
or type(entry["size"]) is not int or not 0 <= entry["size"] <= MAX_FILE_BYTES):
|
|
122
|
+
raise ValueError("invalid source inventory entry")
|
|
123
|
+
return files
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _pins(version: str, api_contract: int, archive_sha256: str) -> None:
|
|
127
|
+
if not isinstance(version, str) or not _VERSION.fullmatch(version):
|
|
128
|
+
raise ValueError("invalid preparation version")
|
|
129
|
+
if type(api_contract) is not int or api_contract < 1:
|
|
130
|
+
raise ValueError("invalid preparation API contract")
|
|
131
|
+
if not isinstance(archive_sha256, str) or not _DIGEST.fullmatch(archive_sha256):
|
|
132
|
+
raise ValueError("invalid preparation archive digest")
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _binding(path: Path) -> dict:
|
|
136
|
+
info = _owned_directory(path)
|
|
137
|
+
return {"device": info.st_dev, "inode": info.st_ino,
|
|
138
|
+
"volume_uuid": _volume_uuid(path, info) if sys.platform == "darwin" else None}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _check_binding(path: Path, saved: Any) -> None:
|
|
142
|
+
if (not isinstance(saved, dict) or set(saved) != {"device", "inode", "volume_uuid"}
|
|
143
|
+
or any(type(saved[key]) is not int or saved[key] < 1 for key in ("device", "inode"))):
|
|
144
|
+
raise ValueError("invalid preparation directory identity")
|
|
145
|
+
current = _binding(path)
|
|
146
|
+
if current["inode"] != saved["inode"]:
|
|
147
|
+
raise RuntimeError("prepared directory identity changed")
|
|
148
|
+
if sys.platform == "darwin":
|
|
149
|
+
# st_dev changes across reboot/remount; the UUID and inode must not.
|
|
150
|
+
if not saved["volume_uuid"] or saved["volume_uuid"] != current["volume_uuid"]:
|
|
151
|
+
raise RuntimeError("prepared filesystem identity changed")
|
|
152
|
+
elif saved["volume_uuid"] is not None or saved["device"] != current["device"]:
|
|
153
|
+
raise RuntimeError("prepared filesystem identity changed")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _candidate(root: Path, candidate: Path) -> None:
|
|
157
|
+
_owned_directory(root)
|
|
158
|
+
_owned_directory(root / "releases")
|
|
159
|
+
if candidate.parent != root / "releases":
|
|
160
|
+
raise ValueError("candidate must be a direct retained release")
|
|
161
|
+
_owned_directory(candidate)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _inventory_tree(candidate: Path, source: dict[str, dict]) -> dict[str, dict]:
|
|
165
|
+
result: dict[str, dict] = {}
|
|
166
|
+
total = 0
|
|
167
|
+
parents = {parent.as_posix() for name in source for parent in PurePosixPath(name).parents if str(parent) != "."}
|
|
168
|
+
|
|
169
|
+
def visit(directory: Path) -> None:
|
|
170
|
+
nonlocal total
|
|
171
|
+
before = _owned_directory(directory)
|
|
172
|
+
for child in sorted(directory.iterdir()):
|
|
173
|
+
relative = child.relative_to(candidate).as_posix()
|
|
174
|
+
parts = PurePosixPath(relative).parts
|
|
175
|
+
if any(ord(character) < 32 for character in relative):
|
|
176
|
+
raise ValueError("prepared runtime member contains control characters")
|
|
177
|
+
dependency = parts[0] == ".venv"
|
|
178
|
+
cache = ((parts[-1] == "__pycache__" and (len(parts) == 1 or str(PurePosixPath(relative).parent) in parents))
|
|
179
|
+
or (len(parts) >= 2 and parts[-2] == "__pycache__" and child.suffix in {".pyc", ".pyo"}
|
|
180
|
+
and (len(parts) == 2 or str(PurePosixPath(relative).parent.parent) in parents)))
|
|
181
|
+
if not dependency and not cache and relative not in source and relative not in parents:
|
|
182
|
+
raise ValueError("unexpected prepared runtime member")
|
|
183
|
+
info = child.lstat()
|
|
184
|
+
if stat.S_ISLNK(info.st_mode):
|
|
185
|
+
if not dependency or info.st_uid != os.getuid():
|
|
186
|
+
raise PermissionError("prepared source links are forbidden")
|
|
187
|
+
target = os.readlink(child)
|
|
188
|
+
resolved = child.resolve(strict=True)
|
|
189
|
+
if resolved.is_dir():
|
|
190
|
+
if candidate not in resolved.parents:
|
|
191
|
+
raise ValueError("external prepared dependency directory link")
|
|
192
|
+
_owned_directory(resolved)
|
|
193
|
+
target_info = {"kind": "directory"}
|
|
194
|
+
else:
|
|
195
|
+
if candidate not in resolved.parents:
|
|
196
|
+
# uv normally links only the Python interpreter out of
|
|
197
|
+
# a venv. Never scan arbitrary external files (including
|
|
198
|
+
# provider credentials) through a substituted link.
|
|
199
|
+
if (len(parts) != 3 or parts[:2] != (".venv", "bin")
|
|
200
|
+
or not re.fullmatch(r"python(?:[0-9]+(?:\.[0-9]+)*)?", parts[2])
|
|
201
|
+
or not re.fullmatch(r"python(?:[0-9]+(?:\.[0-9]+)*)?", resolved.name)
|
|
202
|
+
or not os.access(resolved, os.X_OK)):
|
|
203
|
+
raise ValueError("external prepared dependency link is not an interpreter")
|
|
204
|
+
target_info, _data = _file(resolved, dependency=True, external=True)
|
|
205
|
+
total += target_info["size"]
|
|
206
|
+
if _stable(info) != _stable(child.lstat()) or target != os.readlink(child):
|
|
207
|
+
raise RuntimeError("prepared dependency link changed")
|
|
208
|
+
entry = {"kind": "symlink", "target": target, "resolved": str(resolved), "content": target_info}
|
|
209
|
+
elif stat.S_ISDIR(info.st_mode):
|
|
210
|
+
_owned_directory(child)
|
|
211
|
+
entry = {"kind": "directory", "mode": stat.S_IMODE(info.st_mode)}
|
|
212
|
+
else:
|
|
213
|
+
# uv intentionally creates this empty advisory lock as0666,
|
|
214
|
+
# even with a private staging parent. It contains no runtime
|
|
215
|
+
# code; no other writable dependency receives this exception.
|
|
216
|
+
entry, _data = _file(child, dependency=dependency, uv_lock=relative == ".venv/.lock")
|
|
217
|
+
total += entry["size"]
|
|
218
|
+
result[relative] = entry
|
|
219
|
+
if len(result) > MAX_ENTRIES or total > MAX_TREE_BYTES:
|
|
220
|
+
raise ValueError("prepared runtime inventory exceeds limit")
|
|
221
|
+
if stat.S_ISDIR(info.st_mode):
|
|
222
|
+
visit(child)
|
|
223
|
+
if _stable(before) != _stable(directory.lstat()):
|
|
224
|
+
raise RuntimeError("prepared directory changed while reading")
|
|
225
|
+
|
|
226
|
+
visit(candidate)
|
|
227
|
+
for name, expected in source.items():
|
|
228
|
+
actual = result.get(name, {})
|
|
229
|
+
if (actual.get("kind") != "file" or actual.get("sha256") != expected["sha256"]
|
|
230
|
+
or actual.get("size") != expected["size"]):
|
|
231
|
+
raise RuntimeError("prepared source differs from verified archive")
|
|
232
|
+
python = candidate / ".venv/bin/python"
|
|
233
|
+
if not python.is_file() or not os.access(python, os.X_OK):
|
|
234
|
+
raise ValueError("prepared runtime interpreter is missing")
|
|
235
|
+
return result
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _check_release(candidate: Path, version: str, api_contract: int) -> None:
|
|
239
|
+
_metadata, data = _file(candidate / "VERSION", contents=True)
|
|
240
|
+
if data.decode().strip() != version:
|
|
241
|
+
raise ValueError("prepared runtime version differs from signed candidate")
|
|
242
|
+
_metadata, data = _file(candidate / "agent_server.py", contents=True)
|
|
243
|
+
contracts = re.findall(rb"^API_CONTRACT_VERSION\s*=\s*([0-9]+)\s*$", data, re.MULTILINE)
|
|
244
|
+
if len(contracts) != 1 or int(contracts[0]) != api_contract:
|
|
245
|
+
raise ValueError("prepared runtime API differs from signed candidate")
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _receipt_path(root: Path, receipt: Path, *, create_parent: bool = False) -> None:
|
|
249
|
+
if receipt.parent not in {root, root / ".prepared-receipts"}:
|
|
250
|
+
raise ValueError("preparation receipt must be inside its installation")
|
|
251
|
+
if create_parent and not receipt.parent.exists():
|
|
252
|
+
receipt.parent.mkdir(mode=0o700)
|
|
253
|
+
_fsync(root)
|
|
254
|
+
_owned_directory(receipt.parent, private=True)
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _fsync(path: Path) -> None:
|
|
258
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
259
|
+
try:
|
|
260
|
+
os.fsync(fd)
|
|
261
|
+
finally:
|
|
262
|
+
os.close(fd)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def write_prepared(*, candidate: Path, root: Path, version: str, api_contract: int,
|
|
266
|
+
archive_sha256: str, inventory: Path, output: Path) -> dict:
|
|
267
|
+
"""Freeze verified source and the complete staged tree; never overwrite a receipt."""
|
|
268
|
+
root, candidate, inventory, output = map(_path, (root, candidate, inventory, output))
|
|
269
|
+
_pins(version, api_contract, archive_sha256)
|
|
270
|
+
_candidate(root, candidate)
|
|
271
|
+
source = _json(inventory)
|
|
272
|
+
files = _source_inventory(source)
|
|
273
|
+
identities = {"root": _binding(root), "releases": _binding(root / "releases"), "candidate": _binding(candidate)}
|
|
274
|
+
tree = _inventory_tree(candidate, files)
|
|
275
|
+
_check_release(candidate, version, api_contract)
|
|
276
|
+
for name, path in (("root", root), ("releases", root / "releases"), ("candidate", candidate)):
|
|
277
|
+
_check_binding(path, identities[name])
|
|
278
|
+
value = {"format": FORMAT, "root": str(root), "candidate": str(candidate),
|
|
279
|
+
"version": version, "api_contract": api_contract, "archive_sha256": archive_sha256,
|
|
280
|
+
"identities": identities, "source_inventory": source, "runtime_inventory": tree}
|
|
281
|
+
data = (json.dumps(value, sort_keys=True, separators=(",", ":")) + "\n").encode()
|
|
282
|
+
if len(data) > MAX_RECEIPT_BYTES:
|
|
283
|
+
raise ValueError("prepared receipt exceeds size limit")
|
|
284
|
+
_receipt_path(root, output, create_parent=True)
|
|
285
|
+
temporary = output.parent / f".{output.name}.{secrets.token_hex(12)}.tmp"
|
|
286
|
+
fd = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
287
|
+
try:
|
|
288
|
+
with os.fdopen(fd, "wb") as stream:
|
|
289
|
+
os.fchmod(stream.fileno(), 0o600)
|
|
290
|
+
stream.write(data)
|
|
291
|
+
stream.flush()
|
|
292
|
+
os.fsync(stream.fileno())
|
|
293
|
+
os.link(temporary, output, follow_symlinks=False)
|
|
294
|
+
finally:
|
|
295
|
+
temporary.unlink()
|
|
296
|
+
_fsync(output.parent)
|
|
297
|
+
return value
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def validate_prepared(*, root: Path, version: str, api_contract: int,
|
|
301
|
+
archive_sha256: str, receipt: Path) -> dict:
|
|
302
|
+
"""Validate all caller pins and staged bytes without modifying anything."""
|
|
303
|
+
root, receipt = map(_path, (root, receipt))
|
|
304
|
+
_pins(version, api_contract, archive_sha256)
|
|
305
|
+
_owned_directory(root)
|
|
306
|
+
_receipt_path(root, receipt)
|
|
307
|
+
value = _json(receipt)
|
|
308
|
+
fields = {"format", "root", "candidate", "version", "api_contract", "archive_sha256",
|
|
309
|
+
"identities", "source_inventory", "runtime_inventory"}
|
|
310
|
+
if (set(value) != fields or type(value["format"]) is not int or value["format"] != FORMAT
|
|
311
|
+
or value["root"] != str(root) or value["version"] != version
|
|
312
|
+
or type(value["api_contract"]) is not int or value["api_contract"] != api_contract
|
|
313
|
+
or value["archive_sha256"] != archive_sha256):
|
|
314
|
+
raise ValueError("preparation receipt does not match the signed candidate")
|
|
315
|
+
candidate = _path(value["candidate"])
|
|
316
|
+
_candidate(root, candidate)
|
|
317
|
+
identities = value["identities"]
|
|
318
|
+
if not isinstance(identities, dict) or set(identities) != {"root", "releases", "candidate"}:
|
|
319
|
+
raise ValueError("invalid preparation directory identities")
|
|
320
|
+
for name, path in (("root", root), ("releases", root / "releases"), ("candidate", candidate)):
|
|
321
|
+
_check_binding(path, identities[name])
|
|
322
|
+
source = _source_inventory(value["source_inventory"])
|
|
323
|
+
if _inventory_tree(candidate, source) != value["runtime_inventory"]:
|
|
324
|
+
raise RuntimeError("prepared runtime changed after preparation")
|
|
325
|
+
_check_release(candidate, version, api_contract)
|
|
326
|
+
for name, path in (("root", root), ("releases", root / "releases"), ("candidate", candidate)):
|
|
327
|
+
_check_binding(path, identities[name])
|
|
328
|
+
return value
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def main(argv: list[str] | None = None) -> int:
|
|
332
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
333
|
+
subparsers = parser.add_subparsers(dest="action", required=True)
|
|
334
|
+
for action in ("write", "validate"):
|
|
335
|
+
command = subparsers.add_parser(action)
|
|
336
|
+
command.add_argument("--root", type=Path, required=True)
|
|
337
|
+
command.add_argument("--version", required=True)
|
|
338
|
+
command.add_argument("--api-contract", type=int, required=True)
|
|
339
|
+
command.add_argument("--archive-sha256", required=True)
|
|
340
|
+
if action == "write":
|
|
341
|
+
command.add_argument("--candidate", type=Path, required=True)
|
|
342
|
+
command.add_argument("--inventory", type=Path, required=True)
|
|
343
|
+
command.add_argument("--output", type=Path, required=True)
|
|
344
|
+
else:
|
|
345
|
+
command.add_argument("--receipt", type=Path, required=True)
|
|
346
|
+
arguments = vars(parser.parse_args(argv))
|
|
347
|
+
action = arguments.pop("action")
|
|
348
|
+
try:
|
|
349
|
+
if action == "write":
|
|
350
|
+
write_prepared(**arguments)
|
|
351
|
+
print(arguments["output"])
|
|
352
|
+
else:
|
|
353
|
+
print(validate_prepared(**arguments)["candidate"])
|
|
354
|
+
return 0
|
|
355
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
356
|
+
# Do not print input JSON, environment, file bytes, or subprocess logs.
|
|
357
|
+
print(f"Preparation validation failed: {type(error).__name__}.", file=sys.stderr)
|
|
358
|
+
return 1
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
if __name__ == "__main__":
|
|
362
|
+
raise SystemExit(main())
|