@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,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())