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