fable-engine 1.3.1__py3-none-any.whl

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 (104) hide show
  1. fable_compressor.py +356 -0
  2. fable_engine/__init__.py +1 -0
  3. fable_engine/actions/__init__.py +291 -0
  4. fable_engine/actions/cas.py +182 -0
  5. fable_engine/actions/deliberation.py +523 -0
  6. fable_engine/actions/fleet.py +807 -0
  7. fable_engine/actions/lifecycle.py +298 -0
  8. fable_engine/actions/scrapers.py +116 -0
  9. fable_engine/actions/system3.py +815 -0
  10. fable_engine/browser.py +824 -0
  11. fable_engine/cas.py +974 -0
  12. fable_engine/fable_session.json +510 -0
  13. fable_engine/guards.py +283 -0
  14. fable_engine/schema.py +714 -0
  15. fable_engine/scrapers/__init__.py +32 -0
  16. fable_engine/scrapers/arxiv.py +115 -0
  17. fable_engine/scrapers/base.py +386 -0
  18. fable_engine/scrapers/github.py +129 -0
  19. fable_engine/scrapers/reddit.py +154 -0
  20. fable_engine/scrapers/web.py +120 -0
  21. fable_engine/scrapers/x.py +125 -0
  22. fable_engine/scrapers/youtube.py +132 -0
  23. fable_engine/server.py +414 -0
  24. fable_engine/session.py +1819 -0
  25. fable_engine/test_server.py +1362 -0
  26. fable_engine/updater.py +541 -0
  27. fable_engine-1.3.1.dist-info/LICENSE +22 -0
  28. fable_engine-1.3.1.dist-info/METADATA +173 -0
  29. fable_engine-1.3.1.dist-info/RECORD +104 -0
  30. fable_engine-1.3.1.dist-info/WHEEL +5 -0
  31. fable_engine-1.3.1.dist-info/entry_points.txt +5 -0
  32. fable_engine-1.3.1.dist-info/top_level.txt +6 -0
  33. fable_mode/__init__.py +3 -0
  34. fable_mode/__main__.py +4 -0
  35. fable_mode/adapters.py +1014 -0
  36. fable_mode/installer.py +553 -0
  37. fable_mode/launcher.py +437 -0
  38. fable_mode/manifest.py +142 -0
  39. fable_mode/resources.json +114 -0
  40. fable_mode/safety.py +103 -0
  41. fable_mode_entry.py +10 -0
  42. fable_v2/__init__.py +146 -0
  43. fable_v2/adapters.py +151 -0
  44. fable_v2/coder_fleet/__init__.py +100 -0
  45. fable_v2/coder_fleet/ast_tools.py +158 -0
  46. fable_v2/coder_fleet/compute.py +199 -0
  47. fable_v2/coder_fleet/design_engine.py +1316 -0
  48. fable_v2/coder_fleet/diagnostics.py +293 -0
  49. fable_v2/coder_fleet/fleet_dispatcher.py +214 -0
  50. fable_v2/coder_fleet/mock_auditor.py +306 -0
  51. fable_v2/coder_fleet/mutation.py +216 -0
  52. fable_v2/coder_fleet/property_oracle.py +260 -0
  53. fable_v2/coder_fleet/receipt_attestor.py +122 -0
  54. fable_v2/coder_fleet/red_team_swarm.py +908 -0
  55. fable_v2/coder_fleet/test_harness.py +198 -0
  56. fable_v2/coder_fleet/vector_engine.py +1287 -0
  57. fable_v2/coder_fleet/visual.py +357 -0
  58. fable_v2/coder_fleet/workspace.py +153 -0
  59. fable_v2/cortical/__init__.py +20 -0
  60. fable_v2/cortical/plasticity_engine.py +992 -0
  61. fable_v2/execution_broker.py +811 -0
  62. fable_v2/proof_engine.py +1141 -0
  63. fable_v2/protocol.py +485 -0
  64. fable_v2/runtime.py +1010 -0
  65. fable_v2/system3/__init__.py +204 -0
  66. fable_v2/system3/causal.py +558 -0
  67. fable_v2/system3/dialectical.py +577 -0
  68. fable_v2/system3/evolution.py +503 -0
  69. fable_v2/system3/executive.py +338 -0
  70. fable_v2/system3/free_energy.py +479 -0
  71. fable_v2/system3/hyperbolic.py +555 -0
  72. fable_v2/system3/induction.py +336 -0
  73. fable_v2/system3/kripke.py +548 -0
  74. fable_v2/system3/oracle.py +745 -0
  75. fable_v2/verifiers.py +72 -0
  76. tests/__init__.py +1 -0
  77. tests/test_anti_loop_circuit_breaker.py +64 -0
  78. tests/test_auto_updater.py +407 -0
  79. tests/test_coder_fleet.py +535 -0
  80. tests/test_delegation_compiler.py +54 -0
  81. tests/test_descriptor_boundaries.py +126 -0
  82. tests/test_design_engine.py +603 -0
  83. tests/test_epistemic_evidence_validator.py +66 -0
  84. tests/test_execution_broker.py +233 -0
  85. tests/test_fable_v2.py +406 -0
  86. tests/test_fleet_transitions.py +116 -0
  87. tests/test_fsm_redteam_evolution.py +406 -0
  88. tests/test_goal_rubric_and_pipeline.py +367 -0
  89. tests/test_hebbian_plasticity.py +585 -0
  90. tests/test_packaging_runtime.py +194 -0
  91. tests/test_proof_engine.py +259 -0
  92. tests/test_red_team_swarm.py +645 -0
  93. tests/test_redteam_remediation.py +169 -0
  94. tests/test_registration_transaction.py +375 -0
  95. tests/test_requested_regressions.py +467 -0
  96. tests/test_scrapers.py +370 -0
  97. tests/test_server_actions.py +93 -0
  98. tests/test_server_frontier_actions.py +269 -0
  99. tests/test_server_protocol.py +88 -0
  100. tests/test_stealth_browser.py +970 -0
  101. tests/test_system3.py +381 -0
  102. tests/test_system3_deep_integration.py +385 -0
  103. tests/test_system3_frontier.py +436 -0
  104. tests/test_vector_engine.py +608 -0
fable_mode/adapters.py ADDED
@@ -0,0 +1,1014 @@
1
+ """Safe, bounded host discovery and transactional MCP registration."""
2
+ from __future__ import annotations
3
+
4
+ import json
5
+ import math
6
+ import os
7
+ import shlex
8
+ import shutil
9
+ import signal
10
+ import stat
11
+ import subprocess
12
+ import sys
13
+ import tempfile
14
+ import threading
15
+ from dataclasses import dataclass
16
+ from pathlib import Path
17
+
18
+ MAX_OUTPUT = 8192
19
+ DEFAULT_TIMEOUT = 5.0
20
+ _NAMES = ("fable-engine", "fable-mode")
21
+
22
+
23
+ class RegistrationError(RuntimeError):
24
+ pass
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Host:
29
+ name: str
30
+ executable: Path
31
+ kind: str
32
+ healthy: bool = False
33
+ detail: str = ""
34
+ # Internal override used by tests/explicit transactions. Discovery hosts
35
+ # leave this unset so mutations use the least-privilege target profile env.
36
+ registration_home: Path | None = None
37
+
38
+
39
+ def _safe_path(path: Path, *, allow_missing: bool = True) -> None:
40
+ """Reject links, hardlinks, and non-regular files in existing path parts."""
41
+ p = Path(path)
42
+ cur = p
43
+ parts: list[Path] = []
44
+ while True:
45
+ parts.append(cur)
46
+ if cur.parent == cur:
47
+ break
48
+ cur = cur.parent
49
+ for part in reversed(parts):
50
+ try:
51
+ st = os.lstat(part)
52
+ except FileNotFoundError:
53
+ if allow_missing:
54
+ continue
55
+ raise RegistrationError(f"missing path: {part}")
56
+ reparse = bool(getattr(st, "st_file_attributes", 0) & 0x400)
57
+ junction = bool(getattr(part, "is_junction", lambda: False)())
58
+ trusted_macos_alias = (
59
+ sys.platform == "darwin" and str(part) in {"/var", "/tmp"}
60
+ and str(part.resolve()) in {"/private/var", "/private/tmp"}
61
+ )
62
+ if ((reparse or junction or stat.S_ISLNK(st.st_mode)) and not trusted_macos_alias) or stat.S_ISSOCK(st.st_mode) or stat.S_ISFIFO(st.st_mode) or stat.S_ISCHR(st.st_mode) or stat.S_ISBLK(st.st_mode):
63
+ raise RegistrationError(f"unsafe special/link path: {part}")
64
+ if part == p and stat.S_ISREG(st.st_mode) and st.st_nlink != 1:
65
+ raise RegistrationError(f"hardlinked file is unsafe: {part}")
66
+
67
+
68
+ def _clean_output(data: bytes) -> str:
69
+ text = data[:MAX_OUTPUT].decode("utf-8", "replace")
70
+ return "".join(c for c in text if c in "\n\r\t" or ord(c) >= 32).strip()
71
+
72
+
73
+ def _hash_bytes(data: bytes) -> str:
74
+ import hashlib
75
+ return hashlib.sha256(data).hexdigest()
76
+
77
+
78
+ def _file_identity(path: Path) -> tuple[int, int]:
79
+ st = path.lstat()
80
+ return st.st_dev, st.st_ino
81
+
82
+
83
+ def _kill_process(proc: subprocess.Popen[bytes]) -> None:
84
+ try:
85
+ if os.name == "posix":
86
+ os.killpg(proc.pid, signal.SIGKILL)
87
+ else:
88
+ proc.kill()
89
+ except (OSError, ProcessLookupError):
90
+ try:
91
+ proc.kill()
92
+ except OSError:
93
+ pass
94
+
95
+
96
+ def run_argv(argv: list[str], timeout: float = DEFAULT_TIMEOUT, *, env: dict[str, str] | None = None) -> tuple[int, str, str, bool]:
97
+ """Run argv without a shell, with bounded output and process-group cleanup.
98
+
99
+ POSIX descendants in the new process group are killed on timeout; Windows
100
+ uses the direct-child process-group fallback available in the stdlib, so
101
+ descendant cleanup is best effort there and never escalates to a shell.
102
+ """
103
+ if not argv or any(not isinstance(x, str) or "\x00" in x for x in argv):
104
+ raise ValueError("argv must be a non-empty list of safe strings")
105
+ try:
106
+ timeout = float(timeout)
107
+ except (TypeError, ValueError) as exc:
108
+ raise ValueError("timeout must be a finite positive number") from exc
109
+ if not math.isfinite(timeout) or not (0 < timeout <= 300.0):
110
+ raise ValueError("timeout must be finite and between 0 and 300 seconds")
111
+ kwargs: dict = {"stdin": subprocess.DEVNULL, "stdout": subprocess.PIPE, "stderr": subprocess.PIPE}
112
+ if env is not None:
113
+ # Environment is caller supplied. In particular, never interpret a
114
+ # public variable as a recursive-cleanup capability.
115
+ child_env = dict(env)
116
+ child_env.pop("_FABLE_PROBE_HOME", None)
117
+ kwargs["env"] = child_env
118
+ if os.name == "posix":
119
+ kwargs["start_new_session"] = True
120
+ elif hasattr(subprocess, "CREATE_NEW_PROCESS_GROUP"):
121
+ kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
122
+ try:
123
+ proc = subprocess.Popen(argv, **kwargs)
124
+ except OSError as exc:
125
+ return 127, "", str(exc), False
126
+ captured: dict[str, bytearray] = {"out": bytearray(), "err": bytearray()}
127
+
128
+ def drain(pipe, key: str) -> None:
129
+ try:
130
+ while True:
131
+ chunk = pipe.read(4096)
132
+ if not chunk:
133
+ return
134
+ remaining = MAX_OUTPUT - len(captured[key])
135
+ if remaining > 0:
136
+ captured[key].extend(chunk[:remaining])
137
+ finally:
138
+ pipe.close()
139
+
140
+ threads = [threading.Thread(target=drain, args=(proc.stdout, "out"), daemon=True), threading.Thread(target=drain, args=(proc.stderr, "err"), daemon=True)]
141
+ for thread in threads:
142
+ thread.start()
143
+ timed_out = False
144
+ try:
145
+ proc.wait(timeout=timeout)
146
+ except subprocess.TimeoutExpired:
147
+ timed_out = True
148
+ _kill_process(proc)
149
+ try:
150
+ proc.wait(timeout=2)
151
+ except subprocess.TimeoutExpired:
152
+ proc.kill()
153
+ proc.wait(timeout=2)
154
+ for thread in threads:
155
+ thread.join(timeout=2)
156
+ result = (proc.returncode, _clean_output(bytes(captured["out"])),
157
+ _clean_output(bytes(captured["err"])), timed_out)
158
+ return result
159
+
160
+
161
+ def _probe_environment(executable: Path | None = None) -> dict[str, str]:
162
+ """Build a secret-free environment for one host probe.
163
+
164
+ Probes never inherit the caller's HOME/USERPROFILE or PATH. A private
165
+ temporary home is created for each invocation; its owner (detect_hosts)
166
+ removes it after the child exits. ``/usr/bin/env`` shebangs are resolved *only* from
167
+ the trusted system PATH; falling back to the caller PATH would allow a
168
+ user-private interpreter to run during registration.
169
+ """
170
+ # Include the running interpreter's installation directory as trusted
171
+ # (the launcher itself was resolved before probing), but never caller PATH.
172
+ trusted_path = os.pathsep.join(dict.fromkeys([str(Path(sys.executable).parent), os.defpath]))
173
+ paths = [p for p in trusted_path.split(os.pathsep) if p]
174
+ if executable is not None:
175
+ try:
176
+ first = Path(executable).read_bytes()[:512].splitlines()[0].decode("utf-8", "replace")
177
+ if first.startswith("#!"):
178
+ words = shlex.split(first[2:].strip())
179
+ if words and Path(words[0]).name == "env":
180
+ # Only the system env binary is trusted; a user-provided
181
+ # ``.../env`` must not become a resolver primitive.
182
+ env_path = Path(words[0])
183
+ if not env_path.is_absolute():
184
+ raise RegistrationError("refusing relative env shebang")
185
+ try:
186
+ env_resolved = env_path.resolve()
187
+ env_candidates = {Path(p).resolve() for p in (
188
+ shutil.which("env", path=trusted_path),
189
+ ) if p}
190
+ except OSError as exc:
191
+ raise RegistrationError("refusing unavailable env shebang") from exc
192
+ if env_resolved not in env_candidates:
193
+ raise RegistrationError("refusing untrusted env interpreter")
194
+ # Do not accept env options, assignments, or an empty
195
+ # interpreter: all of those make resolution ambiguous.
196
+ if len(words) != 2 or not words[1] or "/" in words[1] or "\\" in words[1]:
197
+ raise RegistrationError("refusing ambiguous /usr/bin/env shebang")
198
+ interpreter = shutil.which(words[1], path=trusted_path)
199
+ if not interpreter:
200
+ raise RegistrationError("refusing unresolved /usr/bin/env shebang")
201
+ paths.insert(0, str(Path(interpreter).parent))
202
+ elif words and Path(words[0]).is_absolute():
203
+ if len(words) != 1:
204
+ raise RegistrationError("refusing absolute shebang with interpreter arguments")
205
+ interpreter_path = Path(words[0])
206
+ try:
207
+ resolved_interpreter = interpreter_path.resolve()
208
+ st = resolved_interpreter.lstat()
209
+ except OSError as exc:
210
+ raise RegistrationError("refusing unavailable absolute shebang interpreter") from exc
211
+ trusted_interpreters = {Path(sys.executable).resolve()}
212
+ for candidate_name in (interpreter_path.name, "sh", "bash", "python", "python3"):
213
+ candidate = shutil.which(candidate_name, path=trusted_path)
214
+ if candidate:
215
+ trusted_interpreters.add(Path(candidate).resolve())
216
+ if (not stat.S_ISREG(st.st_mode) or st.st_nlink != 1
217
+ or resolved_interpreter not in trusted_interpreters):
218
+ raise RegistrationError("refusing untrusted absolute shebang interpreter")
219
+ paths.insert(0, str(resolved_interpreter.parent))
220
+ except RegistrationError:
221
+ raise
222
+ except FileNotFoundError:
223
+ # Cleanup of a removed host may still need to inspect its last
224
+ # registration; let run_argv report the unavailable executable.
225
+ pass
226
+ except (OSError, ValueError, IndexError):
227
+ raise RegistrationError("refusing malformed host shebang")
228
+ probe_home = Path(tempfile.mkdtemp(prefix="fable-probe-home-"))
229
+ os.chmod(probe_home, 0o700)
230
+ # Private implementation key is removed before spawning and consumed by
231
+ # run_argv's finally block; it is not exposed to the host process.
232
+ return {"PATH": os.pathsep.join(dict.fromkeys(paths)),
233
+ "HOME": str(probe_home), "USERPROFILE": str(probe_home),
234
+ "XDG_CONFIG_HOME": str(probe_home / ".config"),
235
+ "LC_ALL": "C", "LANG": "C",
236
+ "_FABLE_PROBE_HOME": str(probe_home)}
237
+
238
+
239
+ def _run_probe(argv: list[str], executable: Path) -> tuple[int, str, str, bool]:
240
+ env = _probe_environment(executable)
241
+ probe_home = Path(env["_FABLE_PROBE_HOME"])
242
+ try:
243
+ return run_argv(argv, env=env)
244
+ finally:
245
+ shutil.rmtree(probe_home, ignore_errors=True)
246
+
247
+
248
+ def _registration_environment(home: Path | None = None) -> dict[str, str]:
249
+ """Return a least-privilege environment for one host mutation.
250
+
251
+ Host executables are discovered from the user's PATH and therefore are
252
+ arbitrary programs from an untrusted boundary. They must not receive the
253
+ launcher's complete environment: API keys, cloud credentials, proxy
254
+ tokens, and unrelated application secrets are not needed to add/remove an
255
+ MCP entry. Keep only paths and process settings required for a normal CLI
256
+ invocation. Credential forwarding, if ever needed by a host, must be an
257
+ explicit future feature rather than an accidental inheritance side effect.
258
+ """
259
+ target_home = Path(home).expanduser() if home is not None else Path.home()
260
+ value = str(target_home)
261
+ # The executable itself has already been selected and checked. A system
262
+ # PATH is enough for /usr/bin/env shebangs and for normal host subprocesses,
263
+ # while avoiding user PATH entries that can select another interpreter.
264
+ trusted_path = os.pathsep.join(dict.fromkeys([str(Path(sys.executable).parent), os.defpath]))
265
+ def config_path(name: str, fallback: Path) -> str:
266
+ # Preserve an explicitly configured profile location for normal
267
+ # registration. An explicit ``home`` is a transaction override and
268
+ # intentionally makes all profile paths follow that home.
269
+ if home is None and os.environ.get(name):
270
+ return os.environ[name]
271
+ return str(fallback)
272
+
273
+ env = {
274
+ "PATH": trusted_path,
275
+ "HOME": value,
276
+ "USERPROFILE": value,
277
+ "XDG_CONFIG_HOME": config_path("XDG_CONFIG_HOME", target_home / ".config"),
278
+ "XDG_DATA_HOME": config_path("XDG_DATA_HOME", target_home / ".local" / "share"),
279
+ "XDG_CACHE_HOME": config_path("XDG_CACHE_HOME", target_home / ".cache"),
280
+ "XDG_STATE_HOME": config_path("XDG_STATE_HOME", target_home / ".local" / "state"),
281
+ "LC_ALL": "C",
282
+ "LANG": "C",
283
+ "LANGUAGE": "C",
284
+ "PYTHONIOENCODING": "utf-8",
285
+ }
286
+ # Temporary directories are operational rather than credential-bearing.
287
+ # Preserve explicit caller choices because host CLIs may rely on them, but
288
+ # never copy any other environment variable by default.
289
+ for name in ("TMPDIR", "TMP", "TEMP"):
290
+ if name in os.environ:
291
+ env[name] = os.environ[name]
292
+ if os.name == "nt":
293
+ # These are required by cmd.exe-backed host launchers and Windows
294
+ # configuration lookup. They contain system paths, not credentials.
295
+ env["APPDATA"] = config_path("APPDATA", target_home / "AppData" / "Roaming")
296
+ env["LOCALAPPDATA"] = config_path("LOCALAPPDATA", target_home / "AppData" / "Local")
297
+ for name in ("SystemRoot", "SYSTEMROOT", "windir", "PATHEXT", "COMSPEC"):
298
+ if name in os.environ:
299
+ env[name] = os.environ[name]
300
+ return env
301
+
302
+
303
+ def _which(name: str) -> Path | None:
304
+ value = shutil.which(name)
305
+ if not value:
306
+ return None
307
+ path = Path(value)
308
+ try:
309
+ st = path.lstat()
310
+ if stat.S_ISLNK(st.st_mode) or not stat.S_ISREG(st.st_mode) or st.st_nlink != 1:
311
+ return None
312
+ except OSError:
313
+ return None
314
+ return path
315
+
316
+
317
+ def detect_hosts(*, aliases: bool = False) -> dict[str, Host]:
318
+ names: list[tuple[str, str]] = [("claude", "claude"), ("agy", "agy"), ("codex", "codex")]
319
+ if aliases:
320
+ names += [("cc", "cc"), ("antigravity", "antigravity")]
321
+ result: dict[str, Host] = {}
322
+ for name, binary in names:
323
+ exe = _which(binary)
324
+ if exe is None:
325
+ # Keep absence visible to callers instead of silently omitting a
326
+ # requested host from the registration status report.
327
+ result[name] = Host(name, Path(binary), "cli", False, "host command not found")
328
+ continue
329
+ try:
330
+ code, out, err, timed_out = _run_probe([str(exe), "--version"], exe)
331
+ result[name] = Host(name, exe, "cli", code == 0 and not timed_out, (out or err)[:MAX_OUTPUT])
332
+ except RegistrationError as exc:
333
+ # A malformed/unresolved /usr/bin/env shebang makes this host
334
+ # unhealthy, not the entire discovery/installation transaction.
335
+ result[name] = Host(name, exe, "cli", False, str(exc)[:MAX_OUTPUT])
336
+ return result
337
+
338
+
339
+ def _load_config_snapshot(path: Path) -> tuple[bool, bytes, int, dict]:
340
+ _safe_path(path)
341
+ if not path.exists():
342
+ return False, b"", 0, {}
343
+ if not path.is_file():
344
+ raise RegistrationError(f"config is not a regular file: {path}")
345
+ raw = path.read_bytes()
346
+ try:
347
+ current = json.loads(raw.decode("utf-8"))
348
+ except (UnicodeDecodeError, ValueError, RecursionError) as exc:
349
+ raise RegistrationError(f"invalid JSON config: {path}") from exc
350
+ if not isinstance(current, dict):
351
+ raise RegistrationError("MCP config root must be an object")
352
+ servers = current.get("mcpServers", {})
353
+ if not isinstance(servers, dict):
354
+ raise RegistrationError("mcpServers must be an object")
355
+ return True, raw, stat.S_IMODE(path.stat().st_mode), current
356
+
357
+
358
+ def _open_directory_nofollow(path: Path, *, create: bool = True) -> int:
359
+ """Open a directory chain and retain its identity across publication."""
360
+ if os.name != "posix" or not hasattr(os, "O_NOFOLLOW"):
361
+ raise RegistrationError("descriptor-relative config writes are unavailable")
362
+ absolute = Path(path).absolute()
363
+ fd = os.open("/", os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | os.O_NOFOLLOW)
364
+ try:
365
+ for component in absolute.parts[1:]:
366
+ component_flags = os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | os.O_NOFOLLOW
367
+ if (sys.platform == "darwin" and component in {"var", "tmp"}
368
+ and str(Path("/", component).resolve()) in {"/private/var", "/private/tmp"}):
369
+ component_flags &= ~os.O_NOFOLLOW
370
+ try:
371
+ child = os.open(component, component_flags, dir_fd=fd)
372
+ except FileNotFoundError:
373
+ if not create:
374
+ raise RegistrationError(f"missing config directory: {absolute}")
375
+ os.mkdir(component, 0o700, dir_fd=fd)
376
+ child = os.open(component, os.O_RDONLY | getattr(os, "O_DIRECTORY", 0) | os.O_NOFOLLOW, dir_fd=fd)
377
+ os.close(fd)
378
+ fd = child
379
+ return fd
380
+ except Exception:
381
+ os.close(fd)
382
+ raise
383
+
384
+
385
+ def _atomic_write(path: Path, content: bytes, mode: int | None = 0o600) -> None:
386
+ path = Path(path).absolute()
387
+ _safe_path(path.parent)
388
+ if os.name == "posix" and hasattr(os, "O_NOFOLLOW"):
389
+ parent_fd = _open_directory_nofollow(path.parent)
390
+ temp_name = f".{path.name}.tmp-{os.getpid()}-{os.urandom(8).hex()}"
391
+ fd = None
392
+ try:
393
+ fd = os.open(temp_name, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=parent_fd)
394
+ os.fchmod(fd, 0o600 if mode is None else mode)
395
+ with os.fdopen(fd, "wb") as handle:
396
+ fd = None
397
+ handle.write(content)
398
+ handle.flush()
399
+ os.fsync(handle.fileno())
400
+ os.replace(temp_name, path.name, src_dir_fd=parent_fd, dst_dir_fd=parent_fd)
401
+ finally:
402
+ if fd is not None:
403
+ os.close(fd)
404
+ try:
405
+ os.unlink(temp_name, dir_fd=parent_fd)
406
+ except OSError:
407
+ pass
408
+ os.close(parent_fd)
409
+ return
410
+ # Windows fallback: retain the preflight checks and fail closed if the
411
+ # parent changes before publication rather than silently following links.
412
+ parent_identity = _file_identity(path.parent) if path.parent.exists() else None
413
+ path.parent.mkdir(parents=True, exist_ok=True)
414
+ _safe_path(path.parent)
415
+ if parent_identity is not None and _file_identity(path.parent) != parent_identity:
416
+ raise RegistrationError("config parent changed during publication")
417
+ fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.tmp-", dir=str(path.parent))
418
+ temp = Path(temp_name)
419
+ try:
420
+ if hasattr(os, "fchmod"):
421
+ os.fchmod(fd, 0o600 if mode is None else mode)
422
+ else:
423
+ os.chmod(temp_name, 0o600 if mode is None else mode)
424
+ with os.fdopen(fd, "wb") as handle:
425
+ handle.write(content)
426
+ handle.flush()
427
+ os.fsync(handle.fileno())
428
+ _safe_path(path.parent)
429
+ os.replace(temp, path)
430
+ finally:
431
+ if temp.exists():
432
+ temp.unlink()
433
+
434
+
435
+ def _write_antigravity_config(path: Path, executable: list[str], *, dry_run: bool = False) -> bool:
436
+ """Apply one file registration; transaction orchestration is in register_hosts.
437
+
438
+ MCP configuration files can contain credentials or local server commands.
439
+ Every Fable write therefore normalizes the file to owner-only permissions;
440
+ the original mode is retained in the registration record for restoration.
441
+ """
442
+ existed, _raw, _mode, current = _load_config_snapshot(path)
443
+ servers = current.get("mcpServers", {})
444
+ previous = {name: servers[name] for name in _NAMES if name in servers}
445
+ updated = dict(current)
446
+ updated["mcpServers"] = dict(servers)
447
+ updated["mcpServers"].pop("fable-mode", None)
448
+ updated["mcpServers"]["fable-engine"] = {"command": executable[0], "args": executable[1:]}
449
+ if not dry_run:
450
+ _atomic_write(path, (json.dumps(updated, indent=2, sort_keys=True) + "\n").encode(), 0o600)
451
+ return "fable-engine" not in previous
452
+
453
+
454
+ def _parse_cli_json(value: object) -> dict[str, dict] | None:
455
+ """Extract the two Fable entries from a machine-readable MCP listing.
456
+
457
+ ``None`` means the host returned human-readable/unknown output. This is
458
+ deliberately distinct from ``{}``: an empty JSON listing is a safe snapshot.
459
+ """
460
+ if not isinstance(value, (dict, list)):
461
+ return None
462
+ found: dict[str, dict] = {}
463
+ valid = True
464
+ max_depth = 64
465
+
466
+ def visit(node: object, depth: int = 0) -> None:
467
+ nonlocal valid
468
+ if depth > max_depth:
469
+ valid = False
470
+ return
471
+ if isinstance(node, dict):
472
+ for name in _NAMES:
473
+ item = node.get(name)
474
+ if item is not None:
475
+ if not isinstance(item, dict) or not isinstance(item.get("command"), str) or not isinstance(item.get("args", []), list) or any(not isinstance(arg, str) for arg in item.get("args", [])):
476
+ valid = False
477
+ else:
478
+ found[name] = {"command": item["command"], "args": list(item.get("args", []))}
479
+ # Support common {name, command, args} list records.
480
+ name = node.get("name")
481
+ if name in _NAMES and "command" in node:
482
+ item = {"command": node.get("command"), "args": node.get("args", [])}
483
+ if not isinstance(item["command"], str) or not isinstance(item["args"], list) or any(not isinstance(arg, str) for arg in item["args"]):
484
+ valid = False
485
+ else:
486
+ found[name] = item
487
+ for child in node.values():
488
+ visit(child, depth + 1)
489
+ elif isinstance(node, list):
490
+ for child in node:
491
+ visit(child, depth + 1)
492
+ visit(value)
493
+ return found if valid else None
494
+
495
+
496
+ def _snapshot_cli_registrations(executable: Path, *, home: Path | None = None) -> dict[str, dict] | None:
497
+ # Cleanup markers may outlive a removed host executable. Let the bounded
498
+ # argv call report that as an unsupported snapshot instead of raising a
499
+ # state-path traceback; existing paths are still checked for links.
500
+ if executable.exists() or executable.is_symlink():
501
+ _safe_path(executable, allow_missing=False)
502
+ code, out, _err, timed = run_argv(
503
+ [str(executable), "mcp", "list"], env=_registration_environment(home))
504
+ if code != 0 or timed:
505
+ return None
506
+ try:
507
+ return _parse_cli_json(json.loads(out))
508
+ except (TypeError, ValueError, json.JSONDecodeError, RecursionError):
509
+ return None
510
+
511
+
512
+ def _query_cli_registrations(executable: Path) -> dict[str, dict]:
513
+ """Compatibility wrapper: unsupported/human output is represented as empty."""
514
+ return _snapshot_cli_registrations(executable) or {}
515
+
516
+
517
+ def _cli_add(host: Host, name: str, entry: dict) -> tuple[int, str, str, bool]:
518
+ if host.name == "claude":
519
+ argv = [str(host.executable), "mcp", "add", "--transport", "stdio", name, "--", entry["command"], *entry.get("args", [])]
520
+ else:
521
+ argv = [str(host.executable), "mcp", "add", name, "--", entry["command"], *entry.get("args", [])]
522
+ return run_argv(argv, env=_registration_environment(host.registration_home))
523
+
524
+
525
+ def _is_absent_result(result: tuple[int, str, str, bool]) -> bool:
526
+ code, out, err, timed = result
527
+ if timed:
528
+ return False
529
+ if code == 0:
530
+ return True
531
+ text = (out + " " + err).lower()
532
+ return any(token in text for token in ("not found", "does not exist", "doesn't exist", "no such", "unknown server", "not configured", "no mcp server", "already absent", "cannot find"))
533
+
534
+
535
+ def _cli_remove(host: Host, name: str) -> tuple[int, str, str, bool]:
536
+ return run_argv([str(host.executable), "mcp", "remove", name],
537
+ env=_registration_environment(host.registration_home))
538
+
539
+
540
+ def _restore_cli(host: Host, previous: dict[str, dict]) -> None:
541
+ errors: list[str] = []
542
+ # Do not issue a needless legacy remove when it was absent in the snapshot;
543
+ # importantly, rollback never manufactures or otherwise touches fable-mode.
544
+ names_to_remove = ["fable-engine"] + [name for name in _NAMES if name != "fable-engine" and name in previous]
545
+ for name in names_to_remove:
546
+ if not _is_absent_result(_cli_remove(host, name)):
547
+ errors.append(f"remove {name}")
548
+ for name, entry in previous.items():
549
+ code, _out, _err, timed = _cli_add(host, name, entry)
550
+ if code != 0 or timed:
551
+ errors.append(f"restore {name}")
552
+ if errors:
553
+ raise RegistrationError(f"could not restore {host.name} registration ({', '.join(errors)})")
554
+
555
+
556
+ def _post_entries(previous: dict[str, dict], installed: dict) -> dict[str, dict]:
557
+ # Legacy is intentionally absent after installation. This map is recorded
558
+ # so uninstall can detect a user-created legacy entry and preserve it.
559
+ return {"fable-engine": installed}
560
+
561
+
562
+ def register_hosts(hosts: dict[str, Host], installed_executable: list[str], *, home: Path | None = None,
563
+ workspace: Path | None = None, dry_run: bool = False,
564
+ records: list[dict] | None = None,
565
+ owned_records: list[dict] | None = None) -> dict[str, str]:
566
+ """Register all hosts as one transaction, with a complete preflight.
567
+
568
+ Every healthy host is snapshotted before the first mutation. CLI hosts
569
+ that expose only human-readable ``mcp list`` output are rejected before any
570
+ host is changed; this avoids deleting an old runtime that cannot be restored.
571
+ """
572
+ if not installed_executable or any(not isinstance(x, str) or "\x00" in x or "\n" in x or "\r" in x for x in installed_executable):
573
+ raise RegistrationError("invalid installed executable argv")
574
+ explicit_home = home is not None
575
+ home = Path(home or Path.home())
576
+ cli_home = home if explicit_home else None
577
+ statuses: dict[str, str] = {}
578
+ file_snaps: dict[Path, tuple[bool, bytes, int, dict]] = {}
579
+ cli_snaps: dict[str, dict[str, dict]] = {}
580
+ # Entries recorded by the immediately prior install are still Fable-owned
581
+ # during a replacement. Keep the complete record, rather than only the
582
+ # installed command: its previous_entries is the true pre-Fable state and
583
+ # must survive an install -> reinstall -> uninstall cycle.
584
+ prior_cli: dict[str, dict] = {}
585
+ prior_files: dict[str, dict] = {}
586
+ for record in owned_records or []:
587
+ if not isinstance(record, dict):
588
+ continue
589
+ kind = record.get("kind")
590
+ if (kind == "cli" and isinstance(record.get("host"), str)
591
+ and isinstance(record.get("command"), str)
592
+ and isinstance(record.get("args"), list)):
593
+ prior_cli[record["host"]] = record
594
+ elif kind == "file" and isinstance(record.get("path"), str):
595
+ prior_files[record["path"]] = record
596
+
597
+ def prior_entry(record: dict) -> dict | None:
598
+ value = {"command": record.get("command"), "args": record.get("args", [])}
599
+ return value if _valid_registration_entry(value) else None
600
+
601
+ def prior_state(record: dict) -> dict[str, dict] | None:
602
+ value = record.get("previous_entries")
603
+ validator = (_valid_file_registration_state if record.get("kind") == "file"
604
+ else _valid_registration_state)
605
+ return value if validator(value) else None
606
+ active_hosts: list[tuple[str, Host]] = []
607
+ # Preflight every path and CLI state, including hosts that occur later.
608
+ for key, host in hosts.items():
609
+ if not host.healthy:
610
+ statuses[key] = "detected but unhealthy; not registered"
611
+ continue
612
+ if key in {"agy", "antigravity"}:
613
+ active_hosts.append((key, host))
614
+ else:
615
+ # Preserve caller-supplied Host objects while binding CLI
616
+ # mutations to this transaction's intended profile, never probe HOME.
617
+ active_hosts.append((key, Host(host.name, host.executable, host.kind,
618
+ host.healthy, host.detail, cli_home)))
619
+ if key in {"agy", "antigravity"}:
620
+ paths = [home / ".gemini" / "config" / "mcp_config.json"]
621
+ if workspace is not None:
622
+ paths.append(Path(workspace) / ".agents" / "mcp_config.json")
623
+ for path in paths:
624
+ if path not in file_snaps:
625
+ file_snaps[path] = _load_config_snapshot(path)
626
+ else:
627
+ snapshot = None if dry_run else _snapshot_cli_registrations(host.executable, home=cli_home)
628
+ if not dry_run and snapshot is None:
629
+ raise RegistrationError(f"{key} mcp list is not machine-readable; registration skipped before mutation")
630
+ snapshot = snapshot or {}
631
+ old_record = prior_cli.get(key) or prior_cli.get(host.name)
632
+ old_entry = prior_entry(old_record) if old_record else None
633
+ if old_record and old_entry and snapshot.get("fable-engine") == old_entry:
634
+ # The current canonical entry belongs to the retired Fable
635
+ # install. Never snapshot it as user-owned; restore the
636
+ # original complete state recorded by the first installation.
637
+ snapshot = prior_state(old_record) or {}
638
+ cli_snaps[key] = snapshot
639
+ if dry_run:
640
+ for key, host in active_hosts:
641
+ statuses[key] = "would-register"
642
+ return statuses
643
+
644
+ original_records_len = len(records) if records is not None else 0
645
+ # Atomic config publication changes the inode. Rollback may remove or
646
+ # restore only that published inode; if another process replaced it after
647
+ # our write, preserve the replacement and report partial rollback.
648
+ published_file_ids: dict[Path, tuple[int, int]] = {}
649
+ try:
650
+ file_paths_done: set[Path] = set()
651
+ for key, host in active_hosts:
652
+ if key in {"agy", "antigravity"}:
653
+ paths = [home / ".gemini" / "config" / "mcp_config.json"]
654
+ if workspace is not None:
655
+ paths.append(Path(workspace) / ".agents" / "mcp_config.json")
656
+ for path in paths:
657
+ if path in file_paths_done:
658
+ continue
659
+ file_paths_done.add(path)
660
+ existed, raw, mode, current = file_snaps[path]
661
+ servers = current.get("mcpServers", {})
662
+ previous = {name: servers[name] for name in _NAMES if name in servers}
663
+ installed = {"command": installed_executable[0], "args": installed_executable[1:]}
664
+ old_record = prior_files.get(str(path))
665
+ old_entry = prior_entry(old_record) if old_record else None
666
+ if old_record and old_entry and previous.get("fable-engine") == old_entry:
667
+ # A replacement must carry forward the first install's
668
+ # baseline, including whether this config was created
669
+ # by Fable and its original private/public mode.
670
+ baseline = prior_state(old_record)
671
+ if baseline is not None:
672
+ previous = baseline
673
+ if isinstance(old_record.get("existed"), bool):
674
+ existed = old_record["existed"]
675
+ old_mode = old_record.get("previous_mode")
676
+ if isinstance(old_mode, int) and 0 <= old_mode <= 0o777:
677
+ mode = old_mode
678
+ updated = dict(current); updated["mcpServers"] = dict(servers)
679
+ updated["mcpServers"].pop("fable-mode", None); updated["mcpServers"]["fable-engine"] = installed
680
+ # Never leave credentials or executable commands readable
681
+ # by another account after a Fable mutation.
682
+ _atomic_write(path, (json.dumps(updated, indent=2, sort_keys=True) + "\n").encode(), 0o600)
683
+ try:
684
+ st = path.lstat()
685
+ published_file_ids[path] = (st.st_dev, st.st_ino)
686
+ except OSError as exc:
687
+ raise RegistrationError(f"could not stat published config: {path}") from exc
688
+ if records is not None:
689
+ record_workspace = str(workspace) if workspace is not None and path == Path(workspace) / ".agents" / "mcp_config.json" else None
690
+ records.append({"kind": "file", "path": str(path), "name": "fable-engine",
691
+ "workspace": record_workspace,
692
+ "command": installed["command"], "args": installed["args"],
693
+ "existed": existed, "previous_mode": (mode if mode is not None else 0o600),
694
+ "post_mode": 0o600,
695
+ "previous_entries": previous, "previous_entries_hash": _state_hash(previous),
696
+ "post_entries": _post_entries(previous, installed),
697
+ "post_identity": list(_file_identity(path)),
698
+ "post_content_hash": _hash_bytes((json.dumps(updated, indent=2, sort_keys=True) + "\n").encode())})
699
+ statuses[key] = "registered"
700
+ continue
701
+ previous = cli_snaps[key]
702
+ installed = {"command": installed_executable[0], "args": installed_executable[1:]}
703
+ for name in _NAMES:
704
+ if not _is_absent_result(_cli_remove(host, name)):
705
+ raise RegistrationError(f"{key} {name} removal failed")
706
+ code, out, err, timed = _cli_add(host, "fable-engine", installed)
707
+ if code != 0 or timed:
708
+ raise RegistrationError(f"{key} registration failed: {_clean_output((err or out).encode())}")
709
+ if records is not None:
710
+ records.append({"kind": "cli", "host": key, "executable": str(host.executable), "name": "fable-engine",
711
+ "command": installed["command"], "args": installed["args"],
712
+ "previous_entries": previous, "previous_entries_hash": _state_hash(previous),
713
+ "post_entries": _post_entries(previous, installed),
714
+ "created": "fable-engine" not in previous,
715
+ "executable_identity": list(_file_identity(host.executable))})
716
+ statuses[key] = "registered"
717
+ return statuses
718
+ except Exception as exc:
719
+ rollback_errors: list[str] = []
720
+ # Restore all file snapshots byte-for-byte and every CLI snapshot,
721
+ # including hosts that were changed before the failing host.
722
+ for path, (existed, raw, mode, _current) in file_snaps.items():
723
+ try:
724
+ expected = published_file_ids.get(path)
725
+ if expected is None:
726
+ continue # this path was never mutated by this transaction
727
+ try:
728
+ current_st = path.lstat()
729
+ except FileNotFoundError:
730
+ raise RegistrationError("published config disappeared")
731
+ if (current_st.st_dev, current_st.st_ino) != expected:
732
+ raise RegistrationError("published config was replaced; replacement retained")
733
+ if existed:
734
+ _atomic_write(path, raw, mode)
735
+ else:
736
+ _safe_path(path, allow_missing=False)
737
+ path.unlink()
738
+ except (OSError, RegistrationError) as restore_exc:
739
+ rollback_errors.append(f"{path}: {restore_exc}")
740
+ for key, host in active_hosts:
741
+ if key not in cli_snaps:
742
+ continue
743
+ try:
744
+ _restore_cli(host, cli_snaps[key])
745
+ except RegistrationError as restore_exc:
746
+ rollback_errors.append(f"{key}: {restore_exc}")
747
+ if records is not None:
748
+ del records[original_records_len:]
749
+ detail = f"host registration rolled back: {exc}"
750
+ if rollback_errors:
751
+ detail += "; partial state may remain: " + "; ".join(rollback_errors)
752
+ raise RegistrationError(detail) from exc
753
+
754
+
755
+ def _record_state(record: dict) -> tuple[dict[str, dict], dict[str, dict]] | None:
756
+ previous = record.get("previous_entries")
757
+ post = record.get("post_entries")
758
+ if not isinstance(previous, dict) or not isinstance(post, dict):
759
+ return None
760
+ return previous, post
761
+
762
+
763
+ def _valid_registration_entry(value: object) -> bool:
764
+ return (isinstance(value, dict) and set(value) == {"command", "args"}
765
+ and isinstance(value["command"], str) and bool(value["command"])
766
+ and "\x00" not in value["command"] and isinstance(value["args"], list)
767
+ and len(value["args"]) <= 64 and all(isinstance(arg, str) and "\x00" not in arg for arg in value["args"]))
768
+
769
+
770
+ def _valid_registration_state(value: object) -> bool:
771
+ """Validate machine-readable CLI registration state."""
772
+ return (isinstance(value, dict) and set(value).issubset(_NAMES)
773
+ and all(_valid_registration_entry(entry) for entry in value.values()))
774
+
775
+
776
+ def _valid_file_registration_entry(value: object) -> bool:
777
+ """Validate a file-backed MCP entry without discarding host extensions.
778
+
779
+ Antigravity entries commonly carry ``cwd`` or environment metadata in
780
+ addition to command/args. Those fields are user state and must round-trip
781
+ exactly, while command and argument values remain bounded and string-only.
782
+ """
783
+ if not isinstance(value, dict) or not isinstance(value.get("command"), str) or not value["command"]:
784
+ return False
785
+ if "args" in value and (not isinstance(value["args"], list)
786
+ or len(value["args"]) > 64
787
+ or any(not isinstance(arg, str) or "\x00" in arg for arg in value["args"])):
788
+ return False
789
+ return "\x00" not in value["command"]
790
+
791
+
792
+ def _valid_file_registration_state(value: object) -> bool:
793
+ return (isinstance(value, dict) and set(value).issubset(_NAMES)
794
+ and all(_valid_file_registration_entry(entry) for entry in value.values()))
795
+
796
+
797
+ def _state_hash(value: dict) -> str:
798
+ return _hash_bytes(json.dumps(value, sort_keys=True, separators=(",", ":")).encode())
799
+
800
+
801
+ def _canonical_registration_path(record: dict, home: Path) -> Path | None:
802
+ if record.get("kind") != "file":
803
+ return None
804
+ path = Path(record.get("path", ""))
805
+ canonical = home / ".gemini" / "config" / "mcp_config.json"
806
+ if path == canonical:
807
+ return path
808
+ workspace = record.get("workspace")
809
+ if isinstance(workspace, str) and workspace:
810
+ candidate = Path(workspace) / ".agents" / "mcp_config.json"
811
+ if path == candidate:
812
+ return path
813
+ return None
814
+
815
+
816
+ def _validate_strict_record(record: object, install_dir: Path, home: Path,
817
+ *, transaction: bool = False) -> bool:
818
+ """Validate marker records before they can influence any user config."""
819
+ if not isinstance(record, dict) or record.get("install_dir") != str(install_dir):
820
+ return False
821
+ try:
822
+ install_st = install_dir.lstat()
823
+ except OSError:
824
+ return False
825
+ if record.get("install_identity") != [install_st.st_dev, install_st.st_ino]:
826
+ return False
827
+ common = {"kind", "name", "command", "args", "previous_entries", "previous_entries_hash",
828
+ "post_entries", "install_dir", "install_identity"}
829
+ if record.get("name") != "fable-engine" or not _valid_registration_entry({"command": record.get("command"), "args": record.get("args")}):
830
+ return False
831
+ state_validator = (_valid_file_registration_state if record.get("kind") == "file"
832
+ else _valid_registration_state)
833
+ if not state_validator(record.get("previous_entries")) or not state_validator(record.get("post_entries")):
834
+ return False
835
+ previous = record["previous_entries"]
836
+ if record.get("previous_entries_hash") != _state_hash(previous):
837
+ return False
838
+ if record.get("post_entries") != {"fable-engine": {"command": record["command"], "args": record["args"]}}:
839
+ return False
840
+ kind = record.get("kind")
841
+ if kind == "file":
842
+ if set(record) - (common | {"path", "workspace", "existed", "previous_mode",
843
+ "post_mode", "post_identity", "post_content_hash"}):
844
+ return False
845
+ if not isinstance(record.get("existed"), bool):
846
+ return False
847
+ previous_mode = record.get("previous_mode", 0o600)
848
+ post_mode = record.get("post_mode", 0o600)
849
+ if (not isinstance(previous_mode, int) or not 0 <= previous_mode <= 0o777
850
+ or not isinstance(post_mode, int) or not 0 <= post_mode <= 0o777
851
+ or post_mode & 0o077):
852
+ return False
853
+ if _canonical_registration_path(record, home) is None:
854
+ return False
855
+ ident = record.get("post_identity")
856
+ return (isinstance(ident, list) and len(ident) == 2 and all(isinstance(x, int) and x >= 0 for x in ident)
857
+ and isinstance(record.get("post_content_hash"), str) and len(record["post_content_hash"]) == 64
858
+ and all(c in "0123456789abcdef" for c in record["post_content_hash"]))
859
+ if kind == "cli":
860
+ if set(record) - (common | {"host", "executable", "created", "executable_identity"}):
861
+ return False
862
+ host = record.get("host")
863
+ executable = record.get("executable")
864
+ ident = record.get("executable_identity")
865
+ if host not in {"claude", "agy", "codex", "cc", "antigravity"} or not isinstance(executable, str):
866
+ return False
867
+ p = Path(executable)
868
+ # A persisted marker must bind the executable basename to its
869
+ # allowlisted host. During the same-process install transaction,
870
+ # tests/embedders may intentionally use a shim with another filename;
871
+ # inode identity and all other record checks still apply.
872
+ if not p.is_absolute() or (not transaction and
873
+ p.name.casefold().split(".")[0] != host.casefold()):
874
+ return False
875
+ return (isinstance(ident, list) and len(ident) == 2 and all(isinstance(x, int) and x >= 0 for x in ident))
876
+ return False
877
+
878
+
879
+ def validate_registration_record(record: object, install_dir: Path, home: Path,
880
+ *, transaction: bool = False) -> bool:
881
+ """Validate a persisted registration record without touching host state.
882
+
883
+ The launcher uses this after a host mutation but before persisting recovery
884
+ metadata. ``transaction`` is deliberately an explicit, narrow escape
885
+ hatch for same-process test/transaction shims whose executable basename is
886
+ not the discovered host name; normal uninstall validation never enables it.
887
+ """
888
+ return _validate_strict_record(record, Path(install_dir), Path(home),
889
+ transaction=transaction)
890
+
891
+
892
+ def cleanup_recorded_registrations(records: list[dict], *, strict: bool = False,
893
+ install_dir: Path | None = None, home: Path | None = None,
894
+ _preflight: bool = False,
895
+ _transaction: bool = False) -> list[str]:
896
+ """Remove this install's entries, restoring exact pre-install state.
897
+
898
+ Strict mode is used for uninstall marker data. It accepts only records
899
+ emitted by this installer for canonical config paths and matching inode /
900
+ content identities; malformed or tampered records are skipped fail-closed.
901
+
902
+ A changed command/args, unknown CLI format, or changed file is preserved and
903
+ reported rather than being removed. Legacy ``fable-mode`` is restored only
904
+ when it was present in the recorded pre-install snapshot.
905
+ """
906
+ skipped: list[str] = []
907
+ home = Path(home or Path.home())
908
+ if strict and (install_dir is None or not isinstance(records, list)):
909
+ return ["invalid registration marker"]
910
+ if strict and not _preflight:
911
+ # Validate every host/config before mutating any of them. In
912
+ # particular, an unavailable later host must not leave earlier records
913
+ # restored while their marker still claims the installation is active.
914
+ unresolved = cleanup_recorded_registrations(
915
+ records, strict=True, install_dir=install_dir, home=home,
916
+ _preflight=True, _transaction=_transaction)
917
+ if unresolved:
918
+ return unresolved
919
+ for record in records or []:
920
+ if strict and not _validate_strict_record(
921
+ record, Path(install_dir), home, transaction=_transaction):
922
+ skipped.append(str(record.get("path", record.get("host", "invalid record"))) if isinstance(record, dict) else "invalid record")
923
+ continue
924
+ state = _record_state(record)
925
+ if record.get("kind") == "file":
926
+ path = Path(record.get("path", ""))
927
+ try:
928
+ _safe_path(path, allow_missing=False)
929
+ # Check identity, content integrity, and mode before parsing
930
+ # any bytes from the config. Besides being cheaper on a
931
+ # tampered path, this keeps untrusted config contents from
932
+ # influencing cleanup decisions until the path is bound to the
933
+ # exact file published by this install.
934
+ if strict:
935
+ if _file_identity(path) != tuple(record["post_identity"]):
936
+ skipped.append(str(path)); continue
937
+ if (os.name != "nt" and "post_mode" in record
938
+ and stat.S_IMODE(path.stat().st_mode) != record["post_mode"]):
939
+ skipped.append(str(path)); continue
940
+ raw = path.read_bytes()
941
+ if strict and _hash_bytes(raw) != record["post_content_hash"]:
942
+ skipped.append(str(path)); continue
943
+ data = json.loads(raw.decode("utf-8"))
944
+ servers = data.get("mcpServers", {})
945
+ if not isinstance(servers, dict):
946
+ skipped.append(str(path)); continue
947
+ expected = {"command": record.get("command"), "args": record.get("args", [])}
948
+ if servers.get("fable-engine") != expected:
949
+ skipped.append(str(path)); continue
950
+ if state is not None:
951
+ _previous, post = state
952
+ # Check every touched name, including an old legacy name
953
+ # that should be absent after install.
954
+ for name in _NAMES:
955
+ current = servers.get(name)
956
+ wanted = post.get(name)
957
+ if (current if name in servers else None) != wanted:
958
+ raise RegistrationError("registration was modified")
959
+ restore = dict(servers)
960
+ for name in _NAMES:
961
+ restore.pop(name, None)
962
+ for name, value in _previous.items():
963
+ restore[name] = value
964
+ data["mcpServers"] = restore
965
+ else:
966
+ # Compatibility with markers written by older releases.
967
+ data["mcpServers"].pop(record.get("name", "fable-engine"), None)
968
+ restored_bytes = (json.dumps(data, indent=2, sort_keys=True) + "\n").encode()
969
+ if _preflight:
970
+ continue
971
+ # A config created by this install has no prior mode to
972
+ # restore. On POSIX, mode 0 is harmless for unlinking; on
973
+ # Windows, chmod(0) sets the read-only attribute and the
974
+ # subsequent unlink fails with access denied. We have
975
+ # already verified the exact post-install inode and bytes
976
+ # above, so remove the installer-owned file directly instead
977
+ # of publishing a transient mode-0 replacement. This also
978
+ # preserves the fail-closed identity/hash checks for the
979
+ # deletion itself.
980
+ if strict and record.get("existed") is False:
981
+ _safe_path(path, allow_missing=False)
982
+ path.unlink()
983
+ continue
984
+ restore_mode = record.get("previous_mode", stat.S_IMODE(path.stat().st_mode))
985
+ if not isinstance(restore_mode, int) or not 0 <= restore_mode <= 0o777:
986
+ skipped.append(str(path)); continue
987
+ _atomic_write(path, restored_bytes, restore_mode)
988
+ except (OSError, ValueError, TypeError, RegistrationError, json.JSONDecodeError):
989
+ skipped.append(str(path))
990
+ elif record.get("kind") == "cli":
991
+ executable = Path(record.get("executable", ""))
992
+ try:
993
+ if strict and _file_identity(executable) != tuple(record["executable_identity"]):
994
+ skipped.append(str(record.get("host", "cli"))); continue
995
+ current = _snapshot_cli_registrations(executable, home=home)
996
+ if current is None:
997
+ skipped.append(str(record.get("host", "cli"))); continue
998
+ expected = {"command": record.get("command"), "args": record.get("args", [])}
999
+ if current.get("fable-engine") != expected:
1000
+ skipped.append(str(record.get("host", "cli"))); continue
1001
+ if state is None:
1002
+ previous = {}
1003
+ else:
1004
+ previous, post = state
1005
+ if any((current.get(name) if name in current else None) != post.get(name) for name in _NAMES):
1006
+ skipped.append(str(record.get("host", "cli"))); continue
1007
+ host = Host(str(record.get("host", "cli")), executable, "cli", True,
1008
+ registration_home=home)
1009
+ if not _preflight:
1010
+ _restore_cli(host, previous)
1011
+ except (OSError, ValueError, TypeError, RegistrationError):
1012
+ skipped.append(str(record.get("host", "cli")))
1013
+ return skipped
1014
+