@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,1819 @@
1
+ #!/usr/bin/env python3
2
+ """Download, verify, and atomically install an AgentsServer release."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import base64
8
+ import binascii
9
+ from contextlib import contextmanager
10
+ import fcntl
11
+ import hashlib
12
+ import json
13
+ import math
14
+ import os
15
+ import re
16
+ import signal
17
+ import shutil
18
+ import stat
19
+ import subprocess
20
+ import tarfile
21
+ import tempfile
22
+ import time
23
+ import urllib.request
24
+ from urllib.error import HTTPError, URLError
25
+ from pathlib import Path
26
+ from email.utils import parsedate_to_datetime
27
+ from typing import Any, Callable
28
+
29
+ from cryptography.hazmat.primitives import serialization
30
+ from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
31
+
32
+
33
+ RELEASE_REPOSITORY = "ZhengyiLuo/AgentsServer"
34
+ RELEASE_BASE = f"https://github.com/{RELEASE_REPOSITORY}/releases"
35
+ RELEASES_API_URL = f"https://api.github.com/repos/{RELEASE_REPOSITORY}/releases?per_page=100"
36
+ MAX_METADATA_BYTES = 1_000_000
37
+ MAX_ARCHIVE_BYTES = 200 * 1024 * 1024
38
+ SERVER_IDLE_CHECK_TIMEOUT_SECONDS = 10.0
39
+ SERVER_STARTUP_READINESS_TIMEOUT_SECONDS = 45.0
40
+ SERVER_STARTUP_READINESS_POLL_SECONDS = 1.0
41
+ VERSION_PATTERN = re.compile(r"^[0-9]+\.[0-9]+\.[0-9]+(?:[-+][A-Za-z0-9.-]+)?$")
42
+ # The standalone installer allows dependency synchronization to run for up to
43
+ # 1,200 seconds. Keep this enclosing budget comfortably above that so the
44
+ # updater cannot terminate a healthy installer first.
45
+ INSTALLER_TIMEOUT_SECONDS = 1_800
46
+ INSTALLER_HEARTBEAT_SECONDS = 10.0
47
+ # After install.sh starts there is no trustworthy out-of-process proof that it
48
+ # has not activated a candidate. Its TERM handler may therefore be stopping the
49
+ # candidate, restoring an arbitrarily large verified Team Hub snapshot, and
50
+ # restarting the old release. Never put a finite SIGKILL deadline around that
51
+ # recovery transaction. Polling keeps the owning update observable while the
52
+ # installer's signal-masked rollback reaches a safe terminal state.
53
+ INSTALLER_TERMINATION_POLL_SECONDS = 10.0
54
+ INSTALLER_LOG_TAIL_BYTES = 64 * 1024
55
+ INSTALLER_LOG_TAIL_LINES = 12
56
+ INSTALLER_ERROR_MAX_CHARS = 4_000
57
+ INSTALLER_LOG_MAX_BYTES = 1024 * 1024
58
+ INSTALLER_ENVIRONMENT_SELECTORS = (
59
+ "AGENTSDOCK_AGENT_TOKEN",
60
+ "AGENTSDOCK_EXPECTED_SERVICE_CGROUP",
61
+ "AGENTSDOCK_MANAGED_UPDATE_ID",
62
+ "AGENTSDOCK_PROVIDER_AUTHORITY_FILE",
63
+ "AGENTSDOCK_PUBLISH_TOKEN",
64
+ "CONDA_PREFIX",
65
+ "PYTHONHOME",
66
+ "PYTHONPATH",
67
+ "UV_CONFIG_FILE",
68
+ "UV_NO_PROJECT",
69
+ "UV_PROJECT",
70
+ "UV_PROJECT_ENVIRONMENT",
71
+ "UV_PYTHON",
72
+ "UV_WORKING_DIR",
73
+ "VIRTUAL_ENV",
74
+ "ZENITHBOT_AGENT_TOKEN",
75
+ "ZENITHDOCK_AGENT_TOKEN",
76
+ )
77
+ RELEASE_TRACKS = {"stable", "beta"}
78
+ NPM_PACKAGE_NAME = "@agentsdock/server"
79
+ NPM_REGISTRY_BASE = "https://registry.npmjs.org/@agentsdock/server/-/"
80
+ MAX_NPM_MANIFEST_BYTES = 8_192
81
+ RUNNER_OWNED_ACTIVE_PHASES = {
82
+ "starting",
83
+ "checking",
84
+ "downloading",
85
+ "verifying",
86
+ "installing",
87
+ "restarting",
88
+ }
89
+ SECURE_PEER_HEALTH_REQUIREMENTS = {
90
+ "available": True,
91
+ "state_available": True,
92
+ "state_error_code": None,
93
+ "required": False,
94
+ "version": 1,
95
+ "control_path": "/api/admin/secure-peers/v1/status",
96
+ "proxy_prefix": "/api/team-hub-secure",
97
+ }
98
+
99
+
100
+ class ReleaseUnavailableError(RuntimeError):
101
+ """Raised when the repository has not published a signed release yet."""
102
+
103
+
104
+ class ReleaseRateLimitedError(RuntimeError):
105
+ """The release host asked us to wait before requesting more metadata."""
106
+
107
+ def __init__(self, retry_after_seconds: int):
108
+ self.retry_after_seconds = max(1, retry_after_seconds)
109
+ super().__init__("GitHub temporarily limited update requests. Try again shortly.")
110
+
111
+
112
+ def release_rate_limit_delay(error: HTTPError) -> int | None:
113
+ headers = {name.lower(): value for name, value in (error.headers or {}).items()}
114
+ if error.code != 429 and not (
115
+ error.code == 403 and (
116
+ headers.get("x-ratelimit-remaining") == "0" or "retry-after" in headers
117
+ )
118
+ ):
119
+ return None
120
+ delays = []
121
+ retry_after = headers.get("retry-after", "")
122
+ try:
123
+ delays.append(float(retry_after))
124
+ except ValueError:
125
+ try:
126
+ delays.append(parsedate_to_datetime(retry_after).timestamp() - time.time())
127
+ except (ValueError, TypeError, OverflowError):
128
+ pass
129
+ try:
130
+ delays.append(float(headers.get("x-ratelimit-reset", "")) - time.time())
131
+ except ValueError:
132
+ pass
133
+ valid = [math.ceil(delay) for delay in delays if math.isfinite(delay) and delay > 0]
134
+ return max(valid, default=60)
135
+
136
+
137
+ class UpdateOwnershipLostError(RuntimeError):
138
+ """Raised when a detached updater no longer owns the durable status row."""
139
+
140
+
141
+ class InstallerRolledBack(RuntimeError):
142
+ """The recovery-only installer verified and restored the prior installation."""
143
+
144
+
145
+ def utc_now() -> str:
146
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
147
+
148
+
149
+ def atomic_json(path: Path, value: dict[str, Any]) -> None:
150
+ path.parent.mkdir(parents=True, exist_ok=True)
151
+ descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
152
+ temporary = Path(temporary_name)
153
+ try:
154
+ with os.fdopen(descriptor, "w") as stream:
155
+ json.dump(value, stream, indent=2, sort_keys=True)
156
+ stream.write("\n")
157
+ stream.flush()
158
+ os.fsync(stream.fileno())
159
+ os.replace(temporary, path)
160
+ parent = os.open(path.parent, os.O_RDONLY | os.O_DIRECTORY)
161
+ try:
162
+ os.fsync(parent)
163
+ finally:
164
+ os.close(parent)
165
+ finally:
166
+ temporary.unlink(missing_ok=True)
167
+
168
+
169
+ @contextmanager
170
+ def server_update_status_lock(path: Path):
171
+ """Cross-process lock shared by the server and detached updater."""
172
+
173
+ path.parent.mkdir(parents=True, exist_ok=True)
174
+ lock_path = path.with_name(f".{path.name}.lock")
175
+ flags = os.O_RDWR | os.O_CREAT | getattr(os, "O_CLOEXEC", 0)
176
+ flags |= getattr(os, "O_NOFOLLOW", 0)
177
+ descriptor = os.open(lock_path, flags, 0o600)
178
+ try:
179
+ info = os.fstat(descriptor)
180
+ if (
181
+ not stat.S_ISREG(info.st_mode)
182
+ or info.st_nlink != 1
183
+ or info.st_uid != os.getuid()
184
+ ):
185
+ raise PermissionError("server update status lock is unsafe")
186
+ os.fchmod(descriptor, 0o600)
187
+ fcntl.flock(descriptor, fcntl.LOCK_EX)
188
+ yield
189
+ finally:
190
+ try:
191
+ fcntl.flock(descriptor, fcntl.LOCK_UN)
192
+ finally:
193
+ os.close(descriptor)
194
+
195
+
196
+ def _read_status_unlocked(path: Path) -> dict[str, Any]:
197
+ try:
198
+ current = json.loads(path.read_text())
199
+ except (FileNotFoundError, json.JSONDecodeError, OSError):
200
+ return {}
201
+ return current if isinstance(current, dict) else {}
202
+
203
+
204
+ def _update_status_unlocked(path: Path, current: dict[str, Any], **changes: Any) -> dict[str, Any]:
205
+ current = dict(current)
206
+ current.update(changes)
207
+ current["updated_at"] = utc_now()
208
+ atomic_json(path, current)
209
+ return current
210
+
211
+
212
+ def update_status(
213
+ path: Path,
214
+ *,
215
+ expected_update_id: str | None = None,
216
+ **changes: Any,
217
+ ) -> dict[str, Any]:
218
+ with server_update_status_lock(path):
219
+ current = _read_status_unlocked(path)
220
+ if (
221
+ expected_update_id is not None
222
+ and (
223
+ str(current.get("update_id") or "") != expected_update_id
224
+ or str(current.get("phase") or "")
225
+ not in RUNNER_OWNED_ACTIVE_PHASES
226
+ )
227
+ ):
228
+ raise UpdateOwnershipLostError(
229
+ "detached updater no longer owns the server update status"
230
+ )
231
+ return _update_status_unlocked(path, current, **changes)
232
+
233
+
234
+ def trim_installer_log(log_path: Path, limit: int = INSTALLER_LOG_MAX_BYTES) -> None:
235
+ """Retain bounded history between attempts, while no installer is writing."""
236
+ try:
237
+ with log_path.open("r+b") as log:
238
+ log.seek(0, os.SEEK_END)
239
+ if log.tell() <= limit:
240
+ return
241
+ log.seek(-limit, os.SEEK_END)
242
+ tail = log.read(limit)
243
+ # Do not retain an unclassifiable partial secret line.
244
+ tail = tail.partition(b"\n")[2]
245
+ log.seek(0)
246
+ log.write(tail)
247
+ log.truncate()
248
+ except FileNotFoundError:
249
+ pass
250
+
251
+
252
+ def installer_log_tail(log_path: Path, *, start_offset: int = 0) -> str:
253
+ """Read a bounded diagnostic tail without loading a large install log."""
254
+ try:
255
+ with log_path.open("rb") as stream:
256
+ stream.seek(0, os.SEEK_END)
257
+ size = stream.tell()
258
+ start = max(start_offset, size - INSTALLER_LOG_TAIL_BYTES)
259
+ truncated = start > start_offset
260
+ stream.seek(start)
261
+ content = stream.read(INSTALLER_LOG_TAIL_BYTES)
262
+ except OSError:
263
+ return ""
264
+ lines = content.decode("utf-8", "replace").splitlines()
265
+ if truncated and lines:
266
+ # The bounded read may begin inside a secret whose identifying key was
267
+ # before the cutoff. Never surface that unclassifiable partial line.
268
+ lines = lines[1:]
269
+ safe_lines: list[str] = []
270
+ for line in lines:
271
+ if "AGENTSDOCK_SETUP_RESULT=" in line:
272
+ continue
273
+ line = re.sub(
274
+ r"(?i)(Authorization\s*:\s*Bearer\s+)\S+",
275
+ r"\1[REDACTED]",
276
+ line,
277
+ )
278
+ line = re.sub(
279
+ r"(?i)(\bBearer\s+)\S+",
280
+ r"\1[REDACTED]",
281
+ line,
282
+ )
283
+ line = re.sub(
284
+ r"(?i)([\"']?(?:AGENTSDOCK_AGENT_TOKEN|ZENITHDOCK_AGENT_TOKEN|ZENITHBOT_AGENT_TOKEN|AGENTSDOCK_PUBLISH_TOKEN|AGENTSDOCK_PROVIDER_AUTHORITY_FILE|access_token)[\"']?\s*[=:]\s*).*$",
285
+ r"\1[REDACTED]",
286
+ line,
287
+ )
288
+ safe_lines.append(line)
289
+ tail = "\n".join(safe_lines[-INSTALLER_LOG_TAIL_LINES:])
290
+ return tail[-INSTALLER_ERROR_MAX_CHARS:].strip()
291
+
292
+
293
+ def terminate_installer(
294
+ process: subprocess.Popen[Any],
295
+ *,
296
+ on_wait: Callable[[], None] | None = None,
297
+ ) -> None:
298
+ """Request installer termination and join its protected recovery.
299
+
300
+ install.sh cannot currently prove to this process whether activation has
301
+ begun. Once it has begun, SIGKILL can strand the service stopped or sever a
302
+ Team Hub restore before the old release is restarted. TERM is cooperative:
303
+ the installer kills ordinary stage workers, then masks further termination
304
+ while it completes rollback. Wait without a force-kill deadline.
305
+ """
306
+ if process.poll() is not None:
307
+ return
308
+ if os.name != "nt":
309
+ try:
310
+ os.killpg(process.pid, signal.SIGTERM)
311
+ except OSError:
312
+ process.terminate()
313
+ else:
314
+ process.terminate()
315
+ while True:
316
+ if on_wait is not None:
317
+ try:
318
+ on_wait()
319
+ except Exception:
320
+ # Losing status ownership must not turn a safe join back into
321
+ # a detached installer or a force-kill boundary.
322
+ pass
323
+ try:
324
+ process.wait(timeout=INSTALLER_TERMINATION_POLL_SECONDS)
325
+ return
326
+ except subprocess.TimeoutExpired:
327
+ continue
328
+
329
+
330
+ def installer_environment() -> dict[str, str]:
331
+ """Return an installer environment detached from the caller's workspace."""
332
+ environment = os.environ.copy()
333
+ for name in INSTALLER_ENVIRONMENT_SELECTORS:
334
+ environment.pop(name, None)
335
+ return environment
336
+
337
+
338
+ def run_installer(
339
+ command: list[str],
340
+ *,
341
+ cwd: Path,
342
+ status_path: Path,
343
+ log_path: Path,
344
+ version: str,
345
+ expected_update_id: str | None = None,
346
+ managed_update_id: str | None = None,
347
+ expected_service_cgroup: str | None = None,
348
+ timeout_seconds: float = INSTALLER_TIMEOUT_SECONDS,
349
+ heartbeat_seconds: float = INSTALLER_HEARTBEAT_SECONDS,
350
+ on_started: Callable[[], None] | None = None,
351
+ accepted_returncodes: tuple[int, ...] = (0,),
352
+ ) -> None:
353
+ """Run the installer with live logging and a durable status heartbeat."""
354
+ if expected_update_id is not None:
355
+ update_status(
356
+ status_path,
357
+ expected_update_id=expected_update_id,
358
+ heartbeat_at=utc_now(),
359
+ )
360
+ log_path.parent.mkdir(parents=True, exist_ok=True)
361
+ header = (
362
+ f"\n--- AgentsServer update {expected_update_id or 'manual'} "
363
+ f"to {version} at {utc_now()} ---\n"
364
+ ).encode()
365
+ trim_installer_log(log_path, INSTALLER_LOG_MAX_BYTES - len(header))
366
+ started = time.monotonic()
367
+ deadline = started + timeout_seconds
368
+ environment = installer_environment()
369
+ if managed_update_id is not None:
370
+ environment["AGENTSDOCK_MANAGED_UPDATE_ID"] = managed_update_id
371
+ if expected_service_cgroup is not None:
372
+ environment["AGENTSDOCK_EXPECTED_SERVICE_CGROUP"] = expected_service_cgroup
373
+ with log_path.open("ab") as log:
374
+ os.chmod(log_path, 0o600)
375
+ log.write(header)
376
+ log.flush()
377
+ attempt_start = log.tell()
378
+ process = subprocess.Popen(
379
+ command,
380
+ cwd=cwd,
381
+ stdout=log,
382
+ stderr=subprocess.STDOUT,
383
+ start_new_session=True,
384
+ env=environment,
385
+ )
386
+ if on_started is not None:
387
+ on_started()
388
+ while True:
389
+ remaining = deadline - time.monotonic()
390
+ if remaining <= 0:
391
+ def report_protected_recovery() -> None:
392
+ elapsed = max(1, int(time.monotonic() - started))
393
+ update_status(
394
+ status_path,
395
+ expected_update_id=expected_update_id,
396
+ phase="installing",
397
+ message=(
398
+ "Installer timeout reached; waiting for its protected "
399
+ f"rollback to finish ({elapsed}s elapsed)."
400
+ ),
401
+ heartbeat_at=utc_now(),
402
+ elapsed_seconds=elapsed,
403
+ )
404
+
405
+ terminate_installer(process, on_wait=report_protected_recovery)
406
+ log.flush()
407
+ tail = installer_log_tail(log_path, start_offset=attempt_start)
408
+ trim_installer_log(log_path)
409
+ detail = f": {tail}" if tail else ""
410
+ raise RuntimeError(
411
+ f"installer timed out after {timeout_seconds:g} seconds{detail}"
412
+ )
413
+ try:
414
+ returncode = process.wait(timeout=min(heartbeat_seconds, remaining))
415
+ break
416
+ except subprocess.TimeoutExpired:
417
+ elapsed = max(1, int(time.monotonic() - started))
418
+ try:
419
+ update_status(
420
+ status_path,
421
+ expected_update_id=expected_update_id,
422
+ phase="installing",
423
+ message=f"Installing AgentsServer {version} ({elapsed}s elapsed).",
424
+ heartbeat_at=utc_now(),
425
+ elapsed_seconds=elapsed,
426
+ )
427
+ except UpdateOwnershipLostError:
428
+ terminate_installer(process)
429
+ log.flush()
430
+ trim_installer_log(log_path)
431
+ raise
432
+ log.flush()
433
+
434
+ tail = installer_log_tail(log_path, start_offset=attempt_start) if returncode else ""
435
+ trim_installer_log(log_path)
436
+ if returncode == 75 and returncode in accepted_returncodes:
437
+ raise InstallerRolledBack("interrupted activation was rolled back")
438
+ if returncode != 0:
439
+ raise RuntimeError(
440
+ f"installer failed ({returncode}): {tail or 'no output; inspect server-update.log'}"
441
+ )
442
+
443
+
444
+ def download_bytes(url: str, limit: int, timeout: float = 30.0) -> bytes:
445
+ request = urllib.request.Request(url, headers={"User-Agent": "AgentsServer-Updater/1"})
446
+ with urllib.request.urlopen(request, timeout=timeout) as response:
447
+ declared = int(response.headers.get("Content-Length") or 0)
448
+ if declared > limit:
449
+ raise RuntimeError(f"download exceeds the {limit}-byte safety limit")
450
+ content = response.read(limit + 1)
451
+ if len(content) > limit:
452
+ raise RuntimeError(f"download exceeds the {limit}-byte safety limit")
453
+ return content
454
+
455
+
456
+ def server_health_snapshot(
457
+ port: int,
458
+ *,
459
+ token: str | None = None,
460
+ timeout: float = SERVER_IDLE_CHECK_TIMEOUT_SECONDS,
461
+ ) -> dict[str, Any]:
462
+ """Read one bounded authenticated AgentsServer health document."""
463
+
464
+ headers = {"User-Agent": "AgentsServer-Updater/1"}
465
+ clean_token = str(token or "").strip()
466
+ if clean_token:
467
+ headers["Authorization"] = f"Bearer {clean_token}"
468
+ request = urllib.request.Request(
469
+ f"http://127.0.0.1:{int(port)}/api/health",
470
+ headers=headers,
471
+ )
472
+ with urllib.request.urlopen(request, timeout=timeout) as response:
473
+ content = response.read(MAX_METADATA_BYTES + 1)
474
+ if len(content) > MAX_METADATA_BYTES:
475
+ raise RuntimeError("server health response exceeds its safety limit")
476
+ try:
477
+ health = json.loads(content)
478
+ except json.JSONDecodeError as exc:
479
+ raise RuntimeError("server health response is invalid JSON") from exc
480
+ if not isinstance(health, dict) or health.get("ok") is not True:
481
+ raise RuntimeError("server health response is not healthy")
482
+ return health
483
+
484
+
485
+ def server_work_snapshot(
486
+ port: int,
487
+ *,
488
+ token: str | None = None,
489
+ timeout: float = SERVER_IDLE_CHECK_TIMEOUT_SECONDS,
490
+ require_cgroup_safe: bool = False,
491
+ require_verified_service_cgroup: bool = False,
492
+ expected_server_identity: str | None = None,
493
+ ) -> tuple[int, int]:
494
+ """Read the live workload immediately before invoking the installer."""
495
+
496
+ health = server_health_snapshot(port, token=token, timeout=timeout)
497
+ if (
498
+ expected_server_identity is not None
499
+ and health.get("server_identity") != expected_server_identity
500
+ ):
501
+ raise RuntimeError("AgentsServer stable identity changed before restart")
502
+
503
+ active = health.get("active")
504
+ raw_active_count = health.get("active_count")
505
+ if isinstance(raw_active_count, bool):
506
+ raise RuntimeError("server health response has an invalid active count")
507
+ if isinstance(raw_active_count, int):
508
+ active_count = max(0, raw_active_count)
509
+ elif isinstance(active, list):
510
+ active_count = len(active)
511
+ else:
512
+ raise RuntimeError("server health response is missing its active count")
513
+
514
+ raw_blocking_queued_count = health.get("update_blocking_queued_count")
515
+ if raw_blocking_queued_count is not None:
516
+ if (
517
+ isinstance(raw_blocking_queued_count, bool)
518
+ or not isinstance(raw_blocking_queued_count, int)
519
+ or raw_blocking_queued_count < 0
520
+ ):
521
+ raise RuntimeError(
522
+ "server health response has an invalid update-blocking queued count"
523
+ )
524
+ queued_count = raw_blocking_queued_count
525
+ else:
526
+ # Older servers did not distinguish durable preserved messages from
527
+ # volatile queue state. Retain their fail-closed behavior.
528
+ queued = health.get("queued")
529
+ if not isinstance(queued, dict):
530
+ raise RuntimeError("server health response is missing its queued turns")
531
+ queued_count = 0
532
+ for value in queued.values():
533
+ if isinstance(value, bool) or not isinstance(value, int) or value < 0:
534
+ raise RuntimeError("server health response has an invalid queued count")
535
+ queued_count += value
536
+ if require_cgroup_safe and not active_count and not queued_count:
537
+ cgroup = health.get("update_service_cgroup")
538
+ if not isinstance(cgroup, dict):
539
+ raise RuntimeError(
540
+ "server health response is missing its service-cgroup admission proof"
541
+ )
542
+ raw_count = cgroup.get("unknown_descendant_count")
543
+ if (
544
+ cgroup.get("safe") is not True
545
+ or isinstance(raw_count, bool)
546
+ or not isinstance(raw_count, int)
547
+ or raw_count != 0
548
+ ):
549
+ raise RuntimeError(
550
+ "AgentsServer has an unverified or nonempty service cgroup"
551
+ )
552
+ if (
553
+ require_verified_service_cgroup
554
+ and cgroup.get("inspection") != "verified"
555
+ ):
556
+ raise RuntimeError(
557
+ "AgentsServer did not return a verified systemd service-cgroup proof"
558
+ )
559
+ return active_count, queued_count
560
+
561
+
562
+ def assert_post_update_identity(
563
+ port: int,
564
+ *,
565
+ token: str | None,
566
+ expected_server_identity: str,
567
+ expected_team_hub_id: str | None = None,
568
+ expected_team_hub_transport: str | None = None,
569
+ expected_team_hub_url: str | None = None,
570
+ expected_team_hub_direct_ip_url: str | None = None,
571
+ expected_server_version: str | None = None,
572
+ expected_api_contract_version: int | None = None,
573
+ ) -> None:
574
+ """Fence a replacement by stable server and managed Hub identities."""
575
+
576
+ health = server_health_snapshot(port, token=token)
577
+ if str(health.get("server_identity") or "") != expected_server_identity:
578
+ raise RuntimeError("updated AgentsServer stable identity does not match")
579
+ if expected_server_version is not None and health.get("server_version") != expected_server_version:
580
+ raise RuntimeError("updated AgentsServer version does not match the signed release")
581
+ if expected_server_version is not None and ("gateway" in health or "execution_service" in health):
582
+ for component in ("gateway", "execution_service"):
583
+ identity = health.get(component)
584
+ if not isinstance(identity, dict) or identity.get("version") != expected_server_version:
585
+ raise RuntimeError("updated AgentsServer gateway and execution versions have not converged")
586
+ if health["execution_service"].get("maintenance_held") is not False:
587
+ raise RuntimeError("updated AgentsServer activation has not released admission")
588
+ if expected_api_contract_version is not None and (
589
+ type(health.get("api_contract_version")) is not int
590
+ or health["api_contract_version"] != expected_api_contract_version
591
+ ):
592
+ raise RuntimeError("updated AgentsServer API contract does not match the signed release")
593
+ capabilities = health.get("capabilities")
594
+ secure_peer_capability = (
595
+ capabilities.get("secure_peer_v1")
596
+ if isinstance(capabilities, dict)
597
+ else None
598
+ )
599
+ if not isinstance(secure_peer_capability, dict) or any(
600
+ secure_peer_capability.get(name) != value
601
+ for name, value in SECURE_PEER_HEALTH_REQUIREMENTS.items()
602
+ ):
603
+ raise RuntimeError("updated AgentsServer secure-peer state is unavailable")
604
+ if expected_team_hub_id is None:
605
+ if (
606
+ expected_team_hub_transport is not None
607
+ or expected_team_hub_url is not None
608
+ or expected_team_hub_direct_ip_url is not None
609
+ ):
610
+ raise RuntimeError("managed Team Hub transport has no bound Hub identity")
611
+ return
612
+ capability = (
613
+ capabilities.get("team_hub_v1")
614
+ if isinstance(capabilities, dict)
615
+ else None
616
+ )
617
+ expected_capability = {
618
+ "available": True,
619
+ "designated_host": True,
620
+ "version": 1,
621
+ "base_path": "/api/team-hub",
622
+ "hub_id": expected_team_hub_id,
623
+ "host_server_identity": expected_server_identity,
624
+ }
625
+ if not isinstance(capability, dict) or any(
626
+ capability.get(name) != value
627
+ for name, value in expected_capability.items()
628
+ ):
629
+ raise RuntimeError("updated AgentsServer lost or changed its Team Hub identity")
630
+ actual_transport = capability.get("transport")
631
+ actual_hub_url = capability.get("hub_url")
632
+ if expected_team_hub_transport is None:
633
+ if actual_transport not in {None, "loopback"} or actual_hub_url is not None:
634
+ raise RuntimeError("updated AgentsServer changed its legacy Team Hub transport")
635
+ elif (
636
+ actual_transport != expected_team_hub_transport
637
+ or actual_hub_url != expected_team_hub_url
638
+ ):
639
+ raise RuntimeError("updated AgentsServer changed its Team Hub transport")
640
+ if expected_team_hub_direct_ip_url is not None:
641
+ expected_routes = [
642
+ {
643
+ "transport": expected_team_hub_transport or "loopback",
644
+ "hub_url": expected_team_hub_url,
645
+ }
646
+ ]
647
+ if (
648
+ expected_team_hub_direct_ip_url
649
+ and expected_team_hub_transport != "direct_ip"
650
+ ):
651
+ expected_routes.append(
652
+ {
653
+ "transport": "direct_ip",
654
+ "hub_url": expected_team_hub_direct_ip_url,
655
+ }
656
+ )
657
+ if capability.get("routes") != expected_routes:
658
+ raise RuntimeError("updated AgentsServer changed its Team Hub routes")
659
+
660
+
661
+ def assert_repaired_team_hub_identity(
662
+ port: int,
663
+ *,
664
+ token: str | None,
665
+ expected_server_identity: str,
666
+ expected_team_hub_transport: str,
667
+ expected_team_hub_url: str | None,
668
+ expected_team_hub_direct_ip_url: str,
669
+ ) -> str:
670
+ """Verify that an unavailable managed host was repaired in place."""
671
+
672
+ health = server_health_snapshot(port, token=token)
673
+ capabilities = health.get("capabilities")
674
+ capability = (
675
+ capabilities.get("team_hub_v1")
676
+ if isinstance(capabilities, dict)
677
+ else None
678
+ )
679
+ required = {
680
+ "available": True,
681
+ "designated_host": True,
682
+ "version": 1,
683
+ "base_path": "/api/team-hub",
684
+ "host_server_identity": expected_server_identity,
685
+ "transport": expected_team_hub_transport,
686
+ "hub_url": expected_team_hub_url,
687
+ }
688
+ if not isinstance(capability, dict) or any(
689
+ capability.get(name) != value for name, value in required.items()
690
+ ):
691
+ raise RuntimeError("updated AgentsServer did not repair its Team Hub host")
692
+ hub_id = capability.get("hub_id")
693
+ if not isinstance(hub_id, str) or re.fullmatch(
694
+ r"[A-Za-z0-9_.:-]{8,240}", hub_id
695
+ ) is None:
696
+ raise RuntimeError("repaired Team Hub identity is invalid")
697
+ expected_routes = [
698
+ {
699
+ "transport": expected_team_hub_transport,
700
+ "hub_url": expected_team_hub_url,
701
+ }
702
+ ]
703
+ if (
704
+ expected_team_hub_direct_ip_url
705
+ and expected_team_hub_transport != "direct_ip"
706
+ ):
707
+ expected_routes.append(
708
+ {
709
+ "transport": "direct_ip",
710
+ "hub_url": expected_team_hub_direct_ip_url,
711
+ }
712
+ )
713
+ if capability.get("routes") != expected_routes:
714
+ raise RuntimeError("repaired Team Hub routes changed")
715
+ return hub_id
716
+
717
+
718
+ def assert_server_idle(
719
+ port: int,
720
+ *,
721
+ token: str | None = None,
722
+ require_verified_service_cgroup: bool = False,
723
+ expected_server_identity: str | None = None,
724
+ timeout: float = SERVER_IDLE_CHECK_TIMEOUT_SECONDS,
725
+ ) -> None:
726
+ """Fail closed if work appeared after the update was accepted."""
727
+
728
+ try:
729
+ active_count, queued_count = server_work_snapshot(
730
+ port,
731
+ token=token,
732
+ require_cgroup_safe=True,
733
+ require_verified_service_cgroup=require_verified_service_cgroup,
734
+ expected_server_identity=expected_server_identity,
735
+ timeout=timeout,
736
+ )
737
+ except Exception as exc:
738
+ raise RuntimeError(
739
+ f"could not verify that AgentsServer is idle before restart: {exc}"
740
+ ) from exc
741
+ if active_count or queued_count:
742
+ parts: list[str] = []
743
+ if active_count:
744
+ parts.append(
745
+ f"{active_count} active agent run{'s' if active_count != 1 else ''}"
746
+ )
747
+ if queued_count:
748
+ parts.append(
749
+ f"{queued_count} queued turn{'s' if queued_count != 1 else ''}"
750
+ )
751
+ raise RuntimeError(
752
+ "server became busy before restart: "
753
+ + " and ".join(parts)
754
+ + "; retry the update after work finishes"
755
+ )
756
+
757
+
758
+ def transient_server_readiness_error(error: BaseException | None) -> bool:
759
+ """Recognize unavailable transport, never failed authentication or proof."""
760
+ if isinstance(error, HTTPError):
761
+ return error.code == 503
762
+ if isinstance(error, URLError):
763
+ error = error.reason
764
+ return isinstance(error, (
765
+ ConnectionRefusedError,
766
+ ConnectionResetError,
767
+ ConnectionAbortedError,
768
+ TimeoutError,
769
+ ))
770
+
771
+
772
+ def wait_for_server_idle(
773
+ port: int,
774
+ *,
775
+ status_path: Path,
776
+ expected_update_id: str,
777
+ expected_server_identity: str,
778
+ token: str | None = None,
779
+ require_verified_service_cgroup: bool = False,
780
+ timeout_seconds: float = SERVER_STARTUP_READINESS_TIMEOUT_SECONDS,
781
+ ) -> None:
782
+ """Allow a forced-update startup to finish opening its native listener.
783
+
784
+ The replacement process advances its reserved update during lifespan,
785
+ before HTTP startup completes. Keep the exact update alive while transport
786
+ becomes ready, but never retry a response that fails the idle/safety proof.
787
+ """
788
+ deadline = time.monotonic() + timeout_seconds
789
+ last_error: RuntimeError | None = None
790
+ while True:
791
+ # This CAS also stops a cancelled/finalized or superseded updater.
792
+ update_status(
793
+ status_path,
794
+ expected_update_id=expected_update_id,
795
+ heartbeat_at=utc_now(),
796
+ message="Waiting for AgentsServer startup before checking installation safety.",
797
+ )
798
+ remaining = deadline - time.monotonic()
799
+ if remaining <= 0:
800
+ raise RuntimeError(
801
+ f"AgentsServer startup readiness timed out after {timeout_seconds:g} seconds"
802
+ ) from last_error
803
+ try:
804
+ assert_server_idle(
805
+ port,
806
+ token=token,
807
+ require_verified_service_cgroup=require_verified_service_cgroup,
808
+ expected_server_identity=expected_server_identity,
809
+ timeout=min(SERVER_IDLE_CHECK_TIMEOUT_SECONDS, remaining),
810
+ )
811
+ except RuntimeError as exc:
812
+ if not transient_server_readiness_error(exc.__cause__):
813
+ raise
814
+ last_error = exc
815
+ remaining = deadline - time.monotonic()
816
+ if remaining > 0:
817
+ time.sleep(min(SERVER_STARTUP_READINESS_POLL_SECONDS, remaining))
818
+ else:
819
+ # Ownership can change while the health request is in flight.
820
+ update_status(
821
+ status_path,
822
+ expected_update_id=expected_update_id,
823
+ heartbeat_at=utc_now(),
824
+ message="AgentsServer startup, identity, and idle checks passed.",
825
+ )
826
+ return
827
+
828
+
829
+ def consume_auth_token_file(path: str | None) -> str:
830
+ """Read and immediately remove the updater's one-time health credential."""
831
+
832
+ clean_path = str(path or "").strip()
833
+ if not clean_path:
834
+ return ""
835
+ token_path = Path(clean_path).expanduser().resolve()
836
+ try:
837
+ value = json.loads(token_path.read_text())
838
+ token = str(value.get("token") or "") if isinstance(value, dict) else ""
839
+ finally:
840
+ try:
841
+ token_path.unlink()
842
+ except FileNotFoundError:
843
+ pass
844
+ if not token:
845
+ raise RuntimeError("server update health credential is empty")
846
+ return token
847
+
848
+
849
+ def clear_team_hub_maintenance(args: argparse.Namespace) -> bool:
850
+ """Clear the exact durable update fence without opening the Hub DB."""
851
+
852
+ hub_id = str(getattr(args, "expected_team_hub_id", "") or "").strip()
853
+ snapshot = str(getattr(args, "team_hub_snapshot", "") or "").strip()
854
+ data_dir = str(getattr(args, "team_hub_data_dir", "") or "").strip()
855
+ host_identity = str(
856
+ getattr(args, "expected_server_identity", "") or ""
857
+ ).strip()
858
+ operation_id = str(getattr(args, "update_id", "") or "").strip()
859
+ if not any((hub_id, snapshot, data_dir)):
860
+ return False
861
+ if not all((hub_id, snapshot, data_dir, host_identity, operation_id)):
862
+ raise RuntimeError("managed Team Hub maintenance arguments are incomplete")
863
+ from agentsdock_team_hub.store import HubStore
864
+
865
+ return HubStore.clear_maintenance_fence_control(
866
+ Path(data_dir),
867
+ expected_hub_id=hub_id,
868
+ expected_host_identity=host_identity,
869
+ expected_reason="server-update",
870
+ expected_operation_id=operation_id,
871
+ expected_snapshot=Path(snapshot),
872
+ )
873
+
874
+
875
+ def team_hub_maintenance_fence_present(args: argparse.Namespace) -> bool:
876
+ """Return whether this exact update still has an on-disk Hub fence."""
877
+
878
+ hub_id = str(getattr(args, "expected_team_hub_id", "") or "").strip()
879
+ snapshot = str(getattr(args, "team_hub_snapshot", "") or "").strip()
880
+ data_dir = str(getattr(args, "team_hub_data_dir", "") or "").strip()
881
+ host_identity = str(
882
+ getattr(args, "expected_server_identity", "") or ""
883
+ ).strip()
884
+ operation_id = str(getattr(args, "update_id", "") or "").strip()
885
+ if not any((hub_id, snapshot, data_dir)):
886
+ return False
887
+ if not all((hub_id, snapshot, data_dir, host_identity, operation_id)):
888
+ raise RuntimeError("managed Team Hub maintenance arguments are incomplete")
889
+ from agentsdock_team_hub.store import HubStore
890
+
891
+ return HubStore.maintenance_fence_matches_control(
892
+ Path(data_dir),
893
+ expected_hub_id=hub_id,
894
+ expected_host_identity=host_identity,
895
+ expected_reason="server-update",
896
+ expected_operation_id=operation_id,
897
+ expected_snapshot=Path(snapshot),
898
+ )
899
+
900
+
901
+ def version_is_prerelease(version: str) -> bool:
902
+ return "-" in version.split("+", 1)[0]
903
+
904
+
905
+ def release_track(version: str) -> str:
906
+ return "beta" if version_is_prerelease(version) else "stable"
907
+
908
+
909
+ def normalized_release_track(track: str | None) -> str:
910
+ value = str(track or "stable").strip().lower()
911
+ if value not in RELEASE_TRACKS:
912
+ raise ValueError(f"invalid release track: {track}")
913
+ return value
914
+
915
+
916
+ def version_key(version: str) -> tuple[Any, ...]:
917
+ """Return a SemVer-compatible key for a trusted release version."""
918
+ if not VERSION_PATTERN.fullmatch(version):
919
+ raise ValueError(f"invalid release version: {version}")
920
+ without_build = version.split("+", 1)[0]
921
+ core, separator, prerelease = without_build.partition("-")
922
+ major, minor, patch = (int(part) for part in core.split("."))
923
+ identifiers: tuple[tuple[int, int | str], ...] = ()
924
+ if separator:
925
+ identifiers = tuple(
926
+ (0, int(part)) if part.isdigit() else (1, part)
927
+ for part in prerelease.split(".")
928
+ )
929
+ return major, minor, patch, 0 if separator else 1, identifiers
930
+
931
+
932
+ def release_manifest_url(version: str) -> str:
933
+ return f"{RELEASE_BASE}/download/v{version}/agents-server-manifest.json"
934
+
935
+
936
+ def release_signature_url(version: str) -> str:
937
+ return f"{RELEASE_BASE}/download/v{version}/agents-server-manifest.sig"
938
+
939
+
940
+ def release_candidates(releases: Any, track: str = "stable") -> list[str]:
941
+ track = normalized_release_track(track)
942
+ if not isinstance(releases, list):
943
+ raise RuntimeError("GitHub releases response must be a JSON array")
944
+ candidates: set[str] = set()
945
+ for release in releases:
946
+ if not isinstance(release, dict) or release.get("draft") is True:
947
+ continue
948
+ tag = str(release.get("tag_name") or "")
949
+ if not tag.startswith("v"):
950
+ continue
951
+ version = tag[1:]
952
+ if not VERSION_PATTERN.fullmatch(version) or release_track(version) != track:
953
+ continue
954
+ declared_prerelease = release.get("prerelease")
955
+ if declared_prerelease is not None and bool(declared_prerelease) != version_is_prerelease(version):
956
+ continue
957
+ candidates.add(version)
958
+ return sorted(candidates, key=version_key, reverse=True)
959
+
960
+
961
+ def stable_release_candidates(releases: Any) -> list[str]:
962
+ """Backward-compatible stable release discovery."""
963
+ return release_candidates(releases, "stable")
964
+
965
+
966
+ def release_versions_from_html(content: bytes, track: str = "stable") -> set[str]:
967
+ track = normalized_release_track(track)
968
+ text = content.decode("utf-8", "replace")
969
+ prefix = f"/{RELEASE_REPOSITORY}/releases/tag/v"
970
+ return {
971
+ match.group(1)
972
+ for match in re.finditer(re.escape(prefix) + r"([^\"'<>/?#]+)", text)
973
+ if VERSION_PATTERN.fullmatch(match.group(1)) and release_track(match.group(1)) == track
974
+ }
975
+
976
+
977
+ def verify_manifest(
978
+ manifest_bytes: bytes,
979
+ signature: bytes,
980
+ public_key_path: Path,
981
+ *,
982
+ expected_version: str | None = None,
983
+ track: str = "stable",
984
+ allow_npm: bool = False,
985
+ ) -> dict[str, Any]:
986
+ track = normalized_release_track(track)
987
+ key = serialization.load_pem_public_key(public_key_path.read_bytes())
988
+ if not isinstance(key, Ed25519PublicKey):
989
+ raise RuntimeError("release public key is not an Ed25519 key")
990
+ key.verify(signature, manifest_bytes)
991
+ manifest = json.loads(manifest_bytes)
992
+ if not isinstance(manifest, dict):
993
+ raise RuntimeError("release manifest must be a JSON object")
994
+ version = str(manifest.get("version") or "")
995
+ if not VERSION_PATTERN.fullmatch(version):
996
+ raise RuntimeError("release manifest contains an invalid version")
997
+ actual_track = release_track(version)
998
+ if actual_track != track:
999
+ raise RuntimeError(f"release manifest is not on the requested {track} track")
1000
+ if expected_version is not None and version != expected_version:
1001
+ raise RuntimeError("release manifest version does not match its immutable release tag")
1002
+ expected_prerelease = actual_track == "beta"
1003
+ if manifest.get("prerelease") not in {None, expected_prerelease}:
1004
+ raise RuntimeError("release manifest prerelease metadata is inconsistent")
1005
+ if manifest.get("track") not in {None, actual_track}:
1006
+ raise RuntimeError("release manifest track metadata is inconsistent")
1007
+ archive = manifest.get("archive")
1008
+ if not isinstance(archive, dict):
1009
+ raise RuntimeError("release manifest is missing archive metadata")
1010
+ schema = manifest.get("schema", 1)
1011
+ if type(schema) is not int or schema not in ({1, 2} if allow_npm else {1}):
1012
+ raise RuntimeError("release manifest schema is not supported")
1013
+ if schema == 2:
1014
+ npm = manifest.get("npm")
1015
+ if (manifest.get("distribution") != "npm" or manifest.get("track") != actual_track
1016
+ or not isinstance(npm, dict)
1017
+ or npm.get("name") != NPM_PACKAGE_NAME or npm.get("version") != version
1018
+ or "+" in version):
1019
+ raise RuntimeError("release npm package identity is not trusted")
1020
+ integrity = npm.get("integrity")
1021
+ try:
1022
+ digest = base64.b64decode(integrity[7:], validate=True) if isinstance(integrity, str) and integrity.startswith("sha512-") else b""
1023
+ except (ValueError, binascii.Error):
1024
+ digest = b""
1025
+ if len(digest) != 64 or integrity != "sha512-" + base64.b64encode(digest).decode("ascii"):
1026
+ raise RuntimeError("release npm integrity is invalid")
1027
+ size = archive.get("size")
1028
+ if type(size) is not int or not 1 <= size <= MAX_ARCHIVE_BYTES:
1029
+ raise RuntimeError("release npm archive size is invalid")
1030
+ if type(manifest.get("api_contract_version")) is not int or manifest["api_contract_version"] < 1:
1031
+ raise RuntimeError("release API contract is invalid")
1032
+ expected_name = f"server-{version}.tgz"
1033
+ expected_prefix = NPM_REGISTRY_BASE
1034
+ else:
1035
+ expected_name = f"agents-server-{version}.tar.gz"
1036
+ expected_prefix = f"{RELEASE_BASE}/download/v{version}/"
1037
+ archive_name = str(archive.get("name") or "")
1038
+ archive_url = str(archive.get("url") or "")
1039
+ archive_sha = str(archive.get("sha256") or "").lower()
1040
+ if archive_name != expected_name or archive_url != expected_prefix + expected_name:
1041
+ raise RuntimeError("release archive location is not trusted")
1042
+ if not re.fullmatch(r"[0-9a-f]{64}", archive_sha):
1043
+ raise RuntimeError("release archive checksum is invalid")
1044
+ return manifest
1045
+
1046
+
1047
+ def verify_npm_release_envelope(
1048
+ envelope: Any, public_key_path: Path, *, expected_version: str | None = None,
1049
+ ) -> dict[str, Any]:
1050
+ """Verify original publisher-signed bytes, never unsigned registry metadata."""
1051
+ if not isinstance(envelope, dict) or set(envelope) != {"manifest_base64", "signature_base64"}:
1052
+ raise RuntimeError("signed npm release envelope is invalid")
1053
+ encoded = envelope.get("manifest_base64")
1054
+ signature = envelope.get("signature_base64")
1055
+ if (not isinstance(encoded, str) or not 1 <= len(encoded) <= ((MAX_NPM_MANIFEST_BYTES + 2) // 3) * 4
1056
+ or not isinstance(signature, str) or len(signature) != 88):
1057
+ raise RuntimeError("signed npm release envelope exceeds its bounds")
1058
+ try:
1059
+ payload = base64.b64decode(encoded, validate=True)
1060
+ signature_bytes = base64.b64decode(signature, validate=True)
1061
+ candidate = json.loads(payload)
1062
+ except (ValueError, binascii.Error, UnicodeError) as exc:
1063
+ raise RuntimeError("signed npm release envelope is invalid") from exc
1064
+ if len(payload) > MAX_NPM_MANIFEST_BYTES or len(signature_bytes) != 64 or not isinstance(candidate, dict):
1065
+ raise RuntimeError("signed npm release envelope is invalid")
1066
+ manifest = verify_manifest(
1067
+ payload, signature_bytes, public_key_path, expected_version=expected_version,
1068
+ track=str(candidate.get("track") or ""), allow_npm=True,
1069
+ )
1070
+ if manifest.get("schema") != 2 or manifest.get("distribution") != "npm":
1071
+ raise RuntimeError("a signed npm release descriptor is required")
1072
+ return manifest
1073
+
1074
+
1075
+ def verify_npm_archive(content: bytes, manifest: dict[str, Any]) -> None:
1076
+ """Check both publisher-bound hashes and exact size before extraction."""
1077
+ if (len(content) != manifest["archive"]["size"]
1078
+ or hashlib.sha256(content).hexdigest() != manifest["archive"]["sha256"].lower()
1079
+ or "sha512-" + base64.b64encode(hashlib.sha512(content).digest()).decode("ascii") != manifest["npm"]["integrity"]):
1080
+ raise RuntimeError("npm archive does not match the signed release descriptor")
1081
+
1082
+
1083
+ def download_npm_archive(manifest: dict[str, Any]) -> bytes:
1084
+ """Fetch the exact signed registry location without following redirects."""
1085
+ class NoRedirect(urllib.request.HTTPRedirectHandler):
1086
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
1087
+ raise RuntimeError("npm release archive redirects are not allowed")
1088
+
1089
+ request = urllib.request.Request(manifest["archive"]["url"], headers={"User-Agent": "AgentsServer-Updater/1"})
1090
+ size = manifest["archive"]["size"]
1091
+ with urllib.request.build_opener(NoRedirect()).open(request, timeout=120.0) as response:
1092
+ if response.geturl() != manifest["archive"]["url"]:
1093
+ raise RuntimeError("npm release archive location changed")
1094
+ declared = response.headers.get("Content-Length")
1095
+ if declared is not None and int(declared) != size:
1096
+ raise RuntimeError("npm release archive size changed")
1097
+ content = response.read(size + 1)
1098
+ verify_npm_archive(content, manifest)
1099
+ return content
1100
+
1101
+
1102
+ def check_release(
1103
+ public_key_path: Path,
1104
+ track: str = "stable",
1105
+ *,
1106
+ expected_version: str | None = None,
1107
+ require_latest: bool = False,
1108
+ ) -> dict[str, Any]:
1109
+ try:
1110
+ return _check_release(
1111
+ public_key_path, track, expected_version=expected_version,
1112
+ require_latest=require_latest,
1113
+ )
1114
+ except HTTPError as exc:
1115
+ delay = release_rate_limit_delay(exc)
1116
+ if delay is not None:
1117
+ exc.close()
1118
+ raise ReleaseRateLimitedError(delay) from exc
1119
+ raise
1120
+
1121
+
1122
+ def _check_release(
1123
+ public_key_path: Path,
1124
+ track: str = "stable",
1125
+ *,
1126
+ expected_version: str | None = None,
1127
+ require_latest: bool = False,
1128
+ ) -> dict[str, Any]:
1129
+ track = normalized_release_track(track)
1130
+ if expected_version is not None:
1131
+ version = str(expected_version).strip()
1132
+ if not VERSION_PATTERN.fullmatch(version):
1133
+ raise RuntimeError("expected release version is invalid")
1134
+ if release_track(version) != track:
1135
+ raise RuntimeError(
1136
+ f"expected release {version} is not on the requested {track} track"
1137
+ )
1138
+ if require_latest:
1139
+ latest_manifest = check_release(public_key_path, track)
1140
+ latest_version = str(latest_manifest.get("version") or "")
1141
+ if latest_version != version:
1142
+ raise RuntimeError(
1143
+ f"requested {track} release {version} is no longer the latest "
1144
+ f"signed {track} release {latest_version}"
1145
+ )
1146
+ # The discovery path verified this manifest against the immutable
1147
+ # versioned tag URL. Return that exact pinned document instead of
1148
+ # fetching a mutable 'latest' alias or selecting a newer release.
1149
+ return latest_manifest
1150
+ try:
1151
+ manifest_bytes = download_bytes(
1152
+ release_manifest_url(version),
1153
+ MAX_METADATA_BYTES,
1154
+ )
1155
+ signature = download_bytes(
1156
+ release_signature_url(version),
1157
+ MAX_METADATA_BYTES,
1158
+ )
1159
+ except HTTPError as exc:
1160
+ if exc.code == 404:
1161
+ raise ReleaseUnavailableError(
1162
+ f"Signed {track} AgentsServer release {version} is unavailable."
1163
+ ) from exc
1164
+ raise
1165
+ return verify_manifest(
1166
+ manifest_bytes,
1167
+ signature,
1168
+ public_key_path,
1169
+ expected_version=version,
1170
+ track=track,
1171
+ )
1172
+
1173
+ try:
1174
+ releases_bytes = download_bytes(RELEASES_API_URL, MAX_METADATA_BYTES)
1175
+ except HTTPError as exc:
1176
+ if exc.code == 404:
1177
+ raise ReleaseUnavailableError("No signed AgentsServer release has been published yet.") from exc
1178
+ # Do not turn a rejected API request into a burst of HTML requests.
1179
+ # The caller shares the Retry-After delay across subsequent checks.
1180
+ raise
1181
+ else:
1182
+ try:
1183
+ releases = json.loads(releases_bytes)
1184
+ except json.JSONDecodeError as exc:
1185
+ raise RuntimeError("GitHub releases response is invalid JSON") from exc
1186
+ candidates = release_candidates(releases, track)
1187
+ if not candidates:
1188
+ raise ReleaseUnavailableError(f"No signed {track} AgentsServer release is available.")
1189
+
1190
+ for version in candidates:
1191
+ try:
1192
+ manifest_bytes = download_bytes(release_manifest_url(version), MAX_METADATA_BYTES)
1193
+ signature = download_bytes(release_signature_url(version), MAX_METADATA_BYTES)
1194
+ except HTTPError as exc:
1195
+ if exc.code == 404:
1196
+ continue
1197
+ raise
1198
+ return verify_manifest(
1199
+ manifest_bytes,
1200
+ signature,
1201
+ public_key_path,
1202
+ expected_version=version,
1203
+ track=track,
1204
+ )
1205
+ raise ReleaseUnavailableError(f"No signed {track} AgentsServer release is available.")
1206
+
1207
+
1208
+ def release_transition_allowed(current: str, target: str, track: str = "stable") -> bool:
1209
+ """Allow forward updates and an explicit prerelease-to-stable channel exit."""
1210
+ track = normalized_release_track(track)
1211
+ if release_track(target) != track:
1212
+ return False
1213
+ if version_key(target) > version_key(current):
1214
+ return True
1215
+ return (
1216
+ version_key(target) < version_key(current)
1217
+ and track == "stable"
1218
+ and version_is_prerelease(current)
1219
+ and not version_is_prerelease(target)
1220
+ )
1221
+
1222
+
1223
+ def safe_extract(archive_path: Path, destination: Path, *, npm_manifest: dict[str, Any] | None = None) -> Path:
1224
+ destination = destination.resolve()
1225
+ with tarfile.open(archive_path, "r:gz") as archive:
1226
+ members = archive.getmembers()
1227
+ for member in members:
1228
+ target = (destination / member.name).resolve()
1229
+ if destination != target and destination not in target.parents:
1230
+ raise RuntimeError("release archive contains an unsafe path")
1231
+ if member.issym() or member.islnk():
1232
+ raise RuntimeError("release archive must not contain links")
1233
+ if npm_manifest is not None and not (member.isfile() or member.isdir()):
1234
+ raise RuntimeError("npm release archive contains a special file")
1235
+ if npm_manifest is not None:
1236
+ if (len(members) > 4096 or sum(member.size for member in members) > 1024 * 1024 * 1024
1237
+ or len({member.name for member in members}) != len(members)):
1238
+ raise RuntimeError("npm release archive exceeds its extraction bounds")
1239
+ archive.extractall(destination, members=members, filter="data")
1240
+ if npm_manifest is not None:
1241
+ if {entry.name for entry in destination.iterdir()} != {"package"}:
1242
+ raise RuntimeError("npm release archive has an invalid layout")
1243
+ package = destination / "package"
1244
+ metadata = json.loads((package / "package.json").read_text())
1245
+ source = package / "server"
1246
+ if (metadata.get("name") != NPM_PACKAGE_NAME or metadata.get("version") != npm_manifest["version"]
1247
+ or not (source / "install.sh").is_file()
1248
+ or (source / "VERSION").read_text().strip() != npm_manifest["version"]):
1249
+ raise RuntimeError("npm release archive payload identity does not match")
1250
+ return source
1251
+ roots = [entry for entry in destination.iterdir() if entry.is_dir()]
1252
+ if len(roots) != 1 or not (roots[0] / "install.sh").is_file():
1253
+ raise RuntimeError("release archive has an invalid layout")
1254
+ return roots[0]
1255
+
1256
+
1257
+ def run_update(args: argparse.Namespace) -> None:
1258
+ status_path = Path(args.status_file).expanduser().resolve()
1259
+ public_key = Path(args.public_key).expanduser().resolve()
1260
+ track = normalized_release_track(getattr(args, "track", "stable"))
1261
+ auth_token = consume_auth_token_file(getattr(args, "auth_token_file", None))
1262
+ expected_server_identity = str(
1263
+ getattr(args, "expected_server_identity", "") or ""
1264
+ ).strip()
1265
+ if not expected_server_identity:
1266
+ raise RuntimeError("managed update is missing the stable server identity")
1267
+ update_id = str(getattr(args, "update_id", "") or "").strip()
1268
+ if re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._:-]{0,127}", update_id) is None:
1269
+ raise RuntimeError("managed update is missing a valid update ID")
1270
+ expected_service_cgroup = str(
1271
+ getattr(args, "expected_service_cgroup", "") or ""
1272
+ ).strip() or None
1273
+ if expected_service_cgroup is not None and re.fullmatch(
1274
+ r"/(?:[A-Za-z0-9_.@:-]+/)*[A-Za-z0-9_.@:-]+",
1275
+ expected_service_cgroup,
1276
+ ) is None:
1277
+ raise RuntimeError("managed update has an invalid service cgroup")
1278
+ repair_failed_team_hub_host = bool(
1279
+ getattr(args, "repair_failed_team_hub_host", False)
1280
+ )
1281
+ expected_team_hub_id = str(
1282
+ getattr(args, "expected_team_hub_id", "") or ""
1283
+ ).strip() or None
1284
+ raw_team_hub_transport = getattr(args, "expected_team_hub_transport", None)
1285
+ expected_team_hub_transport = (
1286
+ str(raw_team_hub_transport).strip()
1287
+ if raw_team_hub_transport is not None
1288
+ else None
1289
+ )
1290
+ raw_team_hub_url = getattr(args, "expected_team_hub_url", None)
1291
+ expected_team_hub_url = (
1292
+ str(raw_team_hub_url).strip()
1293
+ if raw_team_hub_url is not None
1294
+ else None
1295
+ )
1296
+ raw_team_hub_direct_ip_url = getattr(
1297
+ args, "expected_team_hub_direct_ip_url", None
1298
+ )
1299
+ expected_team_hub_direct_ip_url = (
1300
+ str(raw_team_hub_direct_ip_url).strip()
1301
+ if raw_team_hub_direct_ip_url is not None
1302
+ else None
1303
+ )
1304
+ team_hub_snapshot = str(
1305
+ getattr(args, "team_hub_snapshot", "") or ""
1306
+ ).strip() or None
1307
+ team_hub_data_dir = str(
1308
+ getattr(args, "team_hub_data_dir", "") or ""
1309
+ ).strip() or None
1310
+ if any((expected_team_hub_id, team_hub_snapshot, team_hub_data_dir)) and not all(
1311
+ (expected_team_hub_id, team_hub_snapshot, team_hub_data_dir)
1312
+ ):
1313
+ raise RuntimeError("managed Team Hub rollback arguments must be complete")
1314
+ if repair_failed_team_hub_host and any(
1315
+ (expected_team_hub_id, team_hub_snapshot, team_hub_data_dir)
1316
+ ):
1317
+ raise RuntimeError(
1318
+ "failed Team Hub repair cannot reuse live-host rollback arguments"
1319
+ )
1320
+ if expected_team_hub_id is None and not repair_failed_team_hub_host:
1321
+ if (
1322
+ expected_team_hub_transport is not None
1323
+ or expected_team_hub_url is not None
1324
+ or expected_team_hub_direct_ip_url is not None
1325
+ ):
1326
+ raise RuntimeError("managed Team Hub transport arguments require a Hub identity")
1327
+ if repair_failed_team_hub_host and (
1328
+ raw_team_hub_transport is None
1329
+ or raw_team_hub_url is None
1330
+ or raw_team_hub_direct_ip_url is None
1331
+ ):
1332
+ raise RuntimeError(
1333
+ "failed Team Hub repair requires exact transport continuity arguments"
1334
+ )
1335
+ if (
1336
+ expected_team_hub_id is not None or repair_failed_team_hub_host
1337
+ ) and expected_team_hub_transport is None:
1338
+ if raw_team_hub_url is not None:
1339
+ raise RuntimeError("legacy loopback Team Hub cannot have a remote URL")
1340
+ elif expected_team_hub_id is not None or repair_failed_team_hub_host:
1341
+ if expected_team_hub_transport == "loopback":
1342
+ if raw_team_hub_url is None or expected_team_hub_url != "":
1343
+ raise RuntimeError("loopback Team Hub cannot have a remote URL")
1344
+ expected_team_hub_url = None
1345
+ elif expected_team_hub_transport in {"tailscale_serve", "direct_ip"}:
1346
+ if expected_team_hub_url is None:
1347
+ raise RuntimeError("remote Team Hub transport requires its exact URL")
1348
+ try:
1349
+ from team_hub_host import ( # Imported only for the managed-Hub path.
1350
+ TEAM_HUB_MODE_HOST,
1351
+ configured_team_hub_endpoint,
1352
+ )
1353
+
1354
+ resolved_transport, resolved_url, _host, config_error = (
1355
+ configured_team_hub_endpoint(
1356
+ TEAM_HUB_MODE_HOST,
1357
+ expected_team_hub_url,
1358
+ expected_team_hub_transport,
1359
+ args.port,
1360
+ )
1361
+ )
1362
+ except Exception as exc:
1363
+ raise RuntimeError("could not validate the expected Team Hub URL") from exc
1364
+ if (
1365
+ config_error is not None
1366
+ or resolved_transport != expected_team_hub_transport
1367
+ or resolved_url != expected_team_hub_url
1368
+ ):
1369
+ raise RuntimeError("expected Team Hub URL is invalid")
1370
+ else:
1371
+ raise RuntimeError("managed Team Hub transport is invalid")
1372
+ if expected_team_hub_direct_ip_url is not None:
1373
+ if expected_team_hub_id is None and not repair_failed_team_hub_host:
1374
+ raise RuntimeError("managed Team Hub direct-IP route requires a Hub identity")
1375
+ if expected_team_hub_direct_ip_url:
1376
+ try:
1377
+ from team_hub_host import (
1378
+ TEAM_HUB_MODE_HOST,
1379
+ configured_team_hub_endpoint,
1380
+ )
1381
+
1382
+ direct_transport, direct_url, _host, direct_error = (
1383
+ configured_team_hub_endpoint(
1384
+ TEAM_HUB_MODE_HOST,
1385
+ expected_team_hub_direct_ip_url,
1386
+ "direct_ip",
1387
+ args.port,
1388
+ )
1389
+ )
1390
+ except Exception as exc:
1391
+ raise RuntimeError(
1392
+ "could not validate the expected Team Hub direct-IP route"
1393
+ ) from exc
1394
+ if (
1395
+ direct_error is not None
1396
+ or direct_transport != "direct_ip"
1397
+ or direct_url != expected_team_hub_direct_ip_url
1398
+ ):
1399
+ raise RuntimeError("expected Team Hub direct-IP route is invalid")
1400
+ if (
1401
+ expected_team_hub_transport == "direct_ip"
1402
+ and expected_team_hub_direct_ip_url != expected_team_hub_url
1403
+ ):
1404
+ raise RuntimeError("primary direct-IP Team Hub route changed")
1405
+ if repair_failed_team_hub_host:
1406
+ with server_update_status_lock(status_path):
1407
+ admitted = _read_status_unlocked(status_path)
1408
+ expected_repair_routes = [
1409
+ {
1410
+ "transport": expected_team_hub_transport,
1411
+ "hub_url": expected_team_hub_url,
1412
+ }
1413
+ ]
1414
+ if (
1415
+ expected_team_hub_direct_ip_url
1416
+ and expected_team_hub_transport != "direct_ip"
1417
+ ):
1418
+ expected_repair_routes.append(
1419
+ {
1420
+ "transport": "direct_ip",
1421
+ "hub_url": expected_team_hub_direct_ip_url,
1422
+ }
1423
+ )
1424
+ expected_status = {
1425
+ "update_id": update_id,
1426
+ "phase": "starting",
1427
+ "team_hub_repair_mode": "failed_start",
1428
+ "team_hub_host_server_identity": expected_server_identity,
1429
+ "team_hub_transport": expected_team_hub_transport,
1430
+ "team_hub_url": expected_team_hub_url,
1431
+ "team_hub_direct_ip_url": expected_team_hub_direct_ip_url,
1432
+ "team_hub_routes": expected_repair_routes,
1433
+ }
1434
+ if any(admitted.get(name) != value for name, value in expected_status.items()):
1435
+ raise RuntimeError(
1436
+ "failed Team Hub repair is not owned by the exact admitted update"
1437
+ )
1438
+ update_status(
1439
+ status_path,
1440
+ expected_update_id=update_id,
1441
+ phase="checking",
1442
+ track=track,
1443
+ runner_pid=os.getpid(),
1444
+ heartbeat_at=utc_now(),
1445
+ message=f"Checking the signed {track} release manifest.",
1446
+ )
1447
+ expected_version = (
1448
+ str(getattr(args, "expected_version", "") or "").strip() or None
1449
+ )
1450
+ current_version = str(
1451
+ getattr(args, "current_version", "") or ""
1452
+ ).strip()
1453
+ require_latest = (
1454
+ track == "stable"
1455
+ and bool(current_version)
1456
+ and version_is_prerelease(current_version)
1457
+ )
1458
+ prepared_update = None
1459
+ prepared_receipt = getattr(args, "prepared_receipt", None)
1460
+ if prepared_receipt:
1461
+ from update_preparation import verify_prepared_status
1462
+ with server_update_status_lock(status_path):
1463
+ admitted = _read_status_unlocked(status_path)
1464
+ if admitted.get("update_id") != update_id or admitted.get("phase") not in RUNNER_OWNED_ACTIVE_PHASES:
1465
+ raise RuntimeError("Prepared release is not owned by this update")
1466
+ if admitted.get("_prepared_update", {}).get("receipt_sha256") != getattr(args, "prepared_receipt_sha256", None):
1467
+ raise RuntimeError("Prepared release receipt digest changed")
1468
+ prepared_update = verify_prepared_status(admitted,
1469
+ root=Path(os.environ["AGENTS_SERVER_INSTALL_DIR"]).expanduser().resolve(), public_key=public_key)
1470
+ if prepared_update["receipt"] != prepared_receipt:
1471
+ raise RuntimeError("Prepared release receipt path changed")
1472
+ manifest = prepared_update["manifest"]
1473
+ if manifest["track"] != track:
1474
+ raise RuntimeError("Prepared release track changed")
1475
+ elif getattr(args, "npm_descriptor", False):
1476
+ with server_update_status_lock(status_path):
1477
+ admitted = _read_status_unlocked(status_path)
1478
+ if admitted.get("update_id") != update_id or admitted.get("phase") not in RUNNER_OWNED_ACTIVE_PHASES:
1479
+ raise RuntimeError("signed npm release is not owned by this update")
1480
+ manifest = verify_npm_release_envelope(admitted.get("_npm_release"), public_key, expected_version=expected_version)
1481
+ if manifest["track"] != track or release_track(current_version) != track:
1482
+ raise RuntimeError("automatic npm updates cannot change release channels")
1483
+ else:
1484
+ manifest = check_release(
1485
+ public_key,
1486
+ track,
1487
+ expected_version=expected_version,
1488
+ require_latest=require_latest,
1489
+ )
1490
+ version = str(manifest["version"])
1491
+ if expected_version and version != expected_version:
1492
+ raise RuntimeError(
1493
+ f"resolved signed release is {version}, not {expected_version}"
1494
+ )
1495
+ if current_version and not release_transition_allowed(current_version, version, track):
1496
+ raise RuntimeError(
1497
+ f"resolved release {version} is not newer than installed version {current_version}; "
1498
+ "managed updates only permit forward updates or an explicit beta-to-stable channel switch"
1499
+ )
1500
+
1501
+ with tempfile.TemporaryDirectory(prefix="agents-server-update-") as temporary:
1502
+ root = Path(temporary)
1503
+ if prepared_update is not None:
1504
+ source = Path(prepared_update["candidate"])
1505
+ update_status(status_path, expected_update_id=update_id, phase="verifying",
1506
+ target_version=version, message="Prepared runtime and signed release verified.")
1507
+ else:
1508
+ archive_path = root / str(manifest["archive"]["name"])
1509
+ update_status(
1510
+ status_path,
1511
+ expected_update_id=update_id,
1512
+ phase="downloading",
1513
+ track=track,
1514
+ target_version=version,
1515
+ message=f"Downloading AgentsServer {version}.",
1516
+ )
1517
+ archive_bytes = (download_npm_archive(manifest) if manifest.get("schema") == 2 else
1518
+ download_bytes(str(manifest["archive"]["url"]), MAX_ARCHIVE_BYTES, timeout=120.0))
1519
+ digest = hashlib.sha256(archive_bytes).hexdigest()
1520
+ if digest != manifest["archive"]["sha256"]:
1521
+ raise RuntimeError("release archive checksum does not match the signed manifest")
1522
+ archive_path.write_bytes(archive_bytes)
1523
+
1524
+ update_status(
1525
+ status_path,
1526
+ expected_update_id=update_id,
1527
+ phase="verifying",
1528
+ message="Signature and archive checksum verified.",
1529
+ )
1530
+ source = (safe_extract(archive_path, root / "extracted", npm_manifest=manifest)
1531
+ if manifest.get("schema") == 2 else safe_extract(archive_path, root / "extracted"))
1532
+ install = source / "install.sh"
1533
+ if prepared_update is None:
1534
+ install.chmod(0o755)
1535
+ command = [
1536
+ str(install),
1537
+ "--non-interactive",
1538
+ "--release-version", version,
1539
+ "--port", str(args.port),
1540
+ "--bind", args.bind,
1541
+ "--expected-server-identity", expected_server_identity,
1542
+ ]
1543
+ if prepared_update is not None:
1544
+ command.extend(["--activate-prepared", prepared_update["receipt"],
1545
+ "--prepared-archive-sha256", manifest["archive"]["sha256"],
1546
+ "--execution-mode", "split"])
1547
+ handoff_file = getattr(args, "execution_handoff_file", None)
1548
+ if handoff_file:
1549
+ command.extend(["--execution-handoff-file", handoff_file])
1550
+ if manifest.get("schema") == 2 or prepared_update is not None:
1551
+ command.extend(["--expected-api-contract", str(manifest["api_contract_version"])])
1552
+ if expected_team_hub_id is not None:
1553
+ command.extend(
1554
+ [
1555
+ "--expected-team-hub-id", expected_team_hub_id,
1556
+ "--team-hub-snapshot", str(team_hub_snapshot),
1557
+ "--team-hub-data-dir", str(team_hub_data_dir),
1558
+ "--team-hub-operation-id", update_id,
1559
+ ]
1560
+ )
1561
+ if expected_team_hub_transport is not None:
1562
+ command.extend(
1563
+ [
1564
+ "--expected-team-hub-transport",
1565
+ expected_team_hub_transport,
1566
+ "--expected-team-hub-url",
1567
+ expected_team_hub_url or "",
1568
+ ]
1569
+ )
1570
+ if expected_team_hub_direct_ip_url is not None:
1571
+ command.extend(
1572
+ [
1573
+ "--expected-team-hub-direct-ip-url",
1574
+ expected_team_hub_direct_ip_url,
1575
+ ]
1576
+ )
1577
+ elif repair_failed_team_hub_host:
1578
+ command.extend(
1579
+ [
1580
+ "--repair-failed-team-hub-host",
1581
+ "--managed-update-id",
1582
+ update_id,
1583
+ "--expected-team-hub-transport",
1584
+ expected_team_hub_transport or "",
1585
+ "--expected-team-hub-url",
1586
+ expected_team_hub_url or "",
1587
+ "--expected-team-hub-direct-ip-url",
1588
+ expected_team_hub_direct_ip_url or "",
1589
+ ]
1590
+ )
1591
+ wait_for_server_idle(
1592
+ args.port,
1593
+ status_path=status_path,
1594
+ expected_update_id=update_id,
1595
+ expected_server_identity=expected_server_identity,
1596
+ token=auth_token,
1597
+ require_verified_service_cgroup=expected_service_cgroup is not None,
1598
+ )
1599
+ update_status(
1600
+ status_path,
1601
+ expected_update_id=update_id,
1602
+ phase="installing",
1603
+ message=f"Installing AgentsServer {version} with rollback protection.",
1604
+ )
1605
+ if prepared_update is not None:
1606
+ from update_recovery import activation_intent
1607
+ update_status(status_path, expected_update_id=update_id,
1608
+ _activation_recovery=activation_intent(
1609
+ root=Path(os.environ["AGENTS_SERVER_INSTALL_DIR"]), candidate=source,
1610
+ version=version, api_contract=manifest["api_contract_version"],
1611
+ update_id=update_id, server_identity=expected_server_identity))
1612
+ log_path = status_path.with_name("server-update.log")
1613
+ run_installer(
1614
+ command,
1615
+ cwd=source,
1616
+ status_path=status_path,
1617
+ log_path=log_path,
1618
+ version=version,
1619
+ expected_update_id=update_id,
1620
+ managed_update_id=(
1621
+ update_id
1622
+ if expected_service_cgroup is not None
1623
+ or repair_failed_team_hub_host
1624
+ else None
1625
+ ),
1626
+ expected_service_cgroup=expected_service_cgroup,
1627
+ )
1628
+ identity_arguments: dict[str, Any] = {
1629
+ "token": auth_token,
1630
+ "expected_server_identity": expected_server_identity,
1631
+ "expected_team_hub_id": expected_team_hub_id,
1632
+ }
1633
+ if manifest.get("schema") == 2:
1634
+ identity_arguments.update(
1635
+ expected_server_version=version,
1636
+ expected_api_contract_version=manifest["api_contract_version"],
1637
+ )
1638
+ if expected_team_hub_transport is not None and not repair_failed_team_hub_host:
1639
+ identity_arguments["expected_team_hub_transport"] = (
1640
+ expected_team_hub_transport
1641
+ )
1642
+ if expected_team_hub_url is not None and not repair_failed_team_hub_host:
1643
+ identity_arguments["expected_team_hub_url"] = expected_team_hub_url
1644
+ if (
1645
+ expected_team_hub_direct_ip_url is not None
1646
+ and not repair_failed_team_hub_host
1647
+ ):
1648
+ identity_arguments["expected_team_hub_direct_ip_url"] = (
1649
+ expected_team_hub_direct_ip_url
1650
+ )
1651
+ assert_post_update_identity(args.port, **identity_arguments)
1652
+ if repair_failed_team_hub_host:
1653
+ assert_repaired_team_hub_identity(
1654
+ args.port,
1655
+ token=auth_token,
1656
+ expected_server_identity=expected_server_identity,
1657
+ expected_team_hub_transport=expected_team_hub_transport or "",
1658
+ expected_team_hub_url=expected_team_hub_url,
1659
+ expected_team_hub_direct_ip_url=(
1660
+ expected_team_hub_direct_ip_url or ""
1661
+ ),
1662
+ )
1663
+ # install.sh owns the success clear while it can still stop the
1664
+ # candidate, restore the verified snapshot, and restart the old
1665
+ # release. The runner clears only failures before install starts.
1666
+
1667
+ update_status(
1668
+ status_path,
1669
+ expected_update_id=update_id,
1670
+ phase="complete",
1671
+ update_available=False,
1672
+ message=f"AgentsServer {version} is installed and healthy.",
1673
+ track=track,
1674
+ installed_version=version,
1675
+ heartbeat_at=None,
1676
+ elapsed_seconds=None,
1677
+ runner_pid=None,
1678
+ error_code=None,
1679
+ error_action=None,
1680
+ retryable=None,
1681
+ finished_at=utc_now(),
1682
+ )
1683
+
1684
+
1685
+ def main() -> int:
1686
+ parser = argparse.ArgumentParser()
1687
+ parser.add_argument("--status-file", required=True)
1688
+ parser.add_argument("--public-key", required=True)
1689
+ parser.add_argument("--port", required=True, type=int)
1690
+ parser.add_argument("--bind", required=True)
1691
+ parser.add_argument("--expected-version")
1692
+ parser.add_argument("--current-version")
1693
+ parser.add_argument("--track", choices=sorted(RELEASE_TRACKS), default="stable")
1694
+ parser.add_argument("--npm-descriptor", action="store_true")
1695
+ parser.add_argument("--prepared-receipt")
1696
+ parser.add_argument("--prepared-receipt-sha256")
1697
+ parser.add_argument("--execution-handoff-file")
1698
+ parser.add_argument("--recover-only", action="store_true")
1699
+ parser.add_argument("--recovery-transaction")
1700
+ parser.add_argument("--auth-token-file")
1701
+ parser.add_argument("--expected-server-identity", required=True)
1702
+ parser.add_argument("--update-id", required=True)
1703
+ parser.add_argument("--expected-service-cgroup")
1704
+ parser.add_argument("--expected-team-hub-id")
1705
+ parser.add_argument("--expected-team-hub-transport")
1706
+ parser.add_argument("--expected-team-hub-url")
1707
+ parser.add_argument("--expected-team-hub-direct-ip-url")
1708
+ parser.add_argument("--team-hub-snapshot")
1709
+ parser.add_argument("--team-hub-data-dir")
1710
+ parser.add_argument("--repair-failed-team-hub-host", action="store_true")
1711
+ args = parser.parse_args()
1712
+ try:
1713
+ if args.recover_only:
1714
+ from update_recovery import run_recovery
1715
+ run_recovery(args)
1716
+ else:
1717
+ run_update(args)
1718
+ return 0
1719
+ except Exception as exc:
1720
+ status_path = Path(args.status_file).expanduser().resolve()
1721
+ update_id = str(getattr(args, "update_id", "") or "").strip()
1722
+ if args.recover_only:
1723
+ # Only install.sh may release an interrupted transaction's fences.
1724
+ # Keep the journal/hold intact and expose a retryable recovery error.
1725
+ try:
1726
+ update_status(status_path, expected_update_id=update_id,
1727
+ phase="installing", runner_pid=None, heartbeat_at=None, retryable=True,
1728
+ error_code="server_update_recovery_failed",
1729
+ error_action="Retry recovery from the application update settings.",
1730
+ message="The interrupted server update could not finish recovery. See server-update.log.",
1731
+ finished_at=utc_now())
1732
+ except UpdateOwnershipLostError:
1733
+ pass
1734
+ return 1
1735
+ try:
1736
+ release_handoff = False
1737
+ with server_update_status_lock(status_path):
1738
+ current = _read_status_unlocked(status_path)
1739
+ if (
1740
+ str(current.get("update_id") or "") != update_id
1741
+ or str(current.get("phase") or "")
1742
+ not in RUNNER_OWNED_ACTIVE_PHASES
1743
+ ):
1744
+ return 1
1745
+ phase = str(current.get("phase") or "")
1746
+ if phase in {"installing", "restarting"} and current.get("_activation_recovery"):
1747
+ from update_recovery import journal_present
1748
+ if journal_present(Path(os.environ["AGENTS_SERVER_INSTALL_DIR"]).expanduser().resolve()):
1749
+ _update_status_unlocked(status_path, current, runner_pid=None,
1750
+ heartbeat_at=None, retryable=True,
1751
+ error_code="server_update_recovery_pending",
1752
+ message="The installer was interrupted. Its retained activation will be recovered.")
1753
+ return 1
1754
+ if phase in {
1755
+ "starting",
1756
+ "checking",
1757
+ "downloading",
1758
+ "verifying",
1759
+ }:
1760
+ # Keep the owning active status in place until its exact
1761
+ # Hub fence is cleared. A clear failure therefore remains
1762
+ # fail-closed instead of publishing a false terminal row.
1763
+ clear_team_hub_maintenance(args)
1764
+ release_handoff = bool(getattr(args, "execution_handoff_file", None))
1765
+ elif phase in {"installing", "restarting"} and \
1766
+ team_hub_maintenance_fence_present(args):
1767
+ # Once install.sh starts, only its verified rollback or
1768
+ # successful candidate handoff may clear the fence. Keep
1769
+ # the active row if recovery was not proven complete.
1770
+ return 1
1771
+ # Installer preflight can fail after the public phase changed
1772
+ # to installing but before it creates a transaction. The same
1773
+ # exact old hold then needs cleanup; the helper refuses any
1774
+ # journal, takeover or changed native worker before releasing.
1775
+ release_handoff = release_handoff or bool(
1776
+ current.get("_execution_handoff") and getattr(args, "execution_handoff_file", None))
1777
+ _update_status_unlocked(
1778
+ status_path,
1779
+ current,
1780
+ phase="failed",
1781
+ message=str(exc),
1782
+ heartbeat_at=None,
1783
+ runner_pid=None,
1784
+ **({"error_code": "server_update_handoff_release_failed", "retryable": True,
1785
+ "error_action": "Retry the update to finish releasing its execution hold."}
1786
+ if release_handoff else {}),
1787
+ finished_at=utc_now(),
1788
+ )
1789
+ # The sealed lease still owns admission after the failed status
1790
+ # is durable. Release outside the status flock: the authenticated
1791
+ # worker callback reads this same status while proving it is idle.
1792
+ if release_handoff:
1793
+ from update_handoff import release_existing_handoff
1794
+ try:
1795
+ release_existing_handoff(
1796
+ Path(os.environ["AGENTS_SERVER_INSTALL_DIR"]).expanduser().resolve(),
1797
+ Path(args.execution_handoff_file),
1798
+ )
1799
+ with server_update_status_lock(status_path):
1800
+ current = _read_status_unlocked(status_path)
1801
+ if current.get("update_id") == update_id and current.get("phase") == "failed":
1802
+ _update_status_unlocked(status_path, current, _execution_handoff=None,
1803
+ error_code=None, error_action=None, retryable=True)
1804
+ except Exception:
1805
+ with server_update_status_lock(status_path):
1806
+ current = _read_status_unlocked(status_path)
1807
+ if current.get("update_id") == update_id and current.get("phase") == "failed":
1808
+ _update_status_unlocked(status_path, current, retryable=True,
1809
+ error_code="server_update_handoff_release_failed",
1810
+ error_action="Retry the update to finish releasing its execution hold.",
1811
+ message="The update failed and its execution hold could not be released safely.")
1812
+ raise
1813
+ except (UpdateOwnershipLostError, RuntimeError, OSError, ValueError):
1814
+ pass
1815
+ return 1
1816
+
1817
+
1818
+ if __name__ == "__main__":
1819
+ raise SystemExit(main())