@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,264 @@
|
|
|
1
|
+
"""Detached, resumable signed-update preparation while agent admission stays open.
|
|
2
|
+
|
|
3
|
+
This process may download and build a private candidate, never activate it.
|
|
4
|
+
The server's existing pending-update waiter owns the later idle handoff.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import base64
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
import fcntl
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import re
|
|
17
|
+
import stat
|
|
18
|
+
import subprocess
|
|
19
|
+
import tempfile
|
|
20
|
+
import time
|
|
21
|
+
from typing import Any
|
|
22
|
+
|
|
23
|
+
import update_runner as updates
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PreparationSuperseded(RuntimeError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _owned_directory(path: Path, *, create: bool = False) -> Path:
|
|
31
|
+
if not path.is_absolute() or path.resolve() != path:
|
|
32
|
+
raise ValueError("Preparation paths must be absolute and contain no links")
|
|
33
|
+
if create:
|
|
34
|
+
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
|
35
|
+
info = path.lstat()
|
|
36
|
+
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) & 0o022:
|
|
37
|
+
raise ValueError("Preparation directory is not safely owned")
|
|
38
|
+
return path
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def preparation_directory(root: Path, preparation_id: str, *, create: bool = False) -> Path:
|
|
42
|
+
if re.fullmatch(r"[0-9a-f]{32}", preparation_id) is None:
|
|
43
|
+
raise ValueError("Invalid preparation identity")
|
|
44
|
+
_owned_directory(root)
|
|
45
|
+
parent = _owned_directory(root / ".update-preparations", create=create)
|
|
46
|
+
return _owned_directory(parent / preparation_id, create=create)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@contextmanager
|
|
50
|
+
def preparation_lease(directory: Path, *, blocking: bool = False):
|
|
51
|
+
path = directory / "runner.lock"
|
|
52
|
+
descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
53
|
+
try:
|
|
54
|
+
info = os.fstat(descriptor)
|
|
55
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
|
|
56
|
+
or stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1):
|
|
57
|
+
raise ValueError("Preparation lock is unsafe")
|
|
58
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | (0 if blocking else fcntl.LOCK_NB))
|
|
59
|
+
current = path.lstat()
|
|
60
|
+
if (current.st_dev, current.st_ino) != (info.st_dev, info.st_ino):
|
|
61
|
+
raise ValueError("Preparation lock changed")
|
|
62
|
+
yield descriptor
|
|
63
|
+
finally:
|
|
64
|
+
os.close(descriptor)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def preparation_is_active(root: Path, preparation_id: str) -> bool:
|
|
68
|
+
try:
|
|
69
|
+
directory = preparation_directory(root, preparation_id)
|
|
70
|
+
except FileNotFoundError:
|
|
71
|
+
return False
|
|
72
|
+
try:
|
|
73
|
+
with preparation_lease(directory):
|
|
74
|
+
return False
|
|
75
|
+
except BlockingIOError:
|
|
76
|
+
return True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def update_pending(status_path: Path, preparation_id: str, **changes: Any) -> dict[str, Any]:
|
|
80
|
+
with updates.server_update_status_lock(status_path):
|
|
81
|
+
current = updates._read_status_unlocked(status_path)
|
|
82
|
+
if current.get("phase") != "pending" or current.get("preparation_id") != preparation_id:
|
|
83
|
+
raise PreparationSuperseded("Preparation no longer owns the pending update")
|
|
84
|
+
return updates._update_status_unlocked(status_path, current,
|
|
85
|
+
preparation_heartbeat_at=updates.utc_now(), **changes)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def receipt_path(root: Path, preparation_id: str) -> Path:
|
|
89
|
+
if re.fullmatch(r"[0-9a-f]{32}", preparation_id) is None:
|
|
90
|
+
raise ValueError("Invalid preparation identity")
|
|
91
|
+
return root / ".prepared-receipts" / f"{preparation_id}.json"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def receipt_digest(path: Path) -> str:
|
|
95
|
+
from execution_preparation import MAX_RECEIPT_BYTES
|
|
96
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
97
|
+
try:
|
|
98
|
+
info = os.fstat(descriptor)
|
|
99
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
|
|
100
|
+
or stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1
|
|
101
|
+
or info.st_size > MAX_RECEIPT_BYTES):
|
|
102
|
+
raise ValueError("Prepared candidate receipt is unsafe")
|
|
103
|
+
digest = hashlib.sha256()
|
|
104
|
+
length = 0
|
|
105
|
+
while True:
|
|
106
|
+
data = os.read(descriptor, min(1024 * 1024, MAX_RECEIPT_BYTES + 1 - length))
|
|
107
|
+
if not data:
|
|
108
|
+
break
|
|
109
|
+
length += len(data)
|
|
110
|
+
if length > MAX_RECEIPT_BYTES:
|
|
111
|
+
raise ValueError("Prepared candidate receipt exceeds its limit")
|
|
112
|
+
digest.update(data)
|
|
113
|
+
current = path.lstat()
|
|
114
|
+
after = os.fstat(descriptor)
|
|
115
|
+
if (length != info.st_size or (current.st_dev, current.st_ino) != (info.st_dev, info.st_ino)
|
|
116
|
+
or (after.st_size, after.st_mtime_ns, after.st_ctime_ns) != (info.st_size, info.st_mtime_ns, info.st_ctime_ns)):
|
|
117
|
+
raise ValueError("Prepared candidate receipt changed while reading")
|
|
118
|
+
return digest.hexdigest()
|
|
119
|
+
finally:
|
|
120
|
+
os.close(descriptor)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def verify_prepared_status(status: dict[str, Any], *, root: Path, public_key: Path) -> dict[str, Any]:
|
|
124
|
+
prepared = status.get("_prepared_update")
|
|
125
|
+
if not isinstance(prepared, dict) or prepared.get("preparation_id") != status.get("preparation_id"):
|
|
126
|
+
raise ValueError("Prepared update is not bound to its pending intent")
|
|
127
|
+
envelope = prepared.get("envelope")
|
|
128
|
+
if (not isinstance(envelope, dict) or set(envelope) != {"manifest_base64", "signature_base64"}
|
|
129
|
+
or not all(isinstance(value, str) for value in envelope.values())
|
|
130
|
+
or len(envelope["manifest_base64"]) > updates.MAX_METADATA_BYTES * 2
|
|
131
|
+
or len(envelope["signature_base64"]) != 88):
|
|
132
|
+
raise ValueError("Prepared update signed envelope is invalid")
|
|
133
|
+
manifest = updates.verify_manifest(
|
|
134
|
+
base64.b64decode(envelope["manifest_base64"], validate=True),
|
|
135
|
+
base64.b64decode(envelope["signature_base64"], validate=True), public_key,
|
|
136
|
+
expected_version=status["target_version"], track=status["track"], allow_npm=True)
|
|
137
|
+
preparation_directory(root, status["preparation_id"])
|
|
138
|
+
if prepared.get("mode") == "legacy":
|
|
139
|
+
return {"manifest": manifest, "candidate": None, "receipt": None}
|
|
140
|
+
if prepared.get("mode") != "prepared":
|
|
141
|
+
raise ValueError("Unknown prepared update mode")
|
|
142
|
+
receipt = receipt_path(root, status["preparation_id"])
|
|
143
|
+
if receipt_digest(receipt) != prepared.get("receipt_sha256"):
|
|
144
|
+
raise ValueError("Prepared candidate receipt changed")
|
|
145
|
+
from execution_preparation import validate_prepared
|
|
146
|
+
validated = validate_prepared(root=root, version=manifest["version"],
|
|
147
|
+
api_contract=int(manifest["api_contract_version"]), archive_sha256=manifest["archive"]["sha256"], receipt=receipt)
|
|
148
|
+
return {"manifest": manifest, "candidate": str(validated["candidate"]), "receipt": str(receipt)}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _signed_envelope(status: dict[str, Any], key: Path) -> tuple[dict[str, str], dict[str, Any]]:
|
|
152
|
+
envelope = status.get("_npm_release")
|
|
153
|
+
if envelope is not None:
|
|
154
|
+
manifest = updates.verify_npm_release_envelope(envelope, key, expected_version=status["target_version"])
|
|
155
|
+
if manifest["track"] != status["track"]:
|
|
156
|
+
raise ValueError("Preparation release track changed")
|
|
157
|
+
return envelope, manifest
|
|
158
|
+
version = status["target_version"]
|
|
159
|
+
document = updates.download_bytes(updates.release_manifest_url(version), updates.MAX_METADATA_BYTES)
|
|
160
|
+
signature = updates.download_bytes(updates.release_signature_url(version), updates.MAX_METADATA_BYTES)
|
|
161
|
+
manifest = updates.verify_manifest(document, signature, key, expected_version=version, track=status["track"])
|
|
162
|
+
return {"manifest_base64": base64.b64encode(document).decode(),
|
|
163
|
+
"signature_base64": base64.b64encode(signature).decode()}, manifest
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def run_preparer(command: list[str], *, source: Path, directory: Path,
|
|
167
|
+
status_path: Path, preparation_id: str, lease_descriptor: int) -> None:
|
|
168
|
+
log = directory / "prepare.log"
|
|
169
|
+
descriptor = os.open(log, os.O_WRONLY | os.O_CREAT | os.O_APPEND | os.O_NOFOLLOW, 0o600)
|
|
170
|
+
info = os.fstat(descriptor)
|
|
171
|
+
if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1:
|
|
172
|
+
os.close(descriptor)
|
|
173
|
+
raise ValueError("Preparation log is unsafe")
|
|
174
|
+
with os.fdopen(descriptor, "ab") as output:
|
|
175
|
+
process = subprocess.Popen(command, cwd=source, env=updates.installer_environment(),
|
|
176
|
+
stdin=subprocess.DEVNULL, stdout=output, stderr=subprocess.STDOUT, start_new_session=True,
|
|
177
|
+
# Keep the preparation lease with the actual staging shell if its
|
|
178
|
+
# observer dies. A resumed observer waits, then consumes its receipt.
|
|
179
|
+
pass_fds=(lease_descriptor,))
|
|
180
|
+
deadline = time.monotonic() + updates.INSTALLER_TIMEOUT_SECONDS
|
|
181
|
+
try:
|
|
182
|
+
while process.poll() is None:
|
|
183
|
+
update_pending(status_path, preparation_id, preparation_runner_pid=os.getpid())
|
|
184
|
+
if time.monotonic() >= deadline:
|
|
185
|
+
raise RuntimeError("Candidate dependency preparation timed out")
|
|
186
|
+
time.sleep(0.5)
|
|
187
|
+
if process.returncode:
|
|
188
|
+
raise RuntimeError("Candidate preparation failed; inspect the preparation log")
|
|
189
|
+
finally:
|
|
190
|
+
if process.poll() is None:
|
|
191
|
+
# Prepare-only cannot activate a service. Still allow its own
|
|
192
|
+
# cleanup to finish; never kill a process outside this owner.
|
|
193
|
+
updates.terminate_installer(process)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def run_preparation(*, status_path: Path, root: Path, public_key: Path, preparation_id: str) -> None:
|
|
197
|
+
directory = preparation_directory(root, preparation_id, create=True)
|
|
198
|
+
with preparation_lease(directory) as lease_descriptor:
|
|
199
|
+
status = update_pending(status_path, preparation_id, preparation_phase="checking",
|
|
200
|
+
preparation_runner_pid=os.getpid(), message="Preparing the signed server update; agents can keep working.")
|
|
201
|
+
envelope, manifest = _signed_envelope(status, public_key)
|
|
202
|
+
receipt = receipt_path(root, preparation_id)
|
|
203
|
+
if receipt.exists() or receipt.is_symlink():
|
|
204
|
+
# A killed runner may have finished staging before persisting ready.
|
|
205
|
+
prepared = {"mode": "prepared", "preparation_id": preparation_id, "envelope": envelope,
|
|
206
|
+
"receipt_sha256": receipt_digest(receipt)}
|
|
207
|
+
verify_prepared_status({**status, "_prepared_update": prepared}, root=root, public_key=public_key)
|
|
208
|
+
else:
|
|
209
|
+
update_pending(status_path, preparation_id, preparation_phase="downloading")
|
|
210
|
+
data = (updates.download_npm_archive(manifest) if manifest.get("schema") == 2
|
|
211
|
+
else updates.download_bytes(manifest["archive"]["url"], updates.MAX_ARCHIVE_BYTES, timeout=120))
|
|
212
|
+
if hashlib.sha256(data).hexdigest() != manifest["archive"]["sha256"]:
|
|
213
|
+
raise ValueError("Preparation archive does not match its signed hash")
|
|
214
|
+
with tempfile.TemporaryDirectory(prefix="source-", dir=directory) as temporary:
|
|
215
|
+
work = Path(temporary)
|
|
216
|
+
archive = work / "release.tar.gz"
|
|
217
|
+
archive.write_bytes(data)
|
|
218
|
+
source = updates.safe_extract(archive, work / "extracted",
|
|
219
|
+
npm_manifest=manifest if manifest.get("schema") == 2 else None)
|
|
220
|
+
if not (source / "execution_preparation.py").is_file():
|
|
221
|
+
# Older signed releases retain their established installer.
|
|
222
|
+
prepared = {"mode": "legacy", "preparation_id": preparation_id, "envelope": envelope}
|
|
223
|
+
else:
|
|
224
|
+
update_pending(status_path, preparation_id, preparation_phase="staging")
|
|
225
|
+
command = ["/bin/bash", str(source / "install.sh"), "--non-interactive", "--prepare-only",
|
|
226
|
+
"--prepared-receipt", str(receipt), "--release-version", manifest["version"],
|
|
227
|
+
"--expected-api-contract", str(manifest["api_contract_version"]),
|
|
228
|
+
"--prepared-archive-sha256", manifest["archive"]["sha256"]]
|
|
229
|
+
run_preparer(command, source=source, directory=directory,
|
|
230
|
+
status_path=status_path, preparation_id=preparation_id,
|
|
231
|
+
lease_descriptor=lease_descriptor)
|
|
232
|
+
prepared = {"mode": "prepared", "preparation_id": preparation_id, "envelope": envelope,
|
|
233
|
+
"receipt_sha256": receipt_digest(receipt)}
|
|
234
|
+
verify_prepared_status({**status, "_prepared_update": prepared}, root=root, public_key=public_key)
|
|
235
|
+
update_pending(status_path, preparation_id, preparation_phase="ready", _prepared_update=prepared,
|
|
236
|
+
preparation_runner_pid=None, message="Server update prepared; it will activate when current work finishes.")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def main() -> int:
|
|
240
|
+
parser = argparse.ArgumentParser()
|
|
241
|
+
parser.add_argument("--status-file", type=Path, required=True)
|
|
242
|
+
parser.add_argument("--install-root", type=Path, required=True)
|
|
243
|
+
parser.add_argument("--public-key", type=Path, required=True)
|
|
244
|
+
parser.add_argument("--preparation-id", required=True)
|
|
245
|
+
args = parser.parse_args()
|
|
246
|
+
try:
|
|
247
|
+
run_preparation(status_path=args.status_file, root=args.install_root,
|
|
248
|
+
public_key=args.public_key, preparation_id=args.preparation_id)
|
|
249
|
+
return 0
|
|
250
|
+
except (PreparationSuperseded, BlockingIOError):
|
|
251
|
+
return 0
|
|
252
|
+
except Exception:
|
|
253
|
+
try:
|
|
254
|
+
update_pending(args.status_file, args.preparation_id, phase="failed", preparation_runner_pid=None,
|
|
255
|
+
message="Server update preparation failed; running agents were left untouched.",
|
|
256
|
+
error_code="server_update_preparation_failed", error_action="Inspect the preparation log and retry the update.",
|
|
257
|
+
retryable=True, finished_at=updates.utc_now())
|
|
258
|
+
except PreparationSuperseded:
|
|
259
|
+
pass
|
|
260
|
+
return 1
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
"""Resume one admitted activation; never stage or create another installation.
|
|
2
|
+
|
|
3
|
+
The durable intent is written before install.sh starts. Its directory identities
|
|
4
|
+
bind the outer journal to that exact verified candidate, including after rename
|
|
5
|
+
or reboot. The installer remains the sole service/state rollback authority.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import re
|
|
13
|
+
import sys
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import activation_transaction as activation
|
|
17
|
+
import execution_preparation as preparation
|
|
18
|
+
import update_runner as updates
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def activation_intent(*, root: Path, candidate: Path, version: str, api_contract: int,
|
|
22
|
+
update_id: str, server_identity: str) -> dict[str, Any]:
|
|
23
|
+
root, candidate = preparation._path(root), preparation._path(candidate)
|
|
24
|
+
preparation._candidate(root, candidate)
|
|
25
|
+
if re.fullmatch(r"[0-9a-f]{32}", update_id) is None or not server_identity:
|
|
26
|
+
raise ValueError("activation recovery identity is invalid")
|
|
27
|
+
return {"format": 1, "root": str(root), "root_binding": preparation._binding(root),
|
|
28
|
+
"candidate_binding": preparation._binding(candidate), "version": version,
|
|
29
|
+
"api_contract": api_contract, "update_id": update_id, "server_identity": server_identity}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def recovery_context(root: Path, status: dict[str, Any], *, server_identity: str) -> dict[str, Any] | None:
|
|
33
|
+
"""Inspect without cleaning journal temporaries or taking service ownership."""
|
|
34
|
+
root = preparation._path(root)
|
|
35
|
+
journal = root / ".activation-transaction"
|
|
36
|
+
if not journal.exists() and not journal.is_symlink():
|
|
37
|
+
return None
|
|
38
|
+
intent = status.get("_activation_recovery")
|
|
39
|
+
if (not isinstance(intent, dict) or set(intent) != {
|
|
40
|
+
"format", "root", "root_binding", "candidate_binding", "version", "api_contract", "update_id", "server_identity",
|
|
41
|
+
} or type(intent["format"]) is not int or intent["format"] != 1
|
|
42
|
+
or intent["root"] != str(root) or intent["server_identity"] != server_identity
|
|
43
|
+
or intent["update_id"] != status.get("update_id")
|
|
44
|
+
or re.fullmatch(r"[0-9a-f]{32}", str(intent["update_id"])) is None
|
|
45
|
+
or intent["version"] != status.get("target_version")
|
|
46
|
+
or type(intent["api_contract"]) is not int or intent["api_contract"] < 1):
|
|
47
|
+
raise RuntimeError("activation journal is not bound to this admitted update")
|
|
48
|
+
preparation._check_binding(root, intent["root_binding"])
|
|
49
|
+
value = activation.execution_context(root)
|
|
50
|
+
if (value is None or value["release_version"] != intent["version"]
|
|
51
|
+
or value["execution"]["api_contract"] != intent["api_contract"]):
|
|
52
|
+
raise RuntimeError("activation journal release pins changed")
|
|
53
|
+
saved = intent["candidate_binding"]
|
|
54
|
+
candidate = value["candidate_release"]
|
|
55
|
+
if (not isinstance(saved, dict) or set(saved) != {"device", "inode", "volume_uuid"}
|
|
56
|
+
or any(type(saved.get(name)) is not int or saved[name] < 1 for name in ("device", "inode"))
|
|
57
|
+
or saved["inode"] != candidate["inode"]):
|
|
58
|
+
raise RuntimeError("activation journal candidate identity changed")
|
|
59
|
+
# execution_context already checks persistent volume UUIDs and rebases
|
|
60
|
+
# Darwin device IDs; candidates and the install root share the same volume.
|
|
61
|
+
if sys.platform == "darwin":
|
|
62
|
+
if not saved["volume_uuid"] or saved["volume_uuid"] != intent["root_binding"]["volume_uuid"]:
|
|
63
|
+
raise RuntimeError("activation candidate volume changed")
|
|
64
|
+
elif saved["volume_uuid"] is not None or saved["device"] != candidate["device"]:
|
|
65
|
+
raise RuntimeError("activation candidate filesystem changed")
|
|
66
|
+
handoff = value["execution"]["handoff"]
|
|
67
|
+
if handoff is not None and handoff != status.get("_execution_handoff"):
|
|
68
|
+
raise RuntimeError("activation journal worker handoff changed")
|
|
69
|
+
return value
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def run_recovery(args: argparse.Namespace) -> None:
|
|
73
|
+
"""Run only the retained journal, with the same status owner and heartbeat."""
|
|
74
|
+
status_path = Path(args.status_file).expanduser().resolve()
|
|
75
|
+
root = preparation._path(os.environ["AGENTS_SERVER_INSTALL_DIR"])
|
|
76
|
+
with updates.server_update_status_lock(status_path):
|
|
77
|
+
status = updates._read_status_unlocked(status_path)
|
|
78
|
+
if (status.get("update_id") != args.update_id
|
|
79
|
+
or status.get("phase") not in updates.RUNNER_OWNED_ACTIVE_PHASES):
|
|
80
|
+
raise updates.UpdateOwnershipLostError("activation recovery lost its admitted update")
|
|
81
|
+
context = recovery_context(root, status, server_identity=args.expected_server_identity)
|
|
82
|
+
if (context is None or context["transaction_id"] != args.recovery_transaction
|
|
83
|
+
or context["release_version"] != args.expected_version):
|
|
84
|
+
raise RuntimeError("activation recovery transaction changed before launch")
|
|
85
|
+
version = context["release_version"]
|
|
86
|
+
api = context["execution"]["api_contract"]
|
|
87
|
+
token = updates.consume_auth_token_file(getattr(args, "auth_token_file", None))
|
|
88
|
+
updates.update_status(status_path, expected_update_id=args.update_id, phase="installing",
|
|
89
|
+
runner_pid=os.getpid(), heartbeat_at=updates.utc_now(),
|
|
90
|
+
message="Recovering the interrupted server activation.")
|
|
91
|
+
# Use the running release's recovery implementation. The candidate may have
|
|
92
|
+
# already been retired by a partly completed rollback.
|
|
93
|
+
source = Path(__file__).resolve().parent
|
|
94
|
+
command = [str(source / "install.sh"), "--recover-only", "--non-interactive",
|
|
95
|
+
"--release-version", version, "--expected-api-contract", str(api),
|
|
96
|
+
"--expected-activation-id", context["transaction_id"],
|
|
97
|
+
"--expected-server-identity", args.expected_server_identity,
|
|
98
|
+
"--managed-update-id", args.update_id,
|
|
99
|
+
"--port", str(args.port), "--bind", args.bind]
|
|
100
|
+
try:
|
|
101
|
+
updates.run_installer(command, cwd=source, status_path=status_path,
|
|
102
|
+
log_path=status_path.with_name("server-update.log"), version=version,
|
|
103
|
+
expected_update_id=args.update_id, managed_update_id=args.update_id,
|
|
104
|
+
expected_service_cgroup=getattr(args, "expected_service_cgroup", None),
|
|
105
|
+
accepted_returncodes=(0, 75))
|
|
106
|
+
except updates.InstallerRolledBack:
|
|
107
|
+
if journal_present(root):
|
|
108
|
+
raise RuntimeError("rollback recovery retained an unfinished journal")
|
|
109
|
+
updates.update_status(status_path, expected_update_id=args.update_id,
|
|
110
|
+
phase="failed", runner_pid=None, heartbeat_at=None, retryable=True,
|
|
111
|
+
error_code="server_update_rolled_back",
|
|
112
|
+
error_action="Retry the bundled update when ready.",
|
|
113
|
+
message="The interrupted update was rolled back to the verified previous installation.",
|
|
114
|
+
finished_at=updates.utc_now())
|
|
115
|
+
return
|
|
116
|
+
if journal_present(root):
|
|
117
|
+
raise RuntimeError("activation recovery exited without finalizing its journal")
|
|
118
|
+
identity = dict(token=token, expected_server_identity=args.expected_server_identity,
|
|
119
|
+
expected_server_version=version, expected_api_contract_version=api)
|
|
120
|
+
if status.get("team_hub_id"):
|
|
121
|
+
identity.update(expected_team_hub_id=status["team_hub_id"],
|
|
122
|
+
expected_team_hub_transport=status.get("team_hub_transport"),
|
|
123
|
+
expected_team_hub_url=status.get("team_hub_url"),
|
|
124
|
+
expected_team_hub_direct_ip_url=status.get("team_hub_direct_ip_url"))
|
|
125
|
+
updates.assert_post_update_identity(args.port, **identity)
|
|
126
|
+
if status.get("team_hub_repair_mode") == "failed_start":
|
|
127
|
+
updates.assert_repaired_team_hub_identity(args.port, token=token,
|
|
128
|
+
expected_server_identity=args.expected_server_identity,
|
|
129
|
+
expected_team_hub_transport=status.get("team_hub_transport") or "",
|
|
130
|
+
expected_team_hub_url=status.get("team_hub_url"),
|
|
131
|
+
expected_team_hub_direct_ip_url=status.get("team_hub_direct_ip_url") or "")
|
|
132
|
+
updates.update_status(status_path, expected_update_id=args.update_id, phase="complete",
|
|
133
|
+
installed_version=version, update_available=False, runner_pid=None, heartbeat_at=None,
|
|
134
|
+
elapsed_seconds=None, error_code=None, error_action=None, retryable=None,
|
|
135
|
+
message=f"AgentsServer {version} recovered and is healthy.", finished_at=updates.utc_now())
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def journal_present(root: Path) -> bool:
|
|
139
|
+
return any((root / name).exists() or (root / name).is_symlink()
|
|
140
|
+
for name in (".activation-transaction", ".execution-transaction"))
|