@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,845 @@
|
|
|
1
|
+
"""Durable native owner for one admitted two-service activation.
|
|
2
|
+
|
|
3
|
+
The installer arms this independent job before disabling either application
|
|
4
|
+
service. The job never invents an installation: it invokes only the retained
|
|
5
|
+
installer's recover-only entry point, with the exact journal and release pins.
|
|
6
|
+
All evidence stays private and survives service loss, process loss and reboot.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import fcntl
|
|
12
|
+
import hashlib
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
import plistlib
|
|
17
|
+
import re
|
|
18
|
+
import secrets
|
|
19
|
+
import stat
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
import time
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
import activation_transaction as activation
|
|
26
|
+
import execution_install as files
|
|
27
|
+
from execution_manage import InstallationLock, NativeServices, WorkerControl
|
|
28
|
+
from execution_preparation import _binding, _check_binding
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
DIRECTORY = ".activation-recovery"
|
|
32
|
+
PAYLOAD = ("execution_recovery.py", "activation_transaction.py", "execution_install.py",
|
|
33
|
+
"execution_manage.py", "execution_transport.py", "execution_preparation.py",
|
|
34
|
+
"execution_recovery_status.py", "execution_http.py")
|
|
35
|
+
OWNER_KEYS = {"format", "root", "root_binding", "transaction_id", "version", "api_contract",
|
|
36
|
+
"source_binding", "source_inventory", "home", "platform", "config_root", "state_root",
|
|
37
|
+
"bind", "port", "expected_server_identity", "managed_update_id", "interpreter",
|
|
38
|
+
"interpreter_sha256", "payload", "fresh", "status_binding", "expected_service_cgroup"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _json(path: Path) -> dict[str, Any]:
|
|
42
|
+
data, _mode = files._read_file(path, private=True)
|
|
43
|
+
value = json.loads(data)
|
|
44
|
+
if not isinstance(value, dict):
|
|
45
|
+
raise ValueError("recovery evidence is not an object")
|
|
46
|
+
return value
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _write(path: Path, value: dict) -> None:
|
|
50
|
+
files._atomic_write(path, files._json_bytes(value))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _directory(root: Path, transaction: str) -> Path:
|
|
54
|
+
if re.fullmatch(r"activation-[0-9a-f]{24}", transaction) is None:
|
|
55
|
+
raise ValueError("invalid recovery transaction")
|
|
56
|
+
root = files._path(root)
|
|
57
|
+
files._owned_directory(root, private=True)
|
|
58
|
+
parent = root / DIRECTORY
|
|
59
|
+
files._path(parent)
|
|
60
|
+
files._owned_directory(parent, private=True)
|
|
61
|
+
result = parent / transaction
|
|
62
|
+
files._path(result)
|
|
63
|
+
files._owned_directory(result, private=True)
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _digest(path: Path) -> str:
|
|
68
|
+
data = activation._read_owned_regular(path, maximum=16 * 1024 * 1024)
|
|
69
|
+
return hashlib.sha256(data).hexdigest()
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _interpreter_digest(path: Path) -> str:
|
|
73
|
+
if not path.is_absolute() or path.resolve(strict=True) != path:
|
|
74
|
+
raise ValueError("recovery interpreter must be a resolved executable")
|
|
75
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_NOFOLLOW)
|
|
76
|
+
try:
|
|
77
|
+
info = os.fstat(descriptor)
|
|
78
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_uid not in {0, os.getuid()}
|
|
79
|
+
or info.st_mode & 0o022 or not info.st_mode & 0o111):
|
|
80
|
+
raise PermissionError("recovery interpreter is not a trusted executable")
|
|
81
|
+
digest = hashlib.sha256()
|
|
82
|
+
while block := os.read(descriptor, 1024 * 1024):
|
|
83
|
+
digest.update(block)
|
|
84
|
+
after = os.fstat(descriptor)
|
|
85
|
+
if (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns) != (
|
|
86
|
+
info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns):
|
|
87
|
+
raise RuntimeError("recovery interpreter changed while inspected")
|
|
88
|
+
return digest.hexdigest()
|
|
89
|
+
finally:
|
|
90
|
+
os.close(descriptor)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _source_inventory(source: Path) -> dict[str, str]:
|
|
94
|
+
result = {}
|
|
95
|
+
for parent, directories, names in os.walk(source, followlinks=False):
|
|
96
|
+
directories[:] = [name for name in directories if name not in {".venv", "__pycache__"}]
|
|
97
|
+
for name in directories:
|
|
98
|
+
files._path(Path(parent) / name)
|
|
99
|
+
for name in names:
|
|
100
|
+
path = Path(parent) / name
|
|
101
|
+
if path.suffix in {".py", ".sh", ".sql", ".pem"} or name in {
|
|
102
|
+
"VERSION", "uv.lock", "pyproject.toml", "LICENSE", "NOTICE"}:
|
|
103
|
+
result[str(path.relative_to(source))] = _digest(path)
|
|
104
|
+
if not {"install.sh", "VERSION", *PAYLOAD}.issubset(result):
|
|
105
|
+
raise RuntimeError("retained runtime lacks required recovery sources")
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _conflicts(root: Path) -> None:
|
|
110
|
+
for name in (".execution-transaction", ".execution-uninstall.json"):
|
|
111
|
+
if (root / name).exists() or (root / name).is_symlink():
|
|
112
|
+
raise RuntimeError("another lifecycle operation owns this installation")
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _owner(root: Path, transaction: str) -> tuple[Path, dict]:
|
|
116
|
+
directory = _directory(root, transaction)
|
|
117
|
+
value = _json(directory / "owner.json")
|
|
118
|
+
if (set(value) != OWNER_KEYS or type(value["format"]) is not int or value["format"] != 1
|
|
119
|
+
or value["root"] != str(root) or value["transaction_id"] != transaction
|
|
120
|
+
or value["platform"] not in {"Darwin", "Linux"}
|
|
121
|
+
or type(value["port"]) is not int or not 1 <= value["port"] <= 65535
|
|
122
|
+
or type(value["api_contract"]) is not int or value["api_contract"] < 1
|
|
123
|
+
or type(value["fresh"]) is not bool
|
|
124
|
+
or set(value["payload"]) != set(PAYLOAD)):
|
|
125
|
+
raise ValueError("recovery owner does not match its transaction")
|
|
126
|
+
_check_binding(root, value["root_binding"])
|
|
127
|
+
for key in ("home", "config_root", "state_root"):
|
|
128
|
+
files._path(value[key])
|
|
129
|
+
for name, digest in value["payload"].items():
|
|
130
|
+
if _digest(directory / name) != digest:
|
|
131
|
+
raise RuntimeError("recovery bootstrap source changed")
|
|
132
|
+
if _interpreter_digest(Path(value["interpreter"])) != value["interpreter_sha256"]:
|
|
133
|
+
raise RuntimeError("recovery interpreter changed")
|
|
134
|
+
return directory, value
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _context(value: dict) -> tuple[dict, Path]:
|
|
138
|
+
root = Path(value["root"])
|
|
139
|
+
_conflicts(root)
|
|
140
|
+
context = activation.execution_context(root)
|
|
141
|
+
if (context is None or context["transaction_id"] != value["transaction_id"]
|
|
142
|
+
or context["release_version"] != value["version"]
|
|
143
|
+
or context["execution"]["api_contract"] != value["api_contract"]
|
|
144
|
+
or context["env_path"] != str(Path(value["config_root"]) / "env")
|
|
145
|
+
or context["execution"]["runtime_dir"] != str(Path(value["state_root"]) / "execution")):
|
|
146
|
+
raise RuntimeError("recovery journal pins changed")
|
|
147
|
+
source = activation._locate_release(context["candidate_release"], extras=(
|
|
148
|
+
root / ".activation-transaction/candidate.retired",
|
|
149
|
+
root / "releases" / (".activation-candidate-retired-" + value["transaction_id"])))
|
|
150
|
+
if source is None:
|
|
151
|
+
raise RuntimeError("exact recovery runtime is unavailable")
|
|
152
|
+
_check_binding(source, value["source_binding"])
|
|
153
|
+
if _source_inventory(source) != value["source_inventory"]:
|
|
154
|
+
raise RuntimeError("retained recovery runtime source changed")
|
|
155
|
+
return context, source
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _boot_id() -> str:
|
|
159
|
+
if sys.platform == "linux":
|
|
160
|
+
return Path("/proc/sys/kernel/random/boot_id").read_text().strip()
|
|
161
|
+
if sys.platform == "darwin":
|
|
162
|
+
result = subprocess.run(["/usr/sbin/sysctl", "-n", "kern.boottime"],
|
|
163
|
+
capture_output=True, text=True, check=True, timeout=5)
|
|
164
|
+
match = re.search(r"sec\s*=\s*([0-9]+),\s*usec\s*=\s*([0-9]+)", result.stdout)
|
|
165
|
+
if match:
|
|
166
|
+
return "darwin-" + "-".join(match.groups())
|
|
167
|
+
raise RuntimeError("cannot bind recovery lock to this boot")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _process_start(pid: int) -> str | None:
|
|
171
|
+
result = subprocess.run(["/bin/ps", "-p", str(pid), "-o", "lstart="],
|
|
172
|
+
capture_output=True, text=True, timeout=5, check=False,
|
|
173
|
+
env={**os.environ, "LC_ALL": "C"})
|
|
174
|
+
return result.stdout.strip() or None
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _ancestor(pid: int) -> bool:
|
|
178
|
+
current = os.getpid()
|
|
179
|
+
for _ in range(16):
|
|
180
|
+
if current == pid:
|
|
181
|
+
return True
|
|
182
|
+
if current <= 1:
|
|
183
|
+
return False
|
|
184
|
+
result = subprocess.run(["/bin/ps", "-p", str(current), "-o", "ppid="],
|
|
185
|
+
capture_output=True, text=True, timeout=5, check=True)
|
|
186
|
+
current = int(result.stdout.strip())
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _lock_observation(root: Path, transaction: str) -> dict:
|
|
191
|
+
path = root / ".install-lock"
|
|
192
|
+
files._owned_directory(path, private=True)
|
|
193
|
+
incarnation = _lock_incarnation(path)
|
|
194
|
+
data, _mode = files._read_file(path / "pid", private=True)
|
|
195
|
+
if re.fullmatch(rb"[0-9]{1,20}\n?", data) is None or not _ancestor(int(data)):
|
|
196
|
+
raise RuntimeError("recovery caller does not own the installer lock")
|
|
197
|
+
start = _process_start(int(data))
|
|
198
|
+
if not start:
|
|
199
|
+
raise RuntimeError("installer process incarnation is unavailable")
|
|
200
|
+
binding = _binding(path)
|
|
201
|
+
if _lock_incarnation(path) != incarnation:
|
|
202
|
+
raise RuntimeError("installer lock changed while observing its incarnation")
|
|
203
|
+
return {"format": 1, "transaction_id": transaction,
|
|
204
|
+
"binding": binding, "incarnation": incarnation,
|
|
205
|
+
"pid": int(data), "pid_sha256": hashlib.sha256(data).hexdigest(),
|
|
206
|
+
"boot_id": _boot_id(), "process_start": start}
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _lock_incarnation(path: Path) -> dict:
|
|
210
|
+
"""Distinguish a later lock even when ext4 immediately reuses its inode.
|
|
211
|
+
|
|
212
|
+
Neither pathname nor inode is a creation identity. Directory and PID-file
|
|
213
|
+
change times survive reboot and cannot be restored with utime; the PID-file
|
|
214
|
+
inode also binds replacement of an identical PID inside the same directory.
|
|
215
|
+
Device identity remains covered by the existing volume-aware binding.
|
|
216
|
+
"""
|
|
217
|
+
directory, owner = path.lstat(), (path / "pid").lstat()
|
|
218
|
+
if (not stat.S_ISDIR(directory.st_mode) or directory.st_uid != os.getuid()
|
|
219
|
+
or stat.S_IMODE(directory.st_mode) != 0o700
|
|
220
|
+
or not stat.S_ISREG(owner.st_mode) or owner.st_uid != os.getuid()
|
|
221
|
+
or owner.st_nlink != 1 or stat.S_IMODE(owner.st_mode) != 0o600
|
|
222
|
+
or owner.st_dev != directory.st_dev):
|
|
223
|
+
raise PermissionError("installer lock incarnation is unsafe")
|
|
224
|
+
return {"directory_ctime_ns": directory.st_ctime_ns,
|
|
225
|
+
"pid_inode": owner.st_ino, "pid_ctime_ns": owner.st_ctime_ns}
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def _saved_lock(directory: Path, transaction: str) -> dict:
|
|
229
|
+
saved = _json(directory / "lock.json")
|
|
230
|
+
if (set(saved) != {"format", "transaction_id", "binding", "incarnation", "pid", "pid_sha256", "boot_id", "process_start"}
|
|
231
|
+
or saved["format"] != 1 or saved["transaction_id"] != transaction):
|
|
232
|
+
raise ValueError("recovery lock provenance is invalid")
|
|
233
|
+
incarnation = saved["incarnation"]
|
|
234
|
+
if (not isinstance(incarnation, dict)
|
|
235
|
+
or set(incarnation) != {"directory_ctime_ns", "pid_inode", "pid_ctime_ns"}
|
|
236
|
+
or any(type(value) is not int or value <= 0 for value in incarnation.values())):
|
|
237
|
+
raise ValueError("recovery lock incarnation is invalid")
|
|
238
|
+
return saved
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def observe_lock(root: Path, transaction: str) -> None:
|
|
242
|
+
directory, _value = _owner(root, transaction)
|
|
243
|
+
_write(directory / "lock.json", _lock_observation(root, transaction))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _reap_observed_lock(root: Path, directory: Path, transaction: str) -> bool:
|
|
247
|
+
"""Retire only a recorded old process incarnation, never PID-only guessing."""
|
|
248
|
+
path = root / ".install-lock"
|
|
249
|
+
if not path.exists() and not path.is_symlink():
|
|
250
|
+
return False
|
|
251
|
+
saved = _saved_lock(directory, transaction)
|
|
252
|
+
try:
|
|
253
|
+
_check_binding(path, saved["binding"])
|
|
254
|
+
if _lock_incarnation(path) != saved["incarnation"]:
|
|
255
|
+
return False
|
|
256
|
+
except (RuntimeError, ValueError):
|
|
257
|
+
return False # another installer owns a different lock directory
|
|
258
|
+
data, _mode = files._read_file(path / "pid", private=True)
|
|
259
|
+
if hashlib.sha256(data).hexdigest() != saved["pid_sha256"] or int(data) != saved["pid"]:
|
|
260
|
+
return False
|
|
261
|
+
if _boot_id() == saved["boot_id"] and _process_start(saved["pid"]) == saved["process_start"]:
|
|
262
|
+
return False
|
|
263
|
+
descriptor = os.open(path, os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW)
|
|
264
|
+
try:
|
|
265
|
+
info = os.fstat(descriptor)
|
|
266
|
+
linked = path.lstat()
|
|
267
|
+
if ((info.st_dev, info.st_ino, info.st_ctime_ns) != (linked.st_dev, linked.st_ino, linked.st_ctime_ns)
|
|
268
|
+
or info.st_ctime_ns != saved["incarnation"]["directory_ctime_ns"]):
|
|
269
|
+
raise RuntimeError("recovery lock changed during inspection")
|
|
270
|
+
if set(os.listdir(descriptor)) != {"pid"}:
|
|
271
|
+
raise RuntimeError("recovery lock contains unknown evidence")
|
|
272
|
+
_check_binding(path, saved["binding"])
|
|
273
|
+
if _lock_incarnation(path) != saved["incarnation"]:
|
|
274
|
+
raise RuntimeError("recovery lock incarnation changed during inspection")
|
|
275
|
+
latest, _mode = files._read_file(path / "pid", private=True)
|
|
276
|
+
if latest != data:
|
|
277
|
+
raise RuntimeError("recovery lock owner changed")
|
|
278
|
+
owner = os.stat("pid", dir_fd=descriptor, follow_symlinks=False)
|
|
279
|
+
if (owner.st_ino, owner.st_ctime_ns) != (saved["incarnation"]["pid_inode"], saved["incarnation"]["pid_ctime_ns"]):
|
|
280
|
+
raise RuntimeError("recovery lock owner incarnation changed")
|
|
281
|
+
os.unlink("pid", dir_fd=descriptor)
|
|
282
|
+
os.fsync(descriptor)
|
|
283
|
+
path.rmdir()
|
|
284
|
+
files._fsync_directory(root)
|
|
285
|
+
finally:
|
|
286
|
+
os.close(descriptor)
|
|
287
|
+
return True
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _label(value: dict) -> str:
|
|
291
|
+
suffix = value["transaction_id"].removeprefix("activation-")
|
|
292
|
+
return ("com.agentsdock.recovery." if value["platform"] == "Darwin" else "agents-server-recovery-") + suffix
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _service_path(value: dict) -> Path:
|
|
296
|
+
home = Path(value["home"])
|
|
297
|
+
if value["platform"] == "Darwin":
|
|
298
|
+
return home / "Library/LaunchAgents" / (_label(value) + ".plist")
|
|
299
|
+
return home / ".config/systemd/user" / (_label(value) + ".service")
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def render_service(directory: Path, value: dict) -> bytes:
|
|
303
|
+
command = [value["interpreter"], "-B", str(directory / "execution_recovery.py"), "run",
|
|
304
|
+
"--root", value["root"], "--transaction-id", value["transaction_id"]]
|
|
305
|
+
environment = {"PATH": f"{value['home']}/.local/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin"}
|
|
306
|
+
if value["platform"] == "Darwin":
|
|
307
|
+
return plistlib.dumps({"Label": _label(value), "ProgramArguments": command,
|
|
308
|
+
"WorkingDirectory": str(directory), "EnvironmentVariables": environment,
|
|
309
|
+
"RunAtLoad": True, "KeepAlive": {"SuccessfulExit": False}, "ThrottleInterval": 30,
|
|
310
|
+
"Umask": 0o077, "ExitTimeOut": 0,
|
|
311
|
+
"StandardOutPath": str(directory / "native.stdout.log"),
|
|
312
|
+
"StandardErrorPath": str(directory / "native.stderr.log")}, sort_keys=True)
|
|
313
|
+
return ("[Unit]\nDescription=AgentsDock admitted activation recovery\nAfter=network-online.target\n"
|
|
314
|
+
"[Service]\nType=simple\nUMask=0077\nRestart=on-failure\nRestartSec=30s\n"
|
|
315
|
+
"TimeoutStopSec=infinity\nSendSIGKILL=no\n"
|
|
316
|
+
f"WorkingDirectory={files._unit_directory(directory)}\n"
|
|
317
|
+
f"Environment={files._unit_word('PATH=' + environment['PATH'])}\n"
|
|
318
|
+
"ExecStart=" + " ".join(files._unit_word(item, command=True) for item in command) +
|
|
319
|
+
"\n[Install]\nWantedBy=default.target\n").encode()
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class RecoveryService:
|
|
323
|
+
def __init__(self, value: dict):
|
|
324
|
+
self.value = value
|
|
325
|
+
|
|
326
|
+
def _command(self, arguments: list[str], *, allow_failure=False):
|
|
327
|
+
result = subprocess.run(arguments, stdin=subprocess.DEVNULL, capture_output=True,
|
|
328
|
+
timeout=20, check=False)
|
|
329
|
+
if result.returncode and not allow_failure:
|
|
330
|
+
raise RuntimeError("native recovery service operation failed")
|
|
331
|
+
return result
|
|
332
|
+
|
|
333
|
+
def start(self, path: Path) -> None:
|
|
334
|
+
if self.value["platform"] == "Linux":
|
|
335
|
+
self._command(["systemctl", "--user", "daemon-reload"])
|
|
336
|
+
self._command(["systemctl", "--user", "enable", "--now", path.name])
|
|
337
|
+
# systemctl owns link creation, but our pre-quiesce boundary also
|
|
338
|
+
# requires its exact registration to survive filesystem recovery.
|
|
339
|
+
wants = files._path(path.parent / "default.target.wants")
|
|
340
|
+
files._owned_directory(wants)
|
|
341
|
+
link = wants / path.name
|
|
342
|
+
info = link.lstat()
|
|
343
|
+
if (not stat.S_ISLNK(info.st_mode) or info.st_uid != os.getuid()
|
|
344
|
+
or link.resolve(strict=True) != path):
|
|
345
|
+
raise RuntimeError("native recovery enablement does not target its owned unit")
|
|
346
|
+
files._fsync_directory(wants)
|
|
347
|
+
files._fsync_directory(path.parent)
|
|
348
|
+
else:
|
|
349
|
+
target = f"gui/{os.getuid()}/{_label(self.value)}"
|
|
350
|
+
self._command(["/bin/launchctl", "enable", target])
|
|
351
|
+
loaded = self._command(["/bin/launchctl", "print", target], allow_failure=True)
|
|
352
|
+
if loaded.returncode:
|
|
353
|
+
self._command(["/bin/launchctl", "bootstrap", f"gui/{os.getuid()}", str(path)])
|
|
354
|
+
elif re.search(rb"(?m)^\s*pid\s*=\s*[1-9][0-9]*\s*$", loaded.stdout) is None:
|
|
355
|
+
# A previously loaded but stopped job needs an explicit start.
|
|
356
|
+
# No -k: a concurrently restarted owner must never be killed.
|
|
357
|
+
self._command(["/bin/launchctl", "kickstart", target])
|
|
358
|
+
|
|
359
|
+
def assert_absent(self, path: Path) -> None:
|
|
360
|
+
if self.value["platform"] == "Linux":
|
|
361
|
+
result = self._command(["systemctl", "--user", "show", path.name,
|
|
362
|
+
"--property=LoadState,ActiveState,MainPID"], allow_failure=True)
|
|
363
|
+
values = dict(line.split("=", 1) for line in result.stdout.decode().splitlines() if "=" in line)
|
|
364
|
+
if values != {"LoadState": "not-found", "ActiveState": "inactive", "MainPID": "0"}:
|
|
365
|
+
raise RuntimeError("a native recovery job is loaded without its owned registration")
|
|
366
|
+
else:
|
|
367
|
+
result = self._command(["/bin/launchctl", "print", f"gui/{os.getuid()}/{_label(self.value)}"], allow_failure=True)
|
|
368
|
+
if not result.returncode or b"Could not find service" not in result.stderr:
|
|
369
|
+
raise RuntimeError("a native recovery job is loaded without its owned registration")
|
|
370
|
+
|
|
371
|
+
def disable(self, path: Path) -> None:
|
|
372
|
+
if self.value["platform"] == "Linux":
|
|
373
|
+
self._command(["systemctl", "--user", "disable", path.name])
|
|
374
|
+
else:
|
|
375
|
+
self._command(["/bin/launchctl", "disable", f"gui/{os.getuid()}/{_label(self.value)}"])
|
|
376
|
+
|
|
377
|
+
def reload(self) -> None:
|
|
378
|
+
if self.value["platform"] == "Linux":
|
|
379
|
+
self._command(["systemctl", "--user", "daemon-reload"])
|
|
380
|
+
|
|
381
|
+
def running(self, path: Path) -> bool:
|
|
382
|
+
if self.value["platform"] == "Linux":
|
|
383
|
+
result = self._command(["systemctl", "--user", "show", path.name,
|
|
384
|
+
"--property=ActiveState,MainPID"], allow_failure=True)
|
|
385
|
+
values = dict(line.split("=", 1) for line in result.stdout.decode().splitlines() if "=" in line)
|
|
386
|
+
if set(values) != {"ActiveState", "MainPID"} or not values["MainPID"].isdigit():
|
|
387
|
+
raise RuntimeError("cannot inspect native recovery process ownership")
|
|
388
|
+
return values["MainPID"] != "0" or values["ActiveState"] not in {"inactive", "failed"}
|
|
389
|
+
result = self._command(["/bin/launchctl", "print", f"gui/{os.getuid()}/{_label(self.value)}"], allow_failure=True)
|
|
390
|
+
if result.returncode:
|
|
391
|
+
if b"Could not find service" not in result.stderr:
|
|
392
|
+
raise RuntimeError("cannot inspect native recovery process ownership")
|
|
393
|
+
return False
|
|
394
|
+
return re.search(rb"(?m)^\s*pid\s*=\s*[1-9][0-9]*\s*$", result.stdout) is not None
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _register(directory: Path, value: dict, native=None) -> None:
|
|
398
|
+
path = files._path(_service_path(value))
|
|
399
|
+
files._ensure_directory(path.parent)
|
|
400
|
+
service = native or RecoveryService(value)
|
|
401
|
+
if path.exists() or path.is_symlink():
|
|
402
|
+
if files._read_file(path)[0] != render_service(directory, value):
|
|
403
|
+
raise RuntimeError("recovery native registration changed")
|
|
404
|
+
else:
|
|
405
|
+
service.assert_absent(path)
|
|
406
|
+
files._atomic_write(path, render_service(directory, value))
|
|
407
|
+
service.start(path)
|
|
408
|
+
_registration_barrier(directory, value)
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def _registration_barrier(directory: Path, value: dict) -> None:
|
|
412
|
+
"""Persist arm's later writes before either application job is quiesced."""
|
|
413
|
+
path = _service_path(value)
|
|
414
|
+
files._fsync_directory(directory)
|
|
415
|
+
files._fsync_directory(path.parent)
|
|
416
|
+
if value["platform"] != "Darwin":
|
|
417
|
+
return
|
|
418
|
+
# The runtime barrier precedes arm. Owner/bootstrap and plist may be on
|
|
419
|
+
# different volumes, so each needs its own post-registration flush anchor.
|
|
420
|
+
for anchor in (directory / "owner.json", path):
|
|
421
|
+
data, mode = files._read_file(anchor, private=True)
|
|
422
|
+
descriptor = os.open(anchor, os.O_RDONLY | os.O_NOFOLLOW)
|
|
423
|
+
try:
|
|
424
|
+
before, linked = os.fstat(descriptor), anchor.lstat()
|
|
425
|
+
if ((before.st_dev, before.st_ino) != (linked.st_dev, linked.st_ino)
|
|
426
|
+
or before.st_uid != os.getuid() or not stat.S_ISREG(before.st_mode)
|
|
427
|
+
or before.st_nlink != 1 or stat.S_IMODE(before.st_mode) != mode):
|
|
428
|
+
raise RuntimeError("native recovery flush anchor changed")
|
|
429
|
+
os.fsync(descriptor)
|
|
430
|
+
fcntl.fcntl(descriptor, getattr(fcntl, "F_FULLFSYNC", 51))
|
|
431
|
+
finally:
|
|
432
|
+
os.close(descriptor)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def arm(root: Path, source: Path, *, home: Path, platform: str, bind: str, port: int,
|
|
436
|
+
expected_server_identity: str, managed_update_id: str = "", expected_service_cgroup: str = "", native=None) -> dict:
|
|
437
|
+
root, source, home = map(files._path, (root, source, home))
|
|
438
|
+
files._owned_directory(root, private=True)
|
|
439
|
+
_conflicts(root)
|
|
440
|
+
context = activation.execution_context(root)
|
|
441
|
+
if context is None or context["execution"]["api_contract"] < 1:
|
|
442
|
+
raise RuntimeError("only an admitted, API-pinned split transaction can arm recovery")
|
|
443
|
+
transaction = context["transaction_id"]
|
|
444
|
+
fresh = (not context["old_release"] and context["current"]["kind"] == "missing"
|
|
445
|
+
and context["service_state"] == "absent" and context["execution"]["gateway_state"] == "absent")
|
|
446
|
+
if not expected_server_identity and not fresh:
|
|
447
|
+
raise RuntimeError("existing installation recovery requires its authenticated identity")
|
|
448
|
+
if expected_server_identity and re.fullmatch(r"[A-Za-z0-9_-]{1,128}", expected_server_identity) is None:
|
|
449
|
+
raise ValueError("recovery server identity is invalid")
|
|
450
|
+
if managed_update_id and re.fullmatch(r"[0-9a-f]{32}", managed_update_id) is None:
|
|
451
|
+
raise ValueError("recovery managed operation is invalid")
|
|
452
|
+
if expected_service_cgroup and re.fullmatch(r"/([A-Za-z0-9_.@:-]+/)*[A-Za-z0-9_.@:-]+", expected_service_cgroup) is None:
|
|
453
|
+
raise ValueError("recovery service cgroup is invalid")
|
|
454
|
+
lock = _lock_observation(root, transaction)
|
|
455
|
+
files._ensure_directory(root / DIRECTORY, private=True)
|
|
456
|
+
directory = root / DIRECTORY / transaction
|
|
457
|
+
if directory.exists() or directory.is_symlink():
|
|
458
|
+
existing_directory, existing = _owner(root, transaction)
|
|
459
|
+
_context(existing)
|
|
460
|
+
observe_lock(root, transaction)
|
|
461
|
+
_register(existing_directory, existing, native)
|
|
462
|
+
return existing
|
|
463
|
+
interpreter = Path(sys.executable).resolve(strict=True)
|
|
464
|
+
value = {"format": 1, "root": str(root), "root_binding": _binding(root),
|
|
465
|
+
"transaction_id": transaction, "version": context["release_version"],
|
|
466
|
+
"api_contract": context["execution"]["api_contract"], "source_binding": _binding(source),
|
|
467
|
+
"source_inventory": _source_inventory(source), "home": str(home), "platform": platform,
|
|
468
|
+
"config_root": str(Path(context["env_path"]).parent),
|
|
469
|
+
"state_root": str(Path(context["execution"]["runtime_dir"]).parent), "bind": bind, "port": port,
|
|
470
|
+
"expected_server_identity": expected_server_identity, "managed_update_id": managed_update_id,
|
|
471
|
+
"interpreter": str(interpreter), "interpreter_sha256": _interpreter_digest(interpreter),
|
|
472
|
+
"payload": {name: _digest(source / name) for name in PAYLOAD}, "fresh": fresh,
|
|
473
|
+
"expected_service_cgroup": expected_service_cgroup}
|
|
474
|
+
from execution_recovery_status import capture_status_binding
|
|
475
|
+
value["status_binding"] = capture_status_binding(root, context, supplied_update_id=managed_update_id,
|
|
476
|
+
expected_server_identity=expected_server_identity)
|
|
477
|
+
if value["status_binding"] is not None:
|
|
478
|
+
value["managed_update_id"] = value["status_binding"]["update_id"]
|
|
479
|
+
_context(value)
|
|
480
|
+
layout = _layout(value)
|
|
481
|
+
if str(layout.service_path("worker")) != context["service_path"]:
|
|
482
|
+
raise ValueError("recovery native service directory belongs to another home")
|
|
483
|
+
path = files._path(_service_path(value))
|
|
484
|
+
files._ensure_directory(path.parent)
|
|
485
|
+
if path.exists() or path.is_symlink():
|
|
486
|
+
raise RuntimeError("another native job already uses the recovery registration")
|
|
487
|
+
staging = directory.with_name("." + transaction + "." + secrets.token_hex(12) + ".tmp")
|
|
488
|
+
staging.mkdir(mode=0o700)
|
|
489
|
+
try:
|
|
490
|
+
for name in PAYLOAD:
|
|
491
|
+
data, _mode = files._read_file(source / name)
|
|
492
|
+
if hashlib.sha256(data).hexdigest() != value["payload"][name]:
|
|
493
|
+
raise RuntimeError("bootstrap source changed during arm")
|
|
494
|
+
files._atomic_write(staging / name, data)
|
|
495
|
+
_write(staging / "owner.json", value)
|
|
496
|
+
_write(staging / "lock.json", lock)
|
|
497
|
+
staging.rename(directory)
|
|
498
|
+
files._fsync_directory(directory.parent)
|
|
499
|
+
finally:
|
|
500
|
+
if staging.exists():
|
|
501
|
+
for child in staging.iterdir():
|
|
502
|
+
child.unlink()
|
|
503
|
+
staging.rmdir()
|
|
504
|
+
_owner(root, transaction)
|
|
505
|
+
_register(directory, value, native)
|
|
506
|
+
return value
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _layout(value: dict) -> files.ExecutionLayout:
|
|
510
|
+
runtime = Path(value["root"]) / "releases" / value["version"]
|
|
511
|
+
return files.ExecutionLayout(Path(value["root"]), Path(value["config_root"]), Path(value["state_root"]),
|
|
512
|
+
Path(value["home"]), value["platform"], runtime, runtime, value["bind"], value["port"])
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def _file_proof(path: Path) -> dict:
|
|
516
|
+
try:
|
|
517
|
+
data, mode = files._read_file(path)
|
|
518
|
+
except FileNotFoundError:
|
|
519
|
+
return {"exists": False}
|
|
520
|
+
return {"exists": True, "mode": mode, "sha256": hashlib.sha256(data).hexdigest()}
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _terminal_snapshot(value: dict, *, services=None, control=None) -> dict:
|
|
524
|
+
layout = _layout(value)
|
|
525
|
+
native = (services or NativeServices(layout)).snapshot()
|
|
526
|
+
configurations = {name: _file_proof(path) for name, path in {
|
|
527
|
+
"env": layout.config_root / "env", "worker": layout.service_path("worker"),
|
|
528
|
+
"gateway": layout.service_path("gateway"), "layout": layout.manifest_path}.items()}
|
|
529
|
+
current = layout.current
|
|
530
|
+
if current.is_symlink():
|
|
531
|
+
link = {"kind": "symlink", "target": os.readlink(current)}
|
|
532
|
+
elif current.exists():
|
|
533
|
+
link = {"kind": "directory", "binding": _binding(current)}
|
|
534
|
+
else:
|
|
535
|
+
link = None
|
|
536
|
+
if all(item["state"] == "absent" for item in native.values()):
|
|
537
|
+
if not value["fresh"] or link is not None or any(item["exists"] for item in configurations.values()):
|
|
538
|
+
raise RuntimeError("absent recovery target is not the original fresh installation")
|
|
539
|
+
children = layout.state_root / "admin/provider-children.json"
|
|
540
|
+
if children.exists() and _json(children).get("children") != []:
|
|
541
|
+
raise RuntimeError("fresh rollback retains unresolved provider ownership")
|
|
542
|
+
state_lock = layout.state_root / "admin/state-owner.lock"
|
|
543
|
+
if state_lock.exists() or state_lock.is_symlink():
|
|
544
|
+
files._read_file(state_lock, private=True)
|
|
545
|
+
descriptor = os.open(state_lock, os.O_RDWR | os.O_NOFOLLOW)
|
|
546
|
+
try:
|
|
547
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
548
|
+
finally:
|
|
549
|
+
os.close(descriptor)
|
|
550
|
+
health = None
|
|
551
|
+
else:
|
|
552
|
+
if native["worker"]["state"] != "running":
|
|
553
|
+
raise RuntimeError("recovered native worker is not healthy")
|
|
554
|
+
control = control or WorkerControl()
|
|
555
|
+
raw = control.health(layout)
|
|
556
|
+
identity = raw.get("server_identity")
|
|
557
|
+
if (raw.get("ok") is not True or not isinstance(identity, str) or not identity
|
|
558
|
+
or (value["expected_server_identity"] and identity != value["expected_server_identity"])):
|
|
559
|
+
raise RuntimeError("recovered server identity is not authenticated")
|
|
560
|
+
identity_file, _mode = files._read_file(layout.state_root / "server-identity")
|
|
561
|
+
if identity_file.decode().strip() != identity:
|
|
562
|
+
raise RuntimeError("recovered server does not own the persistent identity")
|
|
563
|
+
worker, gateway = raw.get("execution_service"), raw.get("gateway")
|
|
564
|
+
if configurations["layout"]["exists"]:
|
|
565
|
+
if (not isinstance(worker, dict) or not isinstance(gateway, dict)
|
|
566
|
+
or worker.get("pid") != native["worker"].get("pid")
|
|
567
|
+
or gateway.get("pid") != native["gateway"].get("pid")
|
|
568
|
+
or worker.get("maintenance_held") is not False):
|
|
569
|
+
raise RuntimeError("recovered worker and gateway are not both active and released")
|
|
570
|
+
health = {"server_identity": identity, "server_version": raw.get("server_version"),
|
|
571
|
+
"api_contract_version": raw.get("api_contract_version"),
|
|
572
|
+
"worker_version": worker.get("version") if isinstance(worker, dict) else None,
|
|
573
|
+
"gateway_version": gateway.get("version") if isinstance(gateway, dict) else None}
|
|
574
|
+
return {"configurations": configurations, "current": link, "health": health,
|
|
575
|
+
"services": {role: {"state": item["state"], "enabled": item["enabled"]}
|
|
576
|
+
for role, item in native.items()}}
|
|
577
|
+
|
|
578
|
+
|
|
579
|
+
def complete(root: Path, transaction: str, *, services=None, control=None) -> None:
|
|
580
|
+
directory, value = _owner(root, transaction)
|
|
581
|
+
observe_lock(root, transaction)
|
|
582
|
+
context, _source = _context(value)
|
|
583
|
+
if context["phase"] not in {"committed", "rollback-healthy"}:
|
|
584
|
+
raise RuntimeError("activation has not crossed a verified terminal boundary")
|
|
585
|
+
snapshot = _terminal_snapshot(value, services=services, control=control)
|
|
586
|
+
if context["phase"] == "committed":
|
|
587
|
+
health = snapshot["health"]
|
|
588
|
+
if (not health or health["server_version"] != value["version"]
|
|
589
|
+
or health["api_contract_version"] != value["api_contract"]
|
|
590
|
+
or health["worker_version"] != value["version"] or health["gateway_version"] != value["version"]):
|
|
591
|
+
raise RuntimeError("committed recovery target does not match the paired release")
|
|
592
|
+
latest, _source = _context(value)
|
|
593
|
+
if latest != context:
|
|
594
|
+
raise RuntimeError("activation changed while recording its terminal proof")
|
|
595
|
+
proof = {"format": 1, "transaction_id": transaction, "phase": context["phase"], "snapshot": snapshot}
|
|
596
|
+
target = directory / "terminal.json"
|
|
597
|
+
if target.exists() and _json(target) != proof:
|
|
598
|
+
raise RuntimeError("a different terminal recovery proof already exists")
|
|
599
|
+
_write(target, proof)
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def finalized(root: Path, transaction: str, *, services=None, control=None) -> None:
|
|
603
|
+
directory, value = _owner(root, transaction)
|
|
604
|
+
observe_lock(root, transaction)
|
|
605
|
+
_conflicts(root)
|
|
606
|
+
if (root / ".activation-transaction").exists() or (root / ".activation-transaction").is_symlink():
|
|
607
|
+
raise RuntimeError("activation journal has not been retired")
|
|
608
|
+
proof = _json(directory / "terminal.json")
|
|
609
|
+
if (set(proof) != {"format", "transaction_id", "phase", "snapshot"} or proof["format"] != 1
|
|
610
|
+
or proof["transaction_id"] != transaction or proof["phase"] not in {"committed", "rollback-healthy"}
|
|
611
|
+
or proof["snapshot"] != _terminal_snapshot(value, services=services, control=control)):
|
|
612
|
+
raise RuntimeError("terminal recovery proof no longer matches the installed services")
|
|
613
|
+
_write(directory / "finalized.json", {"format": 1, "transaction_id": transaction,
|
|
614
|
+
"terminal_sha256": _digest(directory / "terminal.json")})
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _retire(directory: Path, value: dict, native=None) -> bool:
|
|
618
|
+
proof = _json(directory / "finalized.json")
|
|
619
|
+
if proof != {"format": 1, "transaction_id": value["transaction_id"],
|
|
620
|
+
"terminal_sha256": _digest(directory / "terminal.json")}:
|
|
621
|
+
raise RuntimeError("recovery finalization proof is invalid")
|
|
622
|
+
from execution_recovery_status import settle_status
|
|
623
|
+
if not settle_status(value, _json(directory / "terminal.json")):
|
|
624
|
+
return False # An exact live updater still owns completion reporting.
|
|
625
|
+
path = _service_path(value)
|
|
626
|
+
service = native or RecoveryService(value)
|
|
627
|
+
# Record authority before removing registration: a crash after unlink must
|
|
628
|
+
# not leave an unidentifiable job that blocks uninstall forever. Readers
|
|
629
|
+
# also require the registration absent and native process stopped.
|
|
630
|
+
_write(directory / "retired.json", proof)
|
|
631
|
+
if path.exists() or path.is_symlink():
|
|
632
|
+
if files._read_file(path)[0] != render_service(directory, value):
|
|
633
|
+
raise RuntimeError("refusing to remove a changed recovery native job")
|
|
634
|
+
service.disable(path)
|
|
635
|
+
path.unlink()
|
|
636
|
+
files._fsync_directory(path.parent)
|
|
637
|
+
service.reload()
|
|
638
|
+
return True
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def inspect_owner(root: Path, transaction: str) -> dict | None:
|
|
642
|
+
"""Read-only join authority; old journals without an owner return None."""
|
|
643
|
+
root = files._path(root)
|
|
644
|
+
directory = root / DIRECTORY / transaction
|
|
645
|
+
if not directory.exists() and not directory.is_symlink():
|
|
646
|
+
return None
|
|
647
|
+
directory, value = _owner(root, transaction)
|
|
648
|
+
terminal = (directory / "finalized.json").exists()
|
|
649
|
+
retired = (directory / "retired.json").exists()
|
|
650
|
+
if terminal:
|
|
651
|
+
final = _json(directory / "finalized.json")
|
|
652
|
+
expected = {"format": 1, "transaction_id": transaction,
|
|
653
|
+
"terminal_sha256": _digest(directory / "terminal.json")}
|
|
654
|
+
if final != expected or (retired and _json(directory / "retired.json") != expected):
|
|
655
|
+
raise RuntimeError("native recovery retirement evidence changed")
|
|
656
|
+
elif retired:
|
|
657
|
+
raise RuntimeError("native recovery retirement lacks finalization proof")
|
|
658
|
+
journal = root / ".activation-transaction"
|
|
659
|
+
if journal.exists() or journal.is_symlink():
|
|
660
|
+
context = activation.execution_context(root)
|
|
661
|
+
if context and context["transaction_id"] == transaction:
|
|
662
|
+
if terminal:
|
|
663
|
+
raise RuntimeError("finalized recovery owner conflicts with its retained journal")
|
|
664
|
+
_context(value)
|
|
665
|
+
if not retired and files._read_file(_service_path(value))[0] != render_service(directory, value):
|
|
666
|
+
raise RuntimeError("recovery native registration changed")
|
|
667
|
+
result = {"transaction_id": transaction, "finalized": terminal, "retired": retired,
|
|
668
|
+
"service_path": str(_service_path(value))}
|
|
669
|
+
failure = directory / "failure.json"
|
|
670
|
+
if failure.exists():
|
|
671
|
+
result["failure"] = _json(failure)
|
|
672
|
+
return result
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
def _observed_installer_is_live(root: Path, directory: Path, transaction: str) -> bool:
|
|
676
|
+
"""A busy API retry may join only the exact installer recorded at arm."""
|
|
677
|
+
path = root / ".install-lock"
|
|
678
|
+
saved = _saved_lock(directory, transaction)
|
|
679
|
+
try:
|
|
680
|
+
_check_binding(path, saved["binding"])
|
|
681
|
+
if _lock_incarnation(path) != saved["incarnation"]:
|
|
682
|
+
return False
|
|
683
|
+
data, _mode = files._read_file(path / "pid", private=True)
|
|
684
|
+
except (FileNotFoundError, RuntimeError, ValueError):
|
|
685
|
+
return False
|
|
686
|
+
return (hashlib.sha256(data).hexdigest() == saved["pid_sha256"] and int(data) == saved["pid"]
|
|
687
|
+
and _boot_id() == saved["boot_id"] and bool(saved["process_start"])
|
|
688
|
+
and _process_start(saved["pid"]) == saved["process_start"])
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def resume_owner(root: Path, transaction: str, *, native=None) -> dict | None:
|
|
692
|
+
"""Join or finish registering one existing owner; never invent a recovery.
|
|
693
|
+
|
|
694
|
+
Invoke off the server event loop. Registration changes use the same install
|
|
695
|
+
lock as activation; a verified live installer is joined without waiting.
|
|
696
|
+
"""
|
|
697
|
+
root = files._path(root)
|
|
698
|
+
directory = root / DIRECTORY / transaction
|
|
699
|
+
if not directory.exists() and not directory.is_symlink():
|
|
700
|
+
return None
|
|
701
|
+
directory, value = _owner(root, transaction)
|
|
702
|
+
_context(value)
|
|
703
|
+
if (directory / "finalized.json").exists() or (directory / "retired.json").exists():
|
|
704
|
+
raise RuntimeError("terminal recovery owner cannot resume a retained journal")
|
|
705
|
+
path = _service_path(value)
|
|
706
|
+
if path.exists() or path.is_symlink():
|
|
707
|
+
if files._read_file(path)[0] != render_service(directory, value):
|
|
708
|
+
raise RuntimeError("recovery native registration changed")
|
|
709
|
+
_reap_observed_lock(root, directory, transaction)
|
|
710
|
+
lock = InstallationLock(root)
|
|
711
|
+
try:
|
|
712
|
+
lock.__enter__()
|
|
713
|
+
except RuntimeError as error:
|
|
714
|
+
if (str(error) == "another installer owns the installation lock"
|
|
715
|
+
and _observed_installer_is_live(root, directory, transaction)):
|
|
716
|
+
# arm may not have written its registration yet. The exact live
|
|
717
|
+
# installer still owns that boundary; do not claim a job is active.
|
|
718
|
+
return {"transaction_id": transaction, "finalized": False, "retired": False,
|
|
719
|
+
"service_path": str(path), "joining": True}
|
|
720
|
+
raise
|
|
721
|
+
try:
|
|
722
|
+
_context(value)
|
|
723
|
+
_register(directory, value, native)
|
|
724
|
+
return {**inspect_owner(root, transaction), "joining": True}
|
|
725
|
+
finally:
|
|
726
|
+
lock.__exit__(None, None, None)
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def require_retired_owners(root: Path) -> None:
|
|
730
|
+
"""Called under the install lock before removing any runtime/bootstrap."""
|
|
731
|
+
parent = root / DIRECTORY
|
|
732
|
+
if not parent.exists() and not parent.is_symlink():
|
|
733
|
+
return
|
|
734
|
+
files._path(parent)
|
|
735
|
+
files._owned_directory(parent, private=True)
|
|
736
|
+
for directory in parent.iterdir():
|
|
737
|
+
result = inspect_owner(root, directory.name)
|
|
738
|
+
if result is None or result["retired"] is not True:
|
|
739
|
+
raise RuntimeError("a durable activation recovery owner has not retired")
|
|
740
|
+
_directory_path, value = _owner(root, directory.name)
|
|
741
|
+
path = _service_path(value)
|
|
742
|
+
if path.exists() or path.is_symlink() or RecoveryService(value).running(path):
|
|
743
|
+
raise RuntimeError("a durable activation recovery job is still registered or running")
|
|
744
|
+
|
|
745
|
+
|
|
746
|
+
def run_once(root: Path, transaction: str, *, native=None, installer=subprocess.run,
|
|
747
|
+
services=None, control=None) -> bool:
|
|
748
|
+
directory, value = _owner(root, transaction)
|
|
749
|
+
if (directory / "finalized.json").exists():
|
|
750
|
+
_reap_observed_lock(root, directory, transaction)
|
|
751
|
+
with InstallationLock(root):
|
|
752
|
+
return _retire(directory, value, native)
|
|
753
|
+
_reap_observed_lock(root, directory, transaction)
|
|
754
|
+
with InstallationLock(root):
|
|
755
|
+
_conflicts(root)
|
|
756
|
+
if not (root / ".activation-transaction").exists():
|
|
757
|
+
finalized(root, transaction, services=services, control=control)
|
|
758
|
+
return _retire(directory, value, native)
|
|
759
|
+
context, source = _context(value)
|
|
760
|
+
# The child owns the same shared lock and checks this exact transaction
|
|
761
|
+
# again. Never hold it in the parent while launching an installer.
|
|
762
|
+
command = ["/bin/bash", str(source / "install.sh"), "--recover-only", "--non-interactive",
|
|
763
|
+
"--release-version", value["version"], "--expected-api-contract", str(value["api_contract"]),
|
|
764
|
+
"--expected-activation-id", transaction, "--expected-server-identity", value["expected_server_identity"],
|
|
765
|
+
"--port", str(value["port"]), "--bind", value["bind"]]
|
|
766
|
+
if value["managed_update_id"]:
|
|
767
|
+
command += ["--managed-update-id", value["managed_update_id"]]
|
|
768
|
+
if value["expected_service_cgroup"]:
|
|
769
|
+
command += ["--expected-service-cgroup", value["expected_service_cgroup"]]
|
|
770
|
+
environment = {name: content for name, content in os.environ.items() if name in {
|
|
771
|
+
"HOME", "USER", "LOGNAME", "PATH", "LANG", "LC_ALL", "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", "TMPDIR"}}
|
|
772
|
+
environment.update(AGENTS_SERVER_INSTALL_DIR=value["root"], AGENTS_SERVER_CONFIG_DIR=value["config_root"],
|
|
773
|
+
AGENTSDOCK_STATE_DIR=value["state_root"])
|
|
774
|
+
descriptor = os.open(directory / "installer.log", os.O_WRONLY | os.O_APPEND | os.O_CREAT | os.O_NOFOLLOW, 0o600)
|
|
775
|
+
info = os.fstat(descriptor)
|
|
776
|
+
if (not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid()
|
|
777
|
+
or stat.S_IMODE(info.st_mode) != 0o600 or info.st_nlink != 1):
|
|
778
|
+
os.close(descriptor)
|
|
779
|
+
raise PermissionError("recovery installer log is unsafe")
|
|
780
|
+
with os.fdopen(descriptor, "ab") as output:
|
|
781
|
+
result = installer(command, cwd=source, env=environment, stdin=subprocess.DEVNULL,
|
|
782
|
+
stdout=output, stderr=subprocess.STDOUT, check=False)
|
|
783
|
+
if result.returncode not in {0, 75}:
|
|
784
|
+
raise RuntimeError("retained installer recovery did not complete")
|
|
785
|
+
with InstallationLock(root):
|
|
786
|
+
if not (directory / "finalized.json").exists():
|
|
787
|
+
finalized(root, transaction, services=services, control=control)
|
|
788
|
+
return _retire(directory, value, native)
|
|
789
|
+
|
|
790
|
+
|
|
791
|
+
def main(argv=None) -> int:
|
|
792
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
793
|
+
parser.add_argument("operation", choices=("arm", "observe-lock", "complete", "finalized", "run"))
|
|
794
|
+
parser.add_argument("--root", type=Path, required=True)
|
|
795
|
+
parser.add_argument("--transaction-id")
|
|
796
|
+
parser.add_argument("--source", type=Path)
|
|
797
|
+
parser.add_argument("--home", type=Path)
|
|
798
|
+
parser.add_argument("--platform", choices=("Darwin", "Linux"))
|
|
799
|
+
parser.add_argument("--bind", default="0.0.0.0")
|
|
800
|
+
parser.add_argument("--port", type=int, default=17850)
|
|
801
|
+
parser.add_argument("--expected-server-identity", default="")
|
|
802
|
+
parser.add_argument("--managed-update-id", default="")
|
|
803
|
+
parser.add_argument("--expected-service-cgroup", default="")
|
|
804
|
+
args = parser.parse_args(argv)
|
|
805
|
+
try:
|
|
806
|
+
root = files._path(args.root)
|
|
807
|
+
if args.operation == "arm":
|
|
808
|
+
if args.source is None or args.home is None or args.platform is None:
|
|
809
|
+
raise ValueError("arm requires exact source, home and platform")
|
|
810
|
+
value = arm(root, args.source, home=args.home, platform=args.platform, bind=args.bind, port=args.port,
|
|
811
|
+
expected_server_identity=args.expected_server_identity, managed_update_id=args.managed_update_id,
|
|
812
|
+
expected_service_cgroup=args.expected_service_cgroup)
|
|
813
|
+
print(value["interpreter"])
|
|
814
|
+
print(root / DIRECTORY / value["transaction_id"] / "execution_recovery.py")
|
|
815
|
+
elif args.operation != "run":
|
|
816
|
+
{"observe-lock": observe_lock, "complete": complete, "finalized": finalized}[args.operation](root, args.transaction_id)
|
|
817
|
+
else:
|
|
818
|
+
while True:
|
|
819
|
+
try:
|
|
820
|
+
if run_once(root, args.transaction_id):
|
|
821
|
+
# No installer remains alive now. Only the dedicated
|
|
822
|
+
# recovery job can be unloaded; worker/gateway untouched.
|
|
823
|
+
_directory_path, value = _owner(root, args.transaction_id)
|
|
824
|
+
if value["platform"] == "Darwin":
|
|
825
|
+
os.execv("/bin/launchctl", ["launchctl", "bootout", f"gui/{os.getuid()}/{_label(value)}"])
|
|
826
|
+
return 0
|
|
827
|
+
except RuntimeError as error:
|
|
828
|
+
if "another installer owns" not in str(error):
|
|
829
|
+
raise
|
|
830
|
+
time.sleep(2)
|
|
831
|
+
return 0
|
|
832
|
+
except (OSError, ValueError, RuntimeError) as error:
|
|
833
|
+
if args.operation == "run":
|
|
834
|
+
try:
|
|
835
|
+
directory, value = _owner(root, args.transaction_id)
|
|
836
|
+
_write(directory / "failure.json", {"transaction_id": args.transaction_id,
|
|
837
|
+
"error_type": type(error).__name__, "message": str(error), "recorded_at": time.time()})
|
|
838
|
+
except Exception:
|
|
839
|
+
pass # Invalid provenance must never be repaired here.
|
|
840
|
+
print("Activation recovery retained its evidence: " + str(error), file=sys.stderr)
|
|
841
|
+
return 1
|
|
842
|
+
|
|
843
|
+
|
|
844
|
+
if __name__ == "__main__":
|
|
845
|
+
raise SystemExit(main())
|