@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,406 @@
|
|
|
1
|
+
"""Read-only ownership proof for old Darwin updaters without runner_pid.
|
|
2
|
+
|
|
3
|
+
This is an additional installer admission proof, never an alternative to the
|
|
4
|
+
caller's authenticated native listener, idle, cgroup and installation-lock
|
|
5
|
+
checks. It does not repair status, launch jobs, or stop any process.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ctypes
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
import hashlib
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
import re
|
|
16
|
+
import shlex
|
|
17
|
+
import shutil
|
|
18
|
+
import stat
|
|
19
|
+
import struct
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
MAX_ARGUMENT_BYTES = 2 * 1024 * 1024
|
|
25
|
+
MAX_ANCESTORS = 24
|
|
26
|
+
ACTIVE_PHASES = {"starting", "checking", "downloading", "verifying", "installing", "restarting"}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass(frozen=True)
|
|
30
|
+
class Process:
|
|
31
|
+
pid: int
|
|
32
|
+
ppid: int
|
|
33
|
+
uid: int
|
|
34
|
+
ruid: int
|
|
35
|
+
started: tuple[int, int]
|
|
36
|
+
executable: str
|
|
37
|
+
argv: tuple[str, ...]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class _BSDInfo(ctypes.Structure):
|
|
41
|
+
# Darwin SDK sys/proc_info.h: proc_bsdinfo, PROC_PIDTBSDINFO=3.
|
|
42
|
+
_fields_ = [(name, ctypes.c_uint32) for name in (
|
|
43
|
+
"flags", "status", "xstatus", "pid", "ppid", "uid", "gid", "ruid",
|
|
44
|
+
"rgid", "svuid", "svgid", "reserved")]
|
|
45
|
+
_fields_ += [("comm", ctypes.c_char * 16), ("name", ctypes.c_char * 32)]
|
|
46
|
+
_fields_ += [(name, ctypes.c_uint32) for name in (
|
|
47
|
+
"nfiles", "pgid", "jobc", "tdev", "tpgid")]
|
|
48
|
+
_fields_ += [("nice", ctypes.c_int32), ("start_sec", ctypes.c_uint64),
|
|
49
|
+
("start_usec", ctypes.c_uint64)]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _arguments(raw: bytes) -> tuple[str, ...]:
|
|
53
|
+
"""Parse exactly argc strings from KERN_PROCARGS2; never parse environment."""
|
|
54
|
+
if not 5 <= len(raw) <= MAX_ARGUMENT_BYTES:
|
|
55
|
+
raise RuntimeError("legacy process argument buffer is invalid")
|
|
56
|
+
count = struct.unpack_from("=i", raw)[0]
|
|
57
|
+
if not 1 <= count <= 512:
|
|
58
|
+
raise RuntimeError("legacy process argument count is invalid")
|
|
59
|
+
end = raw.find(b"\0", 4)
|
|
60
|
+
if end < 5:
|
|
61
|
+
raise RuntimeError("legacy process executable argument is missing")
|
|
62
|
+
cursor = end + 1
|
|
63
|
+
while cursor < len(raw) and raw[cursor] == 0:
|
|
64
|
+
cursor += 1
|
|
65
|
+
result = []
|
|
66
|
+
for _ in range(count):
|
|
67
|
+
end = raw.find(b"\0", cursor)
|
|
68
|
+
if end < 0:
|
|
69
|
+
raise RuntimeError("legacy process arguments are truncated")
|
|
70
|
+
value = raw[cursor:end].decode("utf-8", "strict")
|
|
71
|
+
if any(ord(c) < 32 or ord(c) == 127 for c in value):
|
|
72
|
+
raise RuntimeError("legacy process argument contains control characters")
|
|
73
|
+
result.append(value)
|
|
74
|
+
cursor = end + 1
|
|
75
|
+
return tuple(result)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _process(pid: int) -> Process:
|
|
79
|
+
if sys.platform != "darwin" or type(pid) is not int or pid <= 1:
|
|
80
|
+
raise RuntimeError("legacy runner proof requires a live Darwin process")
|
|
81
|
+
library = ctypes.CDLL("/usr/lib/libproc.dylib", use_errno=True)
|
|
82
|
+
library.proc_pidinfo.argtypes = [ctypes.c_int, ctypes.c_int, ctypes.c_uint64,
|
|
83
|
+
ctypes.c_void_p, ctypes.c_int]
|
|
84
|
+
library.proc_pidinfo.restype = ctypes.c_int
|
|
85
|
+
library.proc_pidpath.argtypes = [ctypes.c_int, ctypes.c_void_p, ctypes.c_uint32]
|
|
86
|
+
library.proc_pidpath.restype = ctypes.c_int
|
|
87
|
+
def info():
|
|
88
|
+
value = _BSDInfo()
|
|
89
|
+
if library.proc_pidinfo(pid, 3, 0, ctypes.byref(value), ctypes.sizeof(value)) != ctypes.sizeof(value):
|
|
90
|
+
raise RuntimeError("legacy process identity is unavailable")
|
|
91
|
+
if (value.pid != pid or value.uid != os.getuid() or value.ruid != os.getuid() or value.svuid != os.getuid()
|
|
92
|
+
or value.status == 5 or not value.start_sec): # SZOMB
|
|
93
|
+
raise RuntimeError("legacy process owner or incarnation is invalid")
|
|
94
|
+
return value
|
|
95
|
+
first = info()
|
|
96
|
+
path = ctypes.create_string_buffer(4096)
|
|
97
|
+
if library.proc_pidpath(pid, path, len(path)) <= 0:
|
|
98
|
+
raise RuntimeError("legacy process executable is unavailable")
|
|
99
|
+
libc = ctypes.CDLL(None, use_errno=True)
|
|
100
|
+
libc.sysctl.argtypes = [ctypes.POINTER(ctypes.c_int), ctypes.c_uint,
|
|
101
|
+
ctypes.c_void_p, ctypes.POINTER(ctypes.c_size_t),
|
|
102
|
+
ctypes.c_void_p, ctypes.c_size_t]
|
|
103
|
+
libc.sysctl.restype = ctypes.c_int
|
|
104
|
+
mib = (ctypes.c_int * 3)(1, 49, pid) # CTL_KERN, KERN_PROCARGS2, pid
|
|
105
|
+
size = ctypes.c_size_t()
|
|
106
|
+
if libc.sysctl(mib, 3, None, ctypes.byref(size), None, 0) or not 0 < size.value <= MAX_ARGUMENT_BYTES:
|
|
107
|
+
raise RuntimeError("legacy process arguments are unavailable")
|
|
108
|
+
buffer = ctypes.create_string_buffer(size.value)
|
|
109
|
+
if libc.sysctl(mib, 3, buffer, ctypes.byref(size), None, 0):
|
|
110
|
+
raise RuntimeError("legacy process arguments changed while reading")
|
|
111
|
+
argv = _arguments(buffer.raw[:size.value])
|
|
112
|
+
last = info()
|
|
113
|
+
identity = lambda x: (x.pid, x.ppid, x.uid, x.ruid, x.start_sec, x.start_usec)
|
|
114
|
+
if identity(first) != identity(last):
|
|
115
|
+
raise RuntimeError("legacy process incarnation changed while reading")
|
|
116
|
+
return Process(pid, first.ppid, first.uid, first.ruid,
|
|
117
|
+
(first.start_sec, first.start_usec), os.fsdecode(path.value), argv)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _chain(start: int) -> tuple[Process, ...]:
|
|
121
|
+
result = []
|
|
122
|
+
seen = set()
|
|
123
|
+
for _ in range(MAX_ANCESTORS):
|
|
124
|
+
if start <= 1:
|
|
125
|
+
break
|
|
126
|
+
if start in seen:
|
|
127
|
+
raise RuntimeError("legacy process ancestry contains a cycle")
|
|
128
|
+
seen.add(start)
|
|
129
|
+
item = _process(start)
|
|
130
|
+
result.append(item)
|
|
131
|
+
start = item.ppid
|
|
132
|
+
return tuple(result)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _directory(path: Path, *, private=False) -> tuple:
|
|
136
|
+
if not path.is_absolute() or ".." in path.parts or path.resolve() != path:
|
|
137
|
+
raise RuntimeError("legacy proof directory must be canonical")
|
|
138
|
+
value = path.lstat()
|
|
139
|
+
if (not stat.S_ISDIR(value.st_mode) or value.st_uid != os.getuid()
|
|
140
|
+
or value.st_mode & 0o022 or (private and stat.S_IMODE(value.st_mode) != 0o700)):
|
|
141
|
+
raise RuntimeError("legacy proof directory is not owned and safe")
|
|
142
|
+
return value.st_dev, value.st_ino, value.st_uid, stat.S_IMODE(value.st_mode)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _read(path: Path, *, private=False) -> tuple[bytes, tuple]:
|
|
146
|
+
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
147
|
+
try:
|
|
148
|
+
before = os.fstat(fd)
|
|
149
|
+
def identity(s):
|
|
150
|
+
return (s.st_dev, s.st_ino, s.st_uid, s.st_mode, s.st_nlink,
|
|
151
|
+
s.st_size, s.st_mtime_ns, s.st_ctime_ns)
|
|
152
|
+
if (not stat.S_ISREG(before.st_mode) or before.st_uid != os.getuid()
|
|
153
|
+
or before.st_nlink != 1 or before.st_mode & 0o022
|
|
154
|
+
or (private and stat.S_IMODE(before.st_mode) != 0o600)
|
|
155
|
+
or before.st_size > 16 * 1024 * 1024):
|
|
156
|
+
raise RuntimeError("legacy proof file is not owned and safe")
|
|
157
|
+
with os.fdopen(os.dup(fd), "rb") as stream:
|
|
158
|
+
raw = stream.read(16 * 1024 * 1024 + 1)
|
|
159
|
+
if (len(raw) != before.st_size or identity(before) != identity(os.fstat(fd))
|
|
160
|
+
or identity(before) != identity(path.lstat())):
|
|
161
|
+
raise RuntimeError("legacy proof file changed while reading")
|
|
162
|
+
return raw, (*identity(before), hashlib.sha256(raw).hexdigest())
|
|
163
|
+
finally:
|
|
164
|
+
os.close(fd)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _json(path: Path, *, with_binding=False):
|
|
168
|
+
def pairs(items):
|
|
169
|
+
value = {}
|
|
170
|
+
for key, item in items:
|
|
171
|
+
if key in value:
|
|
172
|
+
raise ValueError("duplicate legacy proof field")
|
|
173
|
+
value[key] = item
|
|
174
|
+
return value
|
|
175
|
+
raw, binding = _read(path, private=True)
|
|
176
|
+
if len(raw) > 1024 * 1024:
|
|
177
|
+
raise RuntimeError("legacy proof JSON exceeds limit")
|
|
178
|
+
value = json.loads(raw, object_pairs_hook=pairs)
|
|
179
|
+
if not isinstance(value, dict):
|
|
180
|
+
raise RuntimeError("legacy proof JSON is invalid")
|
|
181
|
+
return (value, binding) if with_binding else value
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _options(argv: tuple[str, ...]) -> dict[str, str]:
|
|
185
|
+
if len(argv) % 2:
|
|
186
|
+
raise RuntimeError("legacy runner options are malformed")
|
|
187
|
+
values = {}
|
|
188
|
+
for key, value in zip(argv[::2], argv[1::2]):
|
|
189
|
+
if not key.startswith("--") or "=" in key or key in values:
|
|
190
|
+
raise RuntimeError("legacy runner options are ambiguous")
|
|
191
|
+
values[key] = value
|
|
192
|
+
return values
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _python_proof(interpreter: Path) -> tuple[tuple[Path, ...], tuple]:
|
|
196
|
+
"""Bind a Python executable and only its exact macOS framework companion.
|
|
197
|
+
|
|
198
|
+
CPython's framework bin/pythonX.Y launcher re-execs the same version's
|
|
199
|
+
Python.app executable and replaces argv[0]. This is not a generic alias
|
|
200
|
+
rule: both binaries and every directory inside that framework are pinned.
|
|
201
|
+
"""
|
|
202
|
+
resolved = interpreter.resolve(strict=True)
|
|
203
|
+
executables = [resolved]
|
|
204
|
+
directories = []
|
|
205
|
+
version = re.fullmatch(r"python(\d+\.\d+)", resolved.name)
|
|
206
|
+
if (version and resolved.parent.name == "bin"
|
|
207
|
+
and resolved.parent.parent.name == version[1]
|
|
208
|
+
and resolved.parents[2].name == "Versions"
|
|
209
|
+
and resolved.parents[3].name == "Python.framework"):
|
|
210
|
+
framework = resolved.parents[3]
|
|
211
|
+
companion = resolved.parent.parent / "Resources/Python.app/Contents/MacOS/Python"
|
|
212
|
+
if companion.resolve(strict=True) != companion:
|
|
213
|
+
raise RuntimeError("legacy Python framework companion is not canonical")
|
|
214
|
+
executables.append(companion)
|
|
215
|
+
for executable in executables:
|
|
216
|
+
directory = executable.parent
|
|
217
|
+
while directory != framework.parent:
|
|
218
|
+
if directory not in directories:
|
|
219
|
+
directories.append(directory)
|
|
220
|
+
directory = directory.parent
|
|
221
|
+
bindings = []
|
|
222
|
+
for path in (*executables, *directories):
|
|
223
|
+
info = path.lstat()
|
|
224
|
+
is_file = path in executables
|
|
225
|
+
if (info.st_uid not in {0, os.getuid()} or info.st_mode & 0o022
|
|
226
|
+
or (is_file and (not stat.S_ISREG(info.st_mode) or not os.access(path, os.X_OK)))
|
|
227
|
+
or (not is_file and not stat.S_ISDIR(info.st_mode))):
|
|
228
|
+
raise RuntimeError("legacy Python executable or framework directory is unsafe")
|
|
229
|
+
bindings.append((str(path), info.st_dev, info.st_ino, info.st_uid,
|
|
230
|
+
info.st_mode, info.st_nlink, info.st_size,
|
|
231
|
+
info.st_mtime_ns, info.st_ctime_ns))
|
|
232
|
+
return tuple(executables), tuple(bindings)
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _tmux(session: str) -> tuple:
|
|
236
|
+
# Old server bootstrap deliberately drops TMUX/TMUX_TMPDIR: its namespace
|
|
237
|
+
# is the default socket. -S prevents a caller environment selecting another.
|
|
238
|
+
socket = Path("/tmp").resolve() / f"tmux-{os.getuid()}" / "default"
|
|
239
|
+
binary = shutil.which("tmux")
|
|
240
|
+
if not binary:
|
|
241
|
+
raise RuntimeError("legacy tmux executable is unavailable")
|
|
242
|
+
return _tmux_at(session, socket, Path(binary).resolve(strict=True))
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _launch_arguments(display: str) -> tuple[str, ...]:
|
|
246
|
+
"""Decode tmux's display of one shell command, without evaluating it.
|
|
247
|
+
|
|
248
|
+
Older tmux exposes the command text directly. Newer tmux quotes the single
|
|
249
|
+
command argument passed to new-session, adding one serialization layer.
|
|
250
|
+
The caller still compares every decoded argument to the kernel snapshot.
|
|
251
|
+
"""
|
|
252
|
+
if any(ord(character) < 32 or ord(character) == 127 for character in display):
|
|
253
|
+
raise RuntimeError("legacy tmux launch display contains control characters")
|
|
254
|
+
arguments = tuple(shlex.split(display))
|
|
255
|
+
if len(arguments) == 1:
|
|
256
|
+
arguments = tuple(shlex.split(arguments[0]))
|
|
257
|
+
if len(arguments) < 2:
|
|
258
|
+
raise RuntimeError("legacy tmux launch display is missing arguments or nested")
|
|
259
|
+
return arguments
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _tmux_at(session: str, socket: Path, executable: Path) -> tuple:
|
|
263
|
+
"""Read one pane; production caller always supplies the default socket."""
|
|
264
|
+
_directory(socket.parent, private=True)
|
|
265
|
+
socket_info = socket.lstat()
|
|
266
|
+
if (not stat.S_ISSOCK(socket_info.st_mode) or socket_info.st_uid != os.getuid()
|
|
267
|
+
or socket_info.st_mode & 0o007 or socket_info.st_nlink != 1):
|
|
268
|
+
raise RuntimeError("legacy tmux socket is not owned")
|
|
269
|
+
binary = str(executable)
|
|
270
|
+
info = Path(binary).stat()
|
|
271
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_uid not in {0, os.getuid()}
|
|
272
|
+
or info.st_mode & 0o022 or not os.access(binary, os.X_OK)):
|
|
273
|
+
raise RuntimeError("legacy tmux executable is unsafe")
|
|
274
|
+
fmt = "#{session_name}|#{session_id}|#{pane_id}|#{pane_pid}|#{pane_dead}|#{pid}|#{pane_start_command}"
|
|
275
|
+
result = subprocess.run([binary, "-S", str(socket), "list-panes", "-s", "-t", "=" + session, "-F", fmt],
|
|
276
|
+
stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL,
|
|
277
|
+
timeout=5, check=False, env={"PATH":"/usr/bin:/bin", "HOME":str(Path.home()), "LC_ALL":"C"})
|
|
278
|
+
if result.returncode or len(result.stdout) > 64 * 1024:
|
|
279
|
+
raise RuntimeError("legacy updater tmux pane is unavailable")
|
|
280
|
+
rows = result.stdout.decode("utf-8", "strict").splitlines()
|
|
281
|
+
if len(rows) != 1:
|
|
282
|
+
raise RuntimeError("legacy updater tmux session must have one pane")
|
|
283
|
+
fields = rows[0].split("|", 6)
|
|
284
|
+
if (len(fields) != 7 or fields[0] != session or not re.fullmatch(r"\$\d+", fields[1])
|
|
285
|
+
or not re.fullmatch(r"%\d+", fields[2]) or not fields[3].isdigit()
|
|
286
|
+
or fields[4] != "0" or not fields[5].isdigit()):
|
|
287
|
+
raise RuntimeError("legacy updater tmux pane identity is invalid")
|
|
288
|
+
server = _process(int(fields[5]))
|
|
289
|
+
if Path(server.executable).resolve() != Path(binary):
|
|
290
|
+
raise RuntimeError("legacy tmux process executable changed")
|
|
291
|
+
current = socket.lstat()
|
|
292
|
+
binding = (socket_info.st_dev, socket_info.st_ino, socket_info.st_uid, socket_info.st_mode)
|
|
293
|
+
if binding != (current.st_dev, current.st_ino, current.st_uid, current.st_mode):
|
|
294
|
+
raise RuntimeError("legacy tmux socket changed")
|
|
295
|
+
return (*fields[:3], int(fields[3]), server, _launch_arguments(fields[6]), binding)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def verify_legacy_runner(args: Any, status: dict[str, Any]) -> str:
|
|
299
|
+
"""Prove only an absent-PID Darwin legacy owner, without mutating anything."""
|
|
300
|
+
if (sys.platform != "darwin" or args.platform != "Darwin" or args.managed_update_id
|
|
301
|
+
or "runner_pid" in status):
|
|
302
|
+
raise RuntimeError("legacy ancestry fallback does not apply")
|
|
303
|
+
identifier = status.get("update_id")
|
|
304
|
+
if not isinstance(identifier, str) or not re.fullmatch(r"[0-9a-f]{32}", identifier):
|
|
305
|
+
raise RuntimeError("legacy update identity is invalid")
|
|
306
|
+
root, state = Path(args.root), Path(args.state_root)
|
|
307
|
+
roots = {path:_directory(path) for path in (root, root / "releases", state, state / "admin")}
|
|
308
|
+
current = root / "current"
|
|
309
|
+
link_info = current.lstat()
|
|
310
|
+
if not stat.S_ISLNK(link_info.st_mode) or link_info.st_uid != os.getuid():
|
|
311
|
+
raise RuntimeError("legacy current release is not an owned link")
|
|
312
|
+
link_target = os.readlink(current)
|
|
313
|
+
release = current.resolve(strict=True)
|
|
314
|
+
if release.parent != root / "releases" or release.name.startswith("."):
|
|
315
|
+
raise RuntimeError("legacy updater must belong to the retained installed release")
|
|
316
|
+
roots[release] = _directory(release)
|
|
317
|
+
files = {name:_read(release / name) for name in (
|
|
318
|
+
"VERSION", "update_runner.py", "agent_server.py", "release-public-key.pem")}
|
|
319
|
+
old_version = files["VERSION"][0].decode().strip()
|
|
320
|
+
interpreter = release / ".venv/bin/python"
|
|
321
|
+
python_paths, python_binding = _python_proof(interpreter)
|
|
322
|
+
stable_info = lambda s: (s.st_dev, s.st_ino, s.st_uid, s.st_mode, s.st_nlink, s.st_size, s.st_mtime_ns, s.st_ctime_ns)
|
|
323
|
+
link_binding = stable_info(link_info)
|
|
324
|
+
status_file = state / "admin/server-update.json"
|
|
325
|
+
health_path, update_path = Path(args.health_file), Path(args.update_file)
|
|
326
|
+
for path in (health_path, update_path):
|
|
327
|
+
if path.parent != state / "admin" or path.resolve() != path:
|
|
328
|
+
raise RuntimeError("legacy authenticated proof is outside private admin directory")
|
|
329
|
+
health, health_binding = _json(health_path, with_binding=True)
|
|
330
|
+
admitted, admitted_binding = _json(update_path, with_binding=True)
|
|
331
|
+
def status_pins(value):
|
|
332
|
+
if ("runner_pid" in value or value.get("update_id") != identifier
|
|
333
|
+
or value.get("phase") not in ACTIVE_PHASES
|
|
334
|
+
or value.get("target_version") != args.release_version
|
|
335
|
+
or value.get("track") not in {"stable", "beta"}):
|
|
336
|
+
raise RuntimeError("legacy admitted update changed")
|
|
337
|
+
return value["update_id"], value["target_version"], value["track"]
|
|
338
|
+
pins = status_pins(status)
|
|
339
|
+
if status_pins(_json(status_file)) != pins or status_pins(admitted) != pins:
|
|
340
|
+
raise RuntimeError("legacy authenticated update does not match durable status")
|
|
341
|
+
if (health.get("server_identity") != args.expected_server_identity
|
|
342
|
+
or admitted.get("server_identity") != args.expected_server_identity
|
|
343
|
+
or not health.get("server_instance_id")
|
|
344
|
+
or admitted.get("server_instance_id") != health["server_instance_id"]
|
|
345
|
+
or health.get("server_version") != old_version
|
|
346
|
+
or type(health.get("active_count")) is not int or health["active_count"] != 0
|
|
347
|
+
or health.get("update_blocking_queued_count", sum(health.get("queued", {}).values())) != 0
|
|
348
|
+
or health.get("update_service_cgroup", {}).get("safe") is not True
|
|
349
|
+
or health.get("update_service_cgroup", {}).get("unknown_descendant_count") != 0):
|
|
350
|
+
raise RuntimeError("legacy authenticated idle health does not match")
|
|
351
|
+
native = _process(args.expected_native_pid)
|
|
352
|
+
native_executable = Path(native.executable).resolve()
|
|
353
|
+
def python_process(process: Process, script: str):
|
|
354
|
+
return (len(process.argv) >= 2 and all(Path(v).is_absolute() for v in (process.executable, *process.argv[:2]))
|
|
355
|
+
and native_executable in python_paths
|
|
356
|
+
and Path(process.executable).resolve() == native_executable
|
|
357
|
+
and Path(process.argv[0]).resolve() in python_paths
|
|
358
|
+
and Path(process.argv[1]).resolve() == release / script)
|
|
359
|
+
if not python_process(native, "agent_server.py"):
|
|
360
|
+
raise RuntimeError("legacy native process belongs to another installed release")
|
|
361
|
+
native_options = _options(native.argv[3:])
|
|
362
|
+
if native.argv[2:3] != ("serve",) or native_options != {"--bind":args.bind, "--port":str(args.port)}:
|
|
363
|
+
raise RuntimeError("legacy native listener arguments differ")
|
|
364
|
+
expected = {"--status-file":str(status_file), "--public-key":str(release / "release-public-key.pem"),
|
|
365
|
+
"--port":str(args.port), "--bind":args.bind, "--expected-version":args.release_version,
|
|
366
|
+
"--current-version":old_version, "--track":pins[2], "--update-id":identifier,
|
|
367
|
+
"--expected-server-identity":args.expected_server_identity}
|
|
368
|
+
allowed = set(expected) | {"--auth-token-file", "--expected-team-hub-id", "--expected-team-hub-transport",
|
|
369
|
+
"--expected-team-hub-url", "--expected-team-hub-direct-ip-url", "--team-hub-snapshot", "--team-hub-data-dir"}
|
|
370
|
+
chain = _chain(os.getpid())
|
|
371
|
+
runners = [process for process in chain[1:] if python_process(process, "update_runner.py")]
|
|
372
|
+
if len(runners) != 1:
|
|
373
|
+
raise RuntimeError("legacy updater ancestor is not unique")
|
|
374
|
+
runner = runners[0]
|
|
375
|
+
options = _options(runner.argv[2:])
|
|
376
|
+
if set(options) - allowed or not set(expected) <= set(options):
|
|
377
|
+
raise RuntimeError("legacy updater options differ from the installed protocol")
|
|
378
|
+
for key, value in expected.items():
|
|
379
|
+
observed = options[key]
|
|
380
|
+
if key in {"--status-file", "--public-key"}:
|
|
381
|
+
observed = str(Path(observed).resolve(strict=True))
|
|
382
|
+
if observed != value:
|
|
383
|
+
raise RuntimeError("legacy updater arguments do not match admitted update")
|
|
384
|
+
if options.get("--auth-token-file") not in (None, str(state / "admin" / f".server-update-{identifier}.auth.json")):
|
|
385
|
+
raise RuntimeError("legacy updater credential handoff path differs")
|
|
386
|
+
pane = _tmux("agents_server_update_" + identifier)
|
|
387
|
+
pane_pid, tmux_server, launch = pane[3:6]
|
|
388
|
+
if (not launch or launch[1:] != runner.argv[1:] or not Path(launch[0]).is_absolute()
|
|
389
|
+
or Path(launch[0]).resolve() not in python_paths):
|
|
390
|
+
raise RuntimeError("legacy updater launch command does not match kernel arguments")
|
|
391
|
+
pids = [process.pid for process in chain]
|
|
392
|
+
if pane_pid not in pids or tmux_server.pid not in pids or not pids.index(runner.pid) <= pids.index(pane_pid) < pids.index(tmux_server.pid):
|
|
393
|
+
raise RuntimeError("legacy updater is outside its admitted tmux pane")
|
|
394
|
+
# Repeat every mutable authority binding; no cached proof authorizes stop.
|
|
395
|
+
if (_chain(os.getpid()) != chain or _process(args.expected_native_pid) != native
|
|
396
|
+
or _tmux(pane[0]) != pane or status_pins(_json(status_file)) != pins
|
|
397
|
+
or _json(health_path, with_binding=True) != (health, health_binding)
|
|
398
|
+
or _json(update_path, with_binding=True) != (admitted, admitted_binding)):
|
|
399
|
+
raise RuntimeError("legacy updater authority changed during verification")
|
|
400
|
+
if any(_directory(path) != binding for path,binding in roots.items()):
|
|
401
|
+
raise RuntimeError("legacy release directory changed during verification")
|
|
402
|
+
if (stable_info(current.lstat()) != link_binding or os.readlink(current) != link_target
|
|
403
|
+
or current.resolve() != release or _python_proof(interpreter) != (python_paths, python_binding)
|
|
404
|
+
or any(_read(release / name) != value for name,value in files.items())):
|
|
405
|
+
raise RuntimeError("legacy installed runtime changed during verification")
|
|
406
|
+
return identifier
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Idle-only execution retirement leases; callers hold admission locks.
|
|
2
|
+
|
|
3
|
+
An expiring preparation lease cannot authorize a later process stop. Sealing
|
|
4
|
+
persists a non-expiring admission hold first. Only that exact hold may authorize
|
|
5
|
+
the service manager to retire the worker. A failed coordinator leaves a visible,
|
|
6
|
+
recoverable hold instead of opening a race with newly accepted work.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
import stat
|
|
15
|
+
import time
|
|
16
|
+
from typing import Any, Callable
|
|
17
|
+
import uuid
|
|
18
|
+
|
|
19
|
+
from execution_control import ExecutionControlError
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ExecutionMaintenance:
|
|
23
|
+
def __init__(self, path: Path, worker_instance_id: str, *, clock: Callable[[], float] = time.time) -> None:
|
|
24
|
+
self.path = path
|
|
25
|
+
self.worker_instance_id = worker_instance_id
|
|
26
|
+
self.clock = clock
|
|
27
|
+
self.lease: dict[str, Any] | None = None
|
|
28
|
+
# New worker epochs never inherit permission to stop from an old epoch.
|
|
29
|
+
# An unfinished installation explicitly creates its own startup hold.
|
|
30
|
+
if path.exists() or path.is_symlink():
|
|
31
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
32
|
+
try:
|
|
33
|
+
info = os.fstat(descriptor)
|
|
34
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
|
|
35
|
+
or stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1
|
|
36
|
+
or info.st_size > 16_384):
|
|
37
|
+
raise ValueError("Invalid execution maintenance receipt")
|
|
38
|
+
document = json.loads(os.read(descriptor, 16_385))
|
|
39
|
+
finally:
|
|
40
|
+
os.close(descriptor)
|
|
41
|
+
if (not isinstance(document, dict) or set(document) != {"schema", "worker_instance_id", "lease"}
|
|
42
|
+
or document["schema"] != 1):
|
|
43
|
+
raise ValueError("Invalid execution maintenance receipt")
|
|
44
|
+
if document["worker_instance_id"] == worker_instance_id:
|
|
45
|
+
self.lease = self._validate_lease(document["lease"])
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _validate_lease(value: Any) -> dict[str, Any] | None:
|
|
49
|
+
if value is None:
|
|
50
|
+
return None
|
|
51
|
+
if not isinstance(value, dict) or set(value) != {"lease_id", "operation_id", "sealed", "expires_at"}:
|
|
52
|
+
raise ValueError("Invalid execution maintenance lease")
|
|
53
|
+
for name in ("lease_id", "operation_id"):
|
|
54
|
+
if str(uuid.UUID(value[name])) != value[name]:
|
|
55
|
+
raise ValueError("Invalid execution maintenance identity")
|
|
56
|
+
if type(value["sealed"]) is not bool:
|
|
57
|
+
raise ValueError("Invalid execution maintenance state")
|
|
58
|
+
if value["sealed"]:
|
|
59
|
+
if value["expires_at"] is not None:
|
|
60
|
+
raise ValueError("A sealed lease cannot expire")
|
|
61
|
+
elif type(value["expires_at"]) not in {int, float} or not 0 < value["expires_at"] < float("inf"):
|
|
62
|
+
raise ValueError("Invalid execution maintenance expiry")
|
|
63
|
+
return dict(value)
|
|
64
|
+
|
|
65
|
+
def is_held(self) -> bool:
|
|
66
|
+
return self.lease is not None and (
|
|
67
|
+
self.lease["sealed"] is True or self.lease["expires_at"] > self.clock()
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
def status(self, blockers: dict[str, int]) -> dict[str, Any]:
|
|
71
|
+
return {
|
|
72
|
+
"worker_instance_id": self.worker_instance_id,
|
|
73
|
+
"idle": not any(blockers.values()),
|
|
74
|
+
"blockers": dict(blockers),
|
|
75
|
+
"lease": dict(self.lease) if self.is_held() else None,
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
def _persist(self, lease: dict[str, Any] | None) -> None:
|
|
79
|
+
self.path.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
80
|
+
info = self.path.parent.lstat()
|
|
81
|
+
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o022:
|
|
82
|
+
raise ValueError("Execution maintenance directory is unsafe")
|
|
83
|
+
temporary = self.path.with_name(f".{self.path.name}.{uuid.uuid4().hex}.tmp")
|
|
84
|
+
descriptor = os.open(temporary, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
|
85
|
+
try:
|
|
86
|
+
with os.fdopen(descriptor, "w") as stream:
|
|
87
|
+
json.dump({"schema": 1, "worker_instance_id": self.worker_instance_id, "lease": lease}, stream)
|
|
88
|
+
stream.write("\n")
|
|
89
|
+
stream.flush()
|
|
90
|
+
os.fsync(stream.fileno())
|
|
91
|
+
os.replace(temporary, self.path)
|
|
92
|
+
directory = os.open(self.path.parent, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
93
|
+
try:
|
|
94
|
+
os.fsync(directory)
|
|
95
|
+
finally:
|
|
96
|
+
os.close(directory)
|
|
97
|
+
finally:
|
|
98
|
+
temporary.unlink(missing_ok=True)
|
|
99
|
+
self.lease = lease
|
|
100
|
+
|
|
101
|
+
def hold_for_startup(self, operation_id: str) -> None:
|
|
102
|
+
if str(uuid.UUID(operation_id)) != operation_id:
|
|
103
|
+
raise ValueError("Invalid startup operation")
|
|
104
|
+
self._persist({"lease_id": str(uuid.uuid4()), "operation_id": operation_id,
|
|
105
|
+
"sealed": True, "expires_at": None})
|
|
106
|
+
|
|
107
|
+
def apply(self, action: str, operation_id: str, lease_id: str | None,
|
|
108
|
+
lifetime_seconds: int, blockers: dict[str, int]) -> dict[str, Any]:
|
|
109
|
+
if str(uuid.UUID(operation_id)) != operation_id or not 30 <= lifetime_seconds <= 300:
|
|
110
|
+
raise ExecutionControlError(400, "Invalid maintenance operation")
|
|
111
|
+
if action == "acquire":
|
|
112
|
+
if self.is_held():
|
|
113
|
+
if self.lease["operation_id"] != operation_id:
|
|
114
|
+
raise ExecutionControlError(409, "Another execution operation owns admission")
|
|
115
|
+
return self.status(blockers)
|
|
116
|
+
if any(blockers.values()):
|
|
117
|
+
raise ExecutionControlError(409, "Execution is busy; running agents were left untouched")
|
|
118
|
+
self._persist({"lease_id": str(uuid.uuid4()), "operation_id": operation_id,
|
|
119
|
+
"sealed": False, "expires_at": self.clock() + lifetime_seconds})
|
|
120
|
+
elif action in {"renew", "seal", "release"}:
|
|
121
|
+
if (self.lease is None or self.lease["operation_id"] != operation_id
|
|
122
|
+
or self.lease["lease_id"] != lease_id):
|
|
123
|
+
raise ExecutionControlError(409, "Execution maintenance ownership changed")
|
|
124
|
+
if action == "release":
|
|
125
|
+
self._persist(None)
|
|
126
|
+
else:
|
|
127
|
+
if not self.is_held():
|
|
128
|
+
raise ExecutionControlError(409, "Execution maintenance lease expired")
|
|
129
|
+
if action == "seal":
|
|
130
|
+
if any(blockers.values()):
|
|
131
|
+
raise ExecutionControlError(409, "Execution work appeared before retirement")
|
|
132
|
+
self._persist({**self.lease, "sealed": True, "expires_at": None})
|
|
133
|
+
elif self.lease["sealed"] is not True:
|
|
134
|
+
self._persist({**self.lease, "expires_at": self.clock() + lifetime_seconds})
|
|
135
|
+
else:
|
|
136
|
+
raise ExecutionControlError(400, "Unknown maintenance action")
|
|
137
|
+
return self.status(blockers)
|