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
@@ -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
+