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
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import stat
|
|
6
|
+
import sys
|
|
7
|
+
import tempfile
|
|
8
|
+
import unittest
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from unittest.mock import patch
|
|
11
|
+
|
|
12
|
+
from fable_engine.server import FableCASStore, IntegrityError, MAX_RPC_LINE_BYTES, main
|
|
13
|
+
from fable_mode.adapters import Host, RegistrationError, register_hosts
|
|
14
|
+
from fable_mode.installer import InstallError, Installer
|
|
15
|
+
from fable_mode.adapters import _probe_environment, run_argv
|
|
16
|
+
from fable_v2 import BrokerPolicy, ExecutionBroker
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class RedTeamRemediationTests(unittest.TestCase):
|
|
20
|
+
def test_broker_rejects_nonfinite_and_excessive_timeouts(self):
|
|
21
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
22
|
+
broker = ExecutionBroker(BrokerPolicy(Path(tmp), (Path(sys.executable).name,),
|
|
23
|
+
write_token_digest=hashlib.sha256(b"x").hexdigest()))
|
|
24
|
+
broker.unlock_writes("x")
|
|
25
|
+
for value in (float("nan"), float("inf"), -float("inf"), 3601):
|
|
26
|
+
with self.assertRaises(ValueError):
|
|
27
|
+
broker.execute_command([sys.executable, "-c", ""], timeout_seconds=value)
|
|
28
|
+
|
|
29
|
+
def test_cas_bytearray_and_corrupt_existing_object(self):
|
|
30
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
31
|
+
store = FableCASStore(Path(tmp))
|
|
32
|
+
ref = store.put(bytearray(b"bytes"))
|
|
33
|
+
self.assertEqual(store.get_bytes(ref), b"bytes")
|
|
34
|
+
store.get_file_path(ref).write_bytes(b"corrupt")
|
|
35
|
+
with self.assertRaises(IntegrityError):
|
|
36
|
+
store.put(b"bytes")
|
|
37
|
+
|
|
38
|
+
def test_replacement_rejects_tampered_prior_install_tree(self):
|
|
39
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
40
|
+
target = Path(tmp) / "install"
|
|
41
|
+
result = Installer(target).install(); result.transaction.commit()
|
|
42
|
+
(target / "unexpected.txt").write_text("preserve")
|
|
43
|
+
with self.assertRaises(InstallError):
|
|
44
|
+
Installer(target).install()
|
|
45
|
+
self.assertEqual((target / "unexpected.txt").read_text(), "preserve")
|
|
46
|
+
|
|
47
|
+
def test_uninstall_rejects_extra_install_files(self):
|
|
48
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
49
|
+
target = Path(tmp) / "install"
|
|
50
|
+
result = Installer(target).install(); result.transaction.commit()
|
|
51
|
+
(target / "unexpected.txt").write_text("do not absorb")
|
|
52
|
+
with self.assertRaises(InstallError):
|
|
53
|
+
Installer(target).uninstall()
|
|
54
|
+
self.assertTrue(target.exists())
|
|
55
|
+
|
|
56
|
+
def test_uninstall_rejects_tampered_arbitrary_registration_path(self):
|
|
57
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
58
|
+
root = Path(tmp); target = root / "install"; unrelated = root / "unrelated.json"
|
|
59
|
+
unrelated.write_text('{"keep": true}')
|
|
60
|
+
result = Installer(target).install(); result.transaction.commit()
|
|
61
|
+
marker_path = target / ".fable-install.json"
|
|
62
|
+
marker = json.loads(marker_path.read_text())
|
|
63
|
+
marker["registrations"] = [{"kind": "file", "path": str(unrelated),
|
|
64
|
+
"name": "fable-engine", "command": "x", "args": [],
|
|
65
|
+
"previous_entries": {"fable-engine": {"command": "rm", "args": []}},
|
|
66
|
+
"post_entries": {"fable-engine": {"command": "x", "args": []}}}]
|
|
67
|
+
marker_path.write_text(json.dumps(marker))
|
|
68
|
+
with self.assertRaises(InstallError):
|
|
69
|
+
Installer(target).uninstall()
|
|
70
|
+
# Recovery metadata must survive an unresolved/tampered record;
|
|
71
|
+
# deleting the install here would make a stale host registration
|
|
72
|
+
# silently unrecoverable.
|
|
73
|
+
self.assertTrue(target.exists())
|
|
74
|
+
self.assertEqual(unrelated.read_text(), '{"keep": true}')
|
|
75
|
+
|
|
76
|
+
def test_registration_replacement_inode_is_retained_on_rollback(self):
|
|
77
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
78
|
+
root = Path(tmp); home = root / "home"; config = home / ".gemini/config/mcp_config.json"
|
|
79
|
+
config.parent.mkdir(parents=True); config.write_text(json.dumps({"mcpServers": {}}))
|
|
80
|
+
exe = root / "host"; exe.write_text("x"); exe.chmod(0o700)
|
|
81
|
+
replacement = {"mcpServers": {"user": {"command": "keep", "args": []}}}
|
|
82
|
+
def add(host, name, entry):
|
|
83
|
+
replacement_path = root / "replacement.json"
|
|
84
|
+
replacement_path.write_text(json.dumps(replacement))
|
|
85
|
+
replacement_path.replace(config)
|
|
86
|
+
return (9, "", "failed", False)
|
|
87
|
+
with patch("fable_mode.adapters._snapshot_cli_registrations", return_value={}), \
|
|
88
|
+
patch("fable_mode.adapters._cli_remove", return_value=(0, "", "", False)), \
|
|
89
|
+
patch("fable_mode.adapters._cli_add", side_effect=add):
|
|
90
|
+
with self.assertRaises(RegistrationError) as raised:
|
|
91
|
+
register_hosts({"antigravity": Host("antigravity", exe, "cli", True),
|
|
92
|
+
"codex": Host("codex", exe, "cli", True)},
|
|
93
|
+
["runtime", "serve"], home=home)
|
|
94
|
+
self.assertIn("partial state", str(raised.exception))
|
|
95
|
+
self.assertEqual(json.loads(config.read_text()), replacement)
|
|
96
|
+
|
|
97
|
+
def test_cas_slice_apis_reject_tampered_objects_and_bad_cache_types(self):
|
|
98
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
99
|
+
store = FableCASStore(Path(tmp))
|
|
100
|
+
ref = store.put("one\\ntwo\\n")
|
|
101
|
+
path = store.get_file_path(ref)
|
|
102
|
+
path.write_bytes(b"tampered\\n")
|
|
103
|
+
# The slice viewer is reached through the server's canonical store.
|
|
104
|
+
from fable_engine.server import CASSliceViewer
|
|
105
|
+
viewer = CASSliceViewer(store)
|
|
106
|
+
for operation in (
|
|
107
|
+
lambda: store.get_file_path(ref),
|
|
108
|
+
lambda: store.get_bytes(ref, verify=True),
|
|
109
|
+
lambda: viewer.get_line_count(ref),
|
|
110
|
+
):
|
|
111
|
+
with self.assertRaises(IntegrityError):
|
|
112
|
+
operation()
|
|
113
|
+
for operation in (
|
|
114
|
+
lambda: viewer.view_slice(ref, 1, 2),
|
|
115
|
+
lambda: viewer.iter_slice(ref, 1, 2),
|
|
116
|
+
):
|
|
117
|
+
with self.assertRaises(IntegrityError):
|
|
118
|
+
operation()
|
|
119
|
+
clean_ref = store.put("clean")
|
|
120
|
+
store.cache.put(store.normalize_ref(clean_ref), object())
|
|
121
|
+
with self.assertRaises(IntegrityError):
|
|
122
|
+
store.get_bytes(clean_ref)
|
|
123
|
+
|
|
124
|
+
def test_broker_inspection_rejects_hardlinks(self):
|
|
125
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
126
|
+
root = Path(tmp)
|
|
127
|
+
source = root / "source.txt"; source.write_text("secret")
|
|
128
|
+
linked = root / "linked.txt"; os.link(source, linked)
|
|
129
|
+
broker = ExecutionBroker(BrokerPolicy(root, (Path(sys.executable).name,)))
|
|
130
|
+
with self.assertRaises(PermissionError):
|
|
131
|
+
broker.inspect_files("linked.txt")
|
|
132
|
+
|
|
133
|
+
def test_public_probe_home_env_cannot_trigger_recursive_cleanup(self):
|
|
134
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
135
|
+
victim = Path(tmp) / "victim"; victim.mkdir(); (victim / "keep").write_text("x")
|
|
136
|
+
run_argv([sys.executable, "-c", ""], env={"_FABLE_PROBE_HOME": str(victim)})
|
|
137
|
+
self.assertTrue((victim / "keep").exists())
|
|
138
|
+
|
|
139
|
+
@unittest.skipIf(os.name == "nt", "POSIX shebang validation is not a Windows execution model")
|
|
140
|
+
def test_probe_rejects_untrusted_absolute_shebang(self):
|
|
141
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
142
|
+
script = Path(tmp) / "host"
|
|
143
|
+
script.write_text("#!/bin/sh\\nexit 0\\n")
|
|
144
|
+
script.chmod(0o700)
|
|
145
|
+
# A regular executable outside the trusted interpreter set cannot
|
|
146
|
+
# be used as a shebang interpreter during discovery.
|
|
147
|
+
evil = Path(tmp) / "evil"
|
|
148
|
+
evil.write_text("#!/bin/sh\\nexit 0\\n"); evil.chmod(0o700)
|
|
149
|
+
script.write_text(f"#!{evil}\\nexit 0\\n")
|
|
150
|
+
with self.assertRaises(RegistrationError):
|
|
151
|
+
_probe_environment(script)
|
|
152
|
+
|
|
153
|
+
def test_rpc_reader_does_not_use_readline_and_preserves_next_frame(self):
|
|
154
|
+
class NoReadline(io.StringIO):
|
|
155
|
+
def readline(self, *args, **kwargs):
|
|
156
|
+
raise AssertionError("unbounded readline used")
|
|
157
|
+
stream = NoReadline(" " * (MAX_RPC_LINE_BYTES + 1) + "\n" +
|
|
158
|
+
json.dumps({"jsonrpc": "2.0", "id": 2, "method": "ping", "params": {}}) + "\n")
|
|
159
|
+
output = io.StringIO()
|
|
160
|
+
with patch("sys.stdin", stream), patch("sys.stdout", output):
|
|
161
|
+
main()
|
|
162
|
+
responses = [json.loads(line) for line in output.getvalue().splitlines() if line]
|
|
163
|
+
self.assertEqual(responses[0]["error"]["code"], -32600)
|
|
164
|
+
self.assertEqual(responses[1]["id"], 2)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
unittest.main()
|
|
169
|
+
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import stat
|
|
4
|
+
import sys
|
|
5
|
+
import tempfile
|
|
6
|
+
import textwrap
|
|
7
|
+
import unittest
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from unittest.mock import patch
|
|
10
|
+
|
|
11
|
+
from fable_mode.adapters import Host, RegistrationError, cleanup_recorded_registrations, register_hosts
|
|
12
|
+
from fable_mode.installer import InstallError, Installer
|
|
13
|
+
from fable_mode import launcher
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class RegistrationTransactionTests(unittest.TestCase):
|
|
17
|
+
def setUp(self):
|
|
18
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
19
|
+
self.root = Path(self.tmp.name)
|
|
20
|
+
|
|
21
|
+
def tearDown(self):
|
|
22
|
+
self.tmp.cleanup()
|
|
23
|
+
|
|
24
|
+
def _host(self, name, *, human=False, fail_add=False):
|
|
25
|
+
script = self.root / name
|
|
26
|
+
state = self.root / f"{name}.json"
|
|
27
|
+
state.write_text(json.dumps({"fable-engine": {"command": "old", "args": ["--old"]},
|
|
28
|
+
"fable-mode": {"command": "legacy", "args": ["serve"]}}))
|
|
29
|
+
code = """
|
|
30
|
+
import json, pathlib, sys
|
|
31
|
+
p = pathlib.Path(__file__).with_suffix('.json')
|
|
32
|
+
s = json.loads(p.read_text())
|
|
33
|
+
if sys.argv[1:2] == ['--version']:
|
|
34
|
+
print('fake'); raise SystemExit(0)
|
|
35
|
+
if sys.argv[1:3] == ['mcp', 'list']:
|
|
36
|
+
HUMAN = HUMAN
|
|
37
|
+
if HUMAN: print('Configured MCP servers: fable-engine'); raise SystemExit(0)
|
|
38
|
+
print(json.dumps(s)); raise SystemExit(0)
|
|
39
|
+
if sys.argv[1:3] == ['mcp', 'remove']:
|
|
40
|
+
name = sys.argv[3]
|
|
41
|
+
if name not in s:
|
|
42
|
+
print('No MCP server named ' + name, file=sys.stderr); raise SystemExit(2)
|
|
43
|
+
del s[name]; p.write_text(json.dumps(s)); raise SystemExit(0)
|
|
44
|
+
if sys.argv[1:3] == ['mcp', 'add']:
|
|
45
|
+
marker = sys.argv.index('--')
|
|
46
|
+
if FAIL_ADD and sys.argv[marker + 1] == 'new-runtime': raise SystemExit(9)
|
|
47
|
+
name = sys.argv[3]
|
|
48
|
+
s[name] = {'command': sys.argv[marker + 1], 'args': sys.argv[marker + 2:]}
|
|
49
|
+
p.write_text(json.dumps(s)); raise SystemExit(0)
|
|
50
|
+
raise SystemExit(4)
|
|
51
|
+
"""
|
|
52
|
+
script_body = "#!/usr/bin/env python3\n" + "HUMAN = " + repr(human) + "\nFAIL_ADD = " + repr(fail_add) + "\n" + textwrap.dedent(code)
|
|
53
|
+
if os.name == "nt":
|
|
54
|
+
py_script = script.with_suffix(".py")
|
|
55
|
+
py_script.write_text(script_body)
|
|
56
|
+
script = script.with_suffix(".cmd")
|
|
57
|
+
script.write_text(f'@echo off\n"{os.fspath(Path(sys.executable))}" "{os.fspath(py_script)}" %*\n')
|
|
58
|
+
else:
|
|
59
|
+
script.write_text(script_body)
|
|
60
|
+
script.chmod(stat.S_IRWXU)
|
|
61
|
+
return Host(name, script, "cli", True), state
|
|
62
|
+
|
|
63
|
+
def test_existing_canonical_and_legacy_are_restored_on_uninstall(self):
|
|
64
|
+
host, state = self._host("host")
|
|
65
|
+
before = state.read_bytes()
|
|
66
|
+
records = []
|
|
67
|
+
register_hosts({"codex": host}, ["new-runtime", "serve"], records=records)
|
|
68
|
+
installed = json.loads(state.read_text())
|
|
69
|
+
self.assertEqual(installed["fable-engine"], {"command": "new-runtime", "args": ["serve"]})
|
|
70
|
+
self.assertNotIn("fable-mode", installed)
|
|
71
|
+
self.assertEqual(cleanup_recorded_registrations(records), [])
|
|
72
|
+
self.assertEqual(state.read_bytes(), before)
|
|
73
|
+
|
|
74
|
+
def test_later_host_failure_rolls_back_earlier_host(self):
|
|
75
|
+
first, first_state = self._host("first")
|
|
76
|
+
second, second_state = self._host("second", fail_add=True)
|
|
77
|
+
before = (first_state.read_bytes(), second_state.read_bytes())
|
|
78
|
+
with self.assertRaises(RegistrationError):
|
|
79
|
+
register_hosts({"codex": first, "claude": second}, ["new-runtime", "serve"])
|
|
80
|
+
self.assertEqual((first_state.read_bytes(), second_state.read_bytes()), before)
|
|
81
|
+
|
|
82
|
+
def test_human_readable_list_fails_before_mutation(self):
|
|
83
|
+
host, state = self._host("human", human=True)
|
|
84
|
+
before = state.read_bytes()
|
|
85
|
+
with self.assertRaises(RegistrationError):
|
|
86
|
+
register_hosts({"codex": host}, ["new-runtime", "serve"])
|
|
87
|
+
self.assertEqual(state.read_bytes(), before)
|
|
88
|
+
|
|
89
|
+
def test_absent_remove_output_is_benign(self):
|
|
90
|
+
host, state = self._host("empty")
|
|
91
|
+
state.write_text("{}")
|
|
92
|
+
register_hosts({"codex": host}, ["new-runtime", "serve"])
|
|
93
|
+
self.assertEqual(json.loads(state.read_text())["fable-engine"]["args"], ["serve"])
|
|
94
|
+
|
|
95
|
+
def test_reinstall_preserves_first_user_baseline_and_uninstall_cleans_new_runtime(self):
|
|
96
|
+
"""A retired Fable entry must not become user-owned on replacement."""
|
|
97
|
+
host, state = self._host("codex")
|
|
98
|
+
home = self.root
|
|
99
|
+
target = self.root / "install"
|
|
100
|
+
|
|
101
|
+
def install_and_register(installer, owned=None):
|
|
102
|
+
result = installer.install()
|
|
103
|
+
records = []
|
|
104
|
+
if owned is None:
|
|
105
|
+
owned = installer.previous_registrations
|
|
106
|
+
register_hosts({"codex": host}, result.executable_argv, home=home,
|
|
107
|
+
records=records, owned_records=owned)
|
|
108
|
+
install_st = result.install_dir.lstat()
|
|
109
|
+
for record in records:
|
|
110
|
+
record["install_dir"] = str(result.install_dir)
|
|
111
|
+
record["install_identity"] = [install_st.st_dev, install_st.st_ino]
|
|
112
|
+
installer.record_registrations(records)
|
|
113
|
+
result.transaction.commit()
|
|
114
|
+
return records
|
|
115
|
+
|
|
116
|
+
original = state.read_bytes()
|
|
117
|
+
first = Installer(target)
|
|
118
|
+
install_and_register(first)
|
|
119
|
+
# Replace without registration: ownership records must be migrated.
|
|
120
|
+
second = Installer(target)
|
|
121
|
+
replacement = second.install()
|
|
122
|
+
replacement.transaction.commit()
|
|
123
|
+
third = Installer(target)
|
|
124
|
+
records = install_and_register(third)
|
|
125
|
+
self.assertEqual(records[0]["previous_entries"], {
|
|
126
|
+
"fable-engine": {"command": "old", "args": ["--old"]},
|
|
127
|
+
"fable-mode": {"command": "legacy", "args": ["serve"]},
|
|
128
|
+
})
|
|
129
|
+
with patch("pathlib.Path.home", return_value=home):
|
|
130
|
+
third.uninstall()
|
|
131
|
+
self.assertEqual(state.read_bytes(), original)
|
|
132
|
+
|
|
133
|
+
def test_file_registration_is_private_and_reinstall_restores_mode_and_state(self):
|
|
134
|
+
home = self.root / "home"
|
|
135
|
+
config = home / ".gemini" / "config" / "mcp_config.json"
|
|
136
|
+
config.parent.mkdir(parents=True)
|
|
137
|
+
original = {"keep": {"x": 1}, "mcpServers": {
|
|
138
|
+
"fable-engine": {"command": "user", "args": ["u"], "cwd": "/user/work"},
|
|
139
|
+
"fable-mode": {"command": "legacy", "args": ["l"], "env": {"PROFILE": "user"}},
|
|
140
|
+
}}
|
|
141
|
+
config.write_text(json.dumps(original))
|
|
142
|
+
config.chmod(0o644)
|
|
143
|
+
exe = self.root / "agy"
|
|
144
|
+
exe.write_text("")
|
|
145
|
+
exe.chmod(0o700)
|
|
146
|
+
host = Host("agy", exe, "cli", True)
|
|
147
|
+
first = []
|
|
148
|
+
register_hosts({"agy": host}, ["runtime-v1", "serve"], home=home, records=first)
|
|
149
|
+
if os.name != "nt":
|
|
150
|
+
self.assertEqual(stat.S_IMODE(config.stat().st_mode), 0o600)
|
|
151
|
+
second = []
|
|
152
|
+
register_hosts({"agy": host}, ["runtime-v2", "serve"], home=home,
|
|
153
|
+
records=second, owned_records=first)
|
|
154
|
+
self.assertEqual(second[0]["previous_entries"], original["mcpServers"])
|
|
155
|
+
if os.name != "nt":
|
|
156
|
+
self.assertEqual(second[0]["previous_mode"], 0o644)
|
|
157
|
+
if os.name != "nt":
|
|
158
|
+
self.assertEqual(stat.S_IMODE(config.stat().st_mode), 0o600)
|
|
159
|
+
self.assertEqual(cleanup_recorded_registrations(second, home=home), [])
|
|
160
|
+
self.assertEqual(json.loads(config.read_text()), original)
|
|
161
|
+
if os.name != "nt":
|
|
162
|
+
self.assertEqual(stat.S_IMODE(config.stat().st_mode), 0o644)
|
|
163
|
+
|
|
164
|
+
def test_windows_created_antigravity_config_is_unlinked_without_mode_zero(self):
|
|
165
|
+
"""A frozen Windows install must remove an initially absent config.
|
|
166
|
+
|
|
167
|
+
``previous_mode`` is zero when the Antigravity config did not exist.
|
|
168
|
+
Restoring that mode before unlinking maps to Windows' read-only
|
|
169
|
+
attribute and makes cleanup fail, even though the post-install file is
|
|
170
|
+
still the exact inode and byte sequence Fable published.
|
|
171
|
+
"""
|
|
172
|
+
home = self.root / "home"
|
|
173
|
+
exe = self.root / "agy"
|
|
174
|
+
exe.write_text("")
|
|
175
|
+
exe.chmod(0o700)
|
|
176
|
+
target = self.root / "install"
|
|
177
|
+
target.mkdir()
|
|
178
|
+
host = Host("agy", exe, "cli", True)
|
|
179
|
+
records = []
|
|
180
|
+
register_hosts({"agy": host}, [str(target / "fable-mode.exe"), "serve"],
|
|
181
|
+
home=home, records=records)
|
|
182
|
+
install_st = target.lstat()
|
|
183
|
+
for record in records:
|
|
184
|
+
record["install_dir"] = str(target)
|
|
185
|
+
record["install_identity"] = [install_st.st_dev, install_st.st_ino]
|
|
186
|
+
self.assertFalse(records[0]["existed"])
|
|
187
|
+
self.assertEqual(records[0]["previous_mode"], 0)
|
|
188
|
+
with patch("fable_mode.adapters._atomic_write",
|
|
189
|
+
side_effect=AssertionError("must not restore mode 0")):
|
|
190
|
+
self.assertEqual(cleanup_recorded_registrations(
|
|
191
|
+
records, strict=True, install_dir=target, home=home), [])
|
|
192
|
+
self.assertFalse(home.joinpath(".gemini", "config", "mcp_config.json").exists())
|
|
193
|
+
|
|
194
|
+
def test_cli_mutation_environment_does_not_forward_secrets(self):
|
|
195
|
+
"""Automatic host mutations receive only the documented safe env."""
|
|
196
|
+
script = self.root / "observing-codex"
|
|
197
|
+
state = self.root / "observing-codex.json"
|
|
198
|
+
observed = self.root / "observed-env.json"
|
|
199
|
+
state.write_text("{}")
|
|
200
|
+
script_body = "#!/usr/bin/env python3\n" + textwrap.dedent(f"""
|
|
201
|
+
import json, os, pathlib, sys
|
|
202
|
+
pathlib.Path({str(observed)!r}).write_text(json.dumps({{
|
|
203
|
+
"secret": os.environ.get("FABLE_TEST_SECRET"),
|
|
204
|
+
"path": os.environ.get("PATH"),
|
|
205
|
+
"home": os.environ.get("HOME"),
|
|
206
|
+
"xdg": os.environ.get("XDG_CONFIG_HOME"),
|
|
207
|
+
}}))
|
|
208
|
+
p = pathlib.Path({str(state)!r})
|
|
209
|
+
s = json.loads(p.read_text())
|
|
210
|
+
if sys.argv[1:3] == ["mcp", "list"]:
|
|
211
|
+
print(json.dumps(s)); raise SystemExit(0)
|
|
212
|
+
if sys.argv[1:3] == ["mcp", "remove"]:
|
|
213
|
+
s.pop(sys.argv[3], None); p.write_text(json.dumps(s)); raise SystemExit(0)
|
|
214
|
+
if sys.argv[1:3] == ["mcp", "add"]:
|
|
215
|
+
marker = sys.argv.index("--")
|
|
216
|
+
s[sys.argv[3]] = {{"command": sys.argv[marker + 1], "args": sys.argv[marker + 2:]}}
|
|
217
|
+
p.write_text(json.dumps(s)); raise SystemExit(0)
|
|
218
|
+
raise SystemExit(0)
|
|
219
|
+
""")
|
|
220
|
+
if os.name == "nt":
|
|
221
|
+
py_script = script.with_suffix(".py")
|
|
222
|
+
py_script.write_text(script_body)
|
|
223
|
+
script = script.with_suffix(".cmd")
|
|
224
|
+
script.write_text(f'@echo off\n"{os.fspath(Path(sys.executable))}" "{os.fspath(py_script)}" %*\n')
|
|
225
|
+
else:
|
|
226
|
+
script.write_text(script_body)
|
|
227
|
+
script.chmod(stat.S_IRWXU)
|
|
228
|
+
with patch.dict(os.environ, {"FABLE_TEST_SECRET": "must-not-leak"}):
|
|
229
|
+
register_hosts({"codex": Host("codex", script, "cli", True)},
|
|
230
|
+
["new-runtime", "serve"])
|
|
231
|
+
env_seen = json.loads(observed.read_text())
|
|
232
|
+
self.assertIsNone(env_seen["secret"])
|
|
233
|
+
self.assertTrue(env_seen["path"])
|
|
234
|
+
self.assertTrue(env_seen["home"])
|
|
235
|
+
self.assertTrue(env_seen["xdg"])
|
|
236
|
+
|
|
237
|
+
def test_launcher_cleans_hosts_before_rollback_when_marker_persistence_fails(self):
|
|
238
|
+
host, state = self._host("marker-failure")
|
|
239
|
+
original = state.read_bytes()
|
|
240
|
+
target = self.root / "install-marker-failure"
|
|
241
|
+
with patch("fable_mode.launcher.detect_hosts", return_value={"codex": host}), \
|
|
242
|
+
patch.object(Installer, "record_registrations", side_effect=RuntimeError("marker write failed")):
|
|
243
|
+
self.assertEqual(launcher.main(["install", "--yes", "--register-hosts",
|
|
244
|
+
"--install-dir", str(target)]), 1)
|
|
245
|
+
self.assertEqual(state.read_bytes(), original)
|
|
246
|
+
self.assertFalse(target.exists())
|
|
247
|
+
|
|
248
|
+
def test_failed_marker_write_and_cleanup_preserve_recoverable_record(self):
|
|
249
|
+
"""A failed first marker write must not strand a live host entry."""
|
|
250
|
+
host, state = self._host("codex")
|
|
251
|
+
original = state.read_bytes()
|
|
252
|
+
target = self.root / "install-recovery-record"
|
|
253
|
+
calls = {"count": 0}
|
|
254
|
+
real_record_registrations = Installer.record_registrations
|
|
255
|
+
|
|
256
|
+
def fail_once(installer, records):
|
|
257
|
+
calls["count"] += 1
|
|
258
|
+
if calls["count"] == 1:
|
|
259
|
+
raise RuntimeError("marker write failed")
|
|
260
|
+
return real_record_registrations(installer, records)
|
|
261
|
+
|
|
262
|
+
with patch("fable_mode.launcher.detect_hosts", return_value={"codex": host}), \
|
|
263
|
+
patch("fable_mode.launcher.cleanup_recorded_registrations", return_value=["codex"]), \
|
|
264
|
+
patch.object(Installer, "record_registrations", new=fail_once):
|
|
265
|
+
self.assertEqual(launcher.main(["install", "--yes", "--register-hosts",
|
|
266
|
+
"--install-dir", str(target)]), 1)
|
|
267
|
+
|
|
268
|
+
marker = json.loads((target / ".fable-install.json").read_text())
|
|
269
|
+
self.assertEqual(len(marker["registrations"]), 1)
|
|
270
|
+
self.assertEqual(marker["registrations"][0]["host"], "codex")
|
|
271
|
+
self.assertEqual(json.loads(state.read_text())["fable-engine"],
|
|
272
|
+
{"command": sys.executable,
|
|
273
|
+
"args": [str(target / "runtime" / "fable_mode_entry.py"), "serve"]})
|
|
274
|
+
|
|
275
|
+
# A later process can use the persisted record to restore the host and
|
|
276
|
+
# remove the preserved installation without the original transaction.
|
|
277
|
+
with patch("pathlib.Path.home", return_value=Path.home()):
|
|
278
|
+
Installer(target).uninstall()
|
|
279
|
+
self.assertEqual(state.read_bytes(), original)
|
|
280
|
+
self.assertFalse(target.exists())
|
|
281
|
+
|
|
282
|
+
def test_commit_failure_leaves_replacement_transaction_rollbackable(self):
|
|
283
|
+
target = self.root / "commit-rollbackable"
|
|
284
|
+
first = Installer(target).install()
|
|
285
|
+
first.transaction.commit()
|
|
286
|
+
replacement = Installer(target).install()
|
|
287
|
+
with patch.object(replacement.transaction, "_verify_backup",
|
|
288
|
+
side_effect=InstallError("backup check failed")):
|
|
289
|
+
with self.assertRaises(InstallError):
|
|
290
|
+
replacement.transaction.commit()
|
|
291
|
+
self.assertFalse(replacement.transaction.done)
|
|
292
|
+
replacement.transaction.rollback()
|
|
293
|
+
self.assertTrue(target.exists())
|
|
294
|
+
|
|
295
|
+
def test_launcher_cleans_hosts_before_rollback_when_commit_fails(self):
|
|
296
|
+
host, state = self._host("commit-failure")
|
|
297
|
+
original = state.read_bytes()
|
|
298
|
+
target = self.root / "install-commit-failure"
|
|
299
|
+
old = Installer(target).install()
|
|
300
|
+
old.transaction.commit()
|
|
301
|
+
with patch("fable_mode.launcher.detect_hosts", return_value={"codex": host}), \
|
|
302
|
+
patch("fable_mode.installer.InstallTransaction._verify_backup",
|
|
303
|
+
side_effect=[InstallError("commit failed"), None]):
|
|
304
|
+
self.assertEqual(launcher.main(["install", "--yes", "--register-hosts",
|
|
305
|
+
"--install-dir", str(target)]), 1)
|
|
306
|
+
self.assertEqual(state.read_bytes(), original)
|
|
307
|
+
self.assertTrue(target.exists())
|
|
308
|
+
self.assertEqual(json.loads((target / ".fable-install.json").read_text())["registrations"], [])
|
|
309
|
+
|
|
310
|
+
def test_uninstall_preserves_marker_when_host_executable_disappears(self):
|
|
311
|
+
host, state = self._host("codex")
|
|
312
|
+
target = self.root / "install-vanished-host"
|
|
313
|
+
installer = Installer(target)
|
|
314
|
+
result = installer.install()
|
|
315
|
+
records = []
|
|
316
|
+
register_hosts({"codex": host}, result.executable_argv, records=records)
|
|
317
|
+
install_st = target.lstat()
|
|
318
|
+
for record in records:
|
|
319
|
+
record["install_dir"] = str(target)
|
|
320
|
+
record["install_identity"] = [install_st.st_dev, install_st.st_ino]
|
|
321
|
+
installer.record_registrations(records)
|
|
322
|
+
result.transaction.commit()
|
|
323
|
+
host.executable.unlink()
|
|
324
|
+
with self.assertRaises(InstallError):
|
|
325
|
+
Installer(target).uninstall()
|
|
326
|
+
self.assertTrue(target.exists())
|
|
327
|
+
self.assertEqual(json.loads((target / ".fable-install.json").read_text())["registrations"], records)
|
|
328
|
+
|
|
329
|
+
def test_cli_mutation_uses_real_home_state_and_persists(self):
|
|
330
|
+
"""Discovery may be sandboxed, but add/remove must use the target HOME."""
|
|
331
|
+
home = self.root / "home"
|
|
332
|
+
home.mkdir()
|
|
333
|
+
script = self.root / "home-only-codex"
|
|
334
|
+
state = home / "codex-mcp.json"
|
|
335
|
+
state.write_text(json.dumps({"user": {"command": "keep", "args": []}}))
|
|
336
|
+
script_body = "#!/usr/bin/env python3\n" + textwrap.dedent("""
|
|
337
|
+
import json, os, pathlib, sys
|
|
338
|
+
p = pathlib.Path(os.environ["HOME"]) / "codex-mcp.json"
|
|
339
|
+
s = json.loads(p.read_text())
|
|
340
|
+
if sys.argv[1:2] == ["--version"]:
|
|
341
|
+
print("fake"); raise SystemExit(0)
|
|
342
|
+
if sys.argv[1:3] == ["mcp", "list"]:
|
|
343
|
+
print(json.dumps(s)); raise SystemExit(0)
|
|
344
|
+
if sys.argv[1:3] == ["mcp", "remove"]:
|
|
345
|
+
name = sys.argv[3]
|
|
346
|
+
if name not in s:
|
|
347
|
+
print("No MCP server named " + name, file=sys.stderr); raise SystemExit(2)
|
|
348
|
+
del s[name]; p.write_text(json.dumps(s)); raise SystemExit(0)
|
|
349
|
+
if sys.argv[1:3] == ["mcp", "add"]:
|
|
350
|
+
marker = sys.argv.index("--"); name = sys.argv[3]
|
|
351
|
+
s[name] = {"command": sys.argv[marker + 1], "args": sys.argv[marker + 2:]}
|
|
352
|
+
p.write_text(json.dumps(s)); raise SystemExit(0)
|
|
353
|
+
raise SystemExit(4)
|
|
354
|
+
""")
|
|
355
|
+
if os.name == "nt":
|
|
356
|
+
py_script = script.with_suffix(".py")
|
|
357
|
+
py_script.write_text(script_body)
|
|
358
|
+
script = script.with_suffix(".cmd")
|
|
359
|
+
script.write_text(f'@echo off\n"{os.fspath(Path(sys.executable))}" "{os.fspath(py_script)}" %*\n')
|
|
360
|
+
else:
|
|
361
|
+
script.write_text(script_body)
|
|
362
|
+
script.chmod(stat.S_IRWXU)
|
|
363
|
+
records = []
|
|
364
|
+
register_hosts({"codex": Host("codex", script, "cli", True)},
|
|
365
|
+
["new-runtime", "serve"], home=home, records=records)
|
|
366
|
+
changed = json.loads(state.read_text())
|
|
367
|
+
self.assertEqual(changed["fable-engine"], {"command": "new-runtime", "args": ["serve"]})
|
|
368
|
+
self.assertIn("user", changed)
|
|
369
|
+
self.assertEqual(cleanup_recorded_registrations(records, home=home), [])
|
|
370
|
+
self.assertEqual(json.loads(state.read_text()), {"user": {"command": "keep", "args": []}})
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
if __name__ == "__main__":
|
|
374
|
+
unittest.main()
|
|
375
|
+
|