@agentsdock/server 1.0.7-beta.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/LICENSE +202 -0
  2. package/NOTICE +19 -0
  3. package/README.md +60 -0
  4. package/npm/cli.cjs +289 -0
  5. package/package.json +32 -0
  6. package/server/LICENSE +202 -0
  7. package/server/NOTICE +19 -0
  8. package/server/VERSION +1 -0
  9. package/server/activation_transaction.py +2458 -0
  10. package/server/agent_server.py +93226 -0
  11. package/server/agentsdock_chats.py +1237 -0
  12. package/server/agentsdock_emergency.py +338 -0
  13. package/server/agentsdock_jobs.py +640 -0
  14. package/server/agentsdock_mail.py +325 -0
  15. package/server/agentsdock_publish.py +404 -0
  16. package/server/agentsdock_team.py +793 -0
  17. package/server/agentsdock_team_hub/__init__.py +33 -0
  18. package/server/agentsdock_team_hub/auth.py +710 -0
  19. package/server/agentsdock_team_hub/cli.py +647 -0
  20. package/server/agentsdock_team_hub/database.py +310 -0
  21. package/server/agentsdock_team_hub/mail_hint_streams.py +176 -0
  22. package/server/agentsdock_team_hub/mail_hints.py +201 -0
  23. package/server/agentsdock_team_hub/migrations/0001_identity_auth.sql +690 -0
  24. package/server/agentsdock_team_hub/migrations/0002_teamspace_ledger.sql +1336 -0
  25. package/server/agentsdock_team_hub/migrations/0003_service_runtime.sql +361 -0
  26. package/server/agentsdock_team_hub/migrations/0004_managed_host_binding.sql +22 -0
  27. package/server/agentsdock_team_hub/migrations/0005_tailnet_bootstrap_delegations.sql +63 -0
  28. package/server/agentsdock_team_hub/migrations/0006_team_network_mailbox.sql +423 -0
  29. package/server/agentsdock_team_hub/migrations/0007_local_agent_mail.sql +63 -0
  30. package/server/agentsdock_team_hub/migrations/0008_managed_server_session.sql +73 -0
  31. package/server/agentsdock_team_hub/migrations/0009_team_messages.sql +324 -0
  32. package/server/agentsdock_team_hub/migrations/0010_team_attachment_orphan_reclamation.sql +52 -0
  33. package/server/agentsdock_team_hub/migrations/0011_human_admin_paging.sql +65 -0
  34. package/server/agentsdock_team_hub/migrations/0012_network_content_deletions.sql +69 -0
  35. package/server/agentsdock_team_hub/migrations/0013_team_message_revisions.sql +48 -0
  36. package/server/agentsdock_team_hub/migrations/0014_managed_network_owner.sql +50 -0
  37. package/server/agentsdock_team_hub/migrations/0015_team_message_inbox_dismissals.sql +3 -0
  38. package/server/agentsdock_team_hub/migrations/0016_team_message_all_servers.sql +4 -0
  39. package/server/agentsdock_team_hub/migrations/0017_skill_announcement_deletions.sql +16 -0
  40. package/server/agentsdock_team_hub/migrations/0018_team_mail_subjects.sql +8 -0
  41. package/server/agentsdock_team_hub/migrations/0019_team_mailbox_state.sql +5 -0
  42. package/server/agentsdock_team_hub/migrations/0020_team_mail_arrivals.sql +44 -0
  43. package/server/agentsdock_team_hub/migrations/0021_team_mail_threads.sql +4 -0
  44. package/server/agentsdock_team_hub/migrations/0022_team_bulletin_changes.sql +49 -0
  45. package/server/agentsdock_team_hub/migrations/0023_team_message_search.sql +93 -0
  46. package/server/agentsdock_team_hub/migrations/__init__.py +1 -0
  47. package/server/agentsdock_team_hub/notification_hints.py +306 -0
  48. package/server/agentsdock_team_hub/secure_peer.py +11215 -0
  49. package/server/agentsdock_team_hub/secure_peer_hub.py +1372 -0
  50. package/server/agentsdock_team_hub/security.py +318 -0
  51. package/server/agentsdock_team_hub/service.py +1737 -0
  52. package/server/agentsdock_team_hub/store.py +16276 -0
  53. package/server/chat_mailbox.py +448 -0
  54. package/server/claude_background_reconciliation.py +92 -0
  55. package/server/claude_goals.py +245 -0
  56. package/server/claude_history_provenance.py +137 -0
  57. package/server/claude_history_repair.py +1601 -0
  58. package/server/claude_sdk_client.py +3731 -0
  59. package/server/claude_side_question.py +212 -0
  60. package/server/codex_app_server.py +3030 -0
  61. package/server/codex_auth.py +93 -0
  62. package/server/codex_history_repair.py +1247 -0
  63. package/server/codex_provider.py +1120 -0
  64. package/server/codex_side_question.py +301 -0
  65. package/server/cursor_agent_client.py +654 -0
  66. package/server/cursor_process_guard.py +347 -0
  67. package/server/execution_activation.py +610 -0
  68. package/server/execution_control.py +233 -0
  69. package/server/execution_durability.py +184 -0
  70. package/server/execution_http.py +256 -0
  71. package/server/execution_install.py +792 -0
  72. package/server/execution_legacy_runner.py +406 -0
  73. package/server/execution_maintenance.py +137 -0
  74. package/server/execution_manage.py +615 -0
  75. package/server/execution_ownership.py +83 -0
  76. package/server/execution_preparation.py +362 -0
  77. package/server/execution_recovery.py +845 -0
  78. package/server/execution_recovery_status.py +195 -0
  79. package/server/execution_service.py +387 -0
  80. package/server/execution_transport.py +622 -0
  81. package/server/execution_uninstall.py +406 -0
  82. package/server/execution_update_status.py +49 -0
  83. package/server/install.sh +7074 -0
  84. package/server/interactive_chat_controls.py +153 -0
  85. package/server/interactive_chat_native.py +66 -0
  86. package/server/interactive_chat_projection.py +152 -0
  87. package/server/interactive_chat_runtime.py +132 -0
  88. package/server/interactive_chat_share_routes.py +534 -0
  89. package/server/interactive_chat_share_web.py +12 -0
  90. package/server/interactive_chat_shares.py +238 -0
  91. package/server/provider_commands.py +425 -0
  92. package/server/public_chat_share_routes.py +426 -0
  93. package/server/public_chat_shares.py +759 -0
  94. package/server/public_chat_transcript.py +357 -0
  95. package/server/pyproject.toml +29 -0
  96. package/server/release-public-key.pem +3 -0
  97. package/server/secure_peer_delivery.py +1236 -0
  98. package/server/secure_peer_runtime.py +7694 -0
  99. package/server/shared_chat_video_stream.py +262 -0
  100. package/server/shared_chat_videos.py +155 -0
  101. package/server/side_questions.py +594 -0
  102. package/server/team_hub_host.py +1354 -0
  103. package/server/team_mail_grants.py +136 -0
  104. package/server/team_mail_runtime.py +302 -0
  105. package/server/team_mail_websocket.py +211 -0
  106. package/server/title_generation.py +333 -0
  107. package/server/uninstall.sh +359 -0
  108. package/server/update_handoff.py +237 -0
  109. package/server/update_preparation.py +264 -0
  110. package/server/update_recovery.py +140 -0
  111. package/server/update_runner.py +1819 -0
  112. package/server/uv.lock +1101 -0
  113. package/server/workspace_git.py +545 -0
@@ -0,0 +1,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())