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.
- fable_compressor.py +356 -0
- fable_engine/__init__.py +1 -0
- fable_engine/actions/__init__.py +291 -0
- fable_engine/actions/cas.py +182 -0
- fable_engine/actions/deliberation.py +523 -0
- fable_engine/actions/fleet.py +807 -0
- fable_engine/actions/lifecycle.py +298 -0
- fable_engine/actions/scrapers.py +116 -0
- fable_engine/actions/system3.py +815 -0
- fable_engine/browser.py +824 -0
- fable_engine/cas.py +974 -0
- fable_engine/fable_session.json +510 -0
- fable_engine/guards.py +283 -0
- fable_engine/schema.py +714 -0
- fable_engine/scrapers/__init__.py +32 -0
- fable_engine/scrapers/arxiv.py +115 -0
- fable_engine/scrapers/base.py +386 -0
- fable_engine/scrapers/github.py +129 -0
- fable_engine/scrapers/reddit.py +154 -0
- fable_engine/scrapers/web.py +120 -0
- fable_engine/scrapers/x.py +125 -0
- fable_engine/scrapers/youtube.py +132 -0
- fable_engine/server.py +414 -0
- fable_engine/session.py +1819 -0
- fable_engine/test_server.py +1362 -0
- fable_engine/updater.py +541 -0
- fable_engine-1.3.1.dist-info/LICENSE +22 -0
- fable_engine-1.3.1.dist-info/METADATA +173 -0
- fable_engine-1.3.1.dist-info/RECORD +104 -0
- fable_engine-1.3.1.dist-info/WHEEL +5 -0
- fable_engine-1.3.1.dist-info/entry_points.txt +5 -0
- fable_engine-1.3.1.dist-info/top_level.txt +6 -0
- fable_mode/__init__.py +3 -0
- fable_mode/__main__.py +4 -0
- fable_mode/adapters.py +1014 -0
- fable_mode/installer.py +553 -0
- fable_mode/launcher.py +437 -0
- fable_mode/manifest.py +142 -0
- fable_mode/resources.json +114 -0
- fable_mode/safety.py +103 -0
- fable_mode_entry.py +10 -0
- fable_v2/__init__.py +146 -0
- fable_v2/adapters.py +151 -0
- fable_v2/coder_fleet/__init__.py +100 -0
- fable_v2/coder_fleet/ast_tools.py +158 -0
- fable_v2/coder_fleet/compute.py +199 -0
- fable_v2/coder_fleet/design_engine.py +1316 -0
- fable_v2/coder_fleet/diagnostics.py +293 -0
- fable_v2/coder_fleet/fleet_dispatcher.py +214 -0
- fable_v2/coder_fleet/mock_auditor.py +306 -0
- fable_v2/coder_fleet/mutation.py +216 -0
- fable_v2/coder_fleet/property_oracle.py +260 -0
- fable_v2/coder_fleet/receipt_attestor.py +122 -0
- fable_v2/coder_fleet/red_team_swarm.py +908 -0
- fable_v2/coder_fleet/test_harness.py +198 -0
- fable_v2/coder_fleet/vector_engine.py +1287 -0
- fable_v2/coder_fleet/visual.py +357 -0
- fable_v2/coder_fleet/workspace.py +153 -0
- fable_v2/cortical/__init__.py +20 -0
- fable_v2/cortical/plasticity_engine.py +992 -0
- fable_v2/execution_broker.py +811 -0
- fable_v2/proof_engine.py +1141 -0
- fable_v2/protocol.py +485 -0
- fable_v2/runtime.py +1010 -0
- fable_v2/system3/__init__.py +204 -0
- fable_v2/system3/causal.py +558 -0
- fable_v2/system3/dialectical.py +577 -0
- fable_v2/system3/evolution.py +503 -0
- fable_v2/system3/executive.py +338 -0
- fable_v2/system3/free_energy.py +479 -0
- fable_v2/system3/hyperbolic.py +555 -0
- fable_v2/system3/induction.py +336 -0
- fable_v2/system3/kripke.py +548 -0
- fable_v2/system3/oracle.py +745 -0
- fable_v2/verifiers.py +72 -0
- tests/__init__.py +1 -0
- tests/test_anti_loop_circuit_breaker.py +64 -0
- tests/test_auto_updater.py +407 -0
- tests/test_coder_fleet.py +535 -0
- tests/test_delegation_compiler.py +54 -0
- tests/test_descriptor_boundaries.py +126 -0
- tests/test_design_engine.py +603 -0
- tests/test_epistemic_evidence_validator.py +66 -0
- tests/test_execution_broker.py +233 -0
- tests/test_fable_v2.py +406 -0
- tests/test_fleet_transitions.py +116 -0
- tests/test_fsm_redteam_evolution.py +406 -0
- tests/test_goal_rubric_and_pipeline.py +367 -0
- tests/test_hebbian_plasticity.py +585 -0
- tests/test_packaging_runtime.py +194 -0
- tests/test_proof_engine.py +259 -0
- tests/test_red_team_swarm.py +645 -0
- tests/test_redteam_remediation.py +169 -0
- tests/test_registration_transaction.py +375 -0
- tests/test_requested_regressions.py +467 -0
- tests/test_scrapers.py +370 -0
- tests/test_server_actions.py +93 -0
- tests/test_server_frontier_actions.py +269 -0
- tests/test_server_protocol.py +88 -0
- tests/test_stealth_browser.py +970 -0
- tests/test_system3.py +381 -0
- tests/test_system3_deep_integration.py +385 -0
- tests/test_system3_frontier.py +436 -0
- 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
|
+
|