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,553 @@
1
+ """Transactional, allowlisted installation for source and frozen Fable runtimes."""
2
+ from __future__ import annotations
3
+
4
+ import hashlib
5
+ import json
6
+ import os
7
+ import shutil
8
+ import stat
9
+ import sys
10
+ import tempfile
11
+ import uuid
12
+ from importlib import metadata, resources
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+
16
+ from .manifest import ALLOWED_FILES, allowed_source_paths, source_root, validate_manifest
17
+ from .safety import mark_owned_directory, safe_cleanup
18
+
19
+ PRODUCT = "fable-mode"
20
+ MARKER = ".fable-install.json"
21
+
22
+
23
+ class InstallError(RuntimeError):
24
+ pass
25
+
26
+
27
+ def _reject_path(path: Path, *, allow_missing: bool = True) -> None:
28
+ """Reject links, reparse points, special files, and hardlinked leaf files."""
29
+ target = Path(path)
30
+ cur = target
31
+ parts = []
32
+ while True:
33
+ parts.append(cur)
34
+ if cur.parent == cur:
35
+ break
36
+ cur = cur.parent
37
+ for part in reversed(parts):
38
+ try:
39
+ st = part.lstat()
40
+ except FileNotFoundError:
41
+ if allow_missing:
42
+ continue
43
+ raise InstallError(f"missing path: {part}")
44
+ reparse = bool(getattr(st, "st_file_attributes", 0) & 0x400)
45
+ junction = bool(getattr(part, "is_junction", lambda: False)())
46
+ trusted_macos_alias = (
47
+ sys.platform == "darwin" and str(part) in {"/var", "/tmp"}
48
+ and str(part.resolve()) in {"/private/var", "/private/tmp"}
49
+ )
50
+ 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):
51
+ raise InstallError(f"unsafe path component: {part}")
52
+ if part == target and stat.S_ISREG(st.st_mode) and st.st_nlink != 1:
53
+ raise InstallError(f"hardlinked path: {part}")
54
+
55
+
56
+ def _regular_file(path: Path) -> None:
57
+ _reject_path(path, allow_missing=False)
58
+ st = path.lstat()
59
+ if not stat.S_ISREG(st.st_mode) or st.st_nlink != 1:
60
+ raise InstallError(f"not a private regular file: {path}")
61
+
62
+
63
+ def _hash(path: Path) -> str:
64
+ digest = hashlib.sha256()
65
+ with path.open("rb") as handle:
66
+ for chunk in iter(lambda: handle.read(1024 * 1024), b""):
67
+ digest.update(chunk)
68
+ return digest.hexdigest()
69
+
70
+
71
+ def _capability(path: Path) -> tuple[int, int, int, int]:
72
+ """Capture inode plus ownership for a path that may be renamed later."""
73
+ st = path.lstat()
74
+ return (st.st_dev, st.st_ino, st.st_uid, st.st_gid)
75
+
76
+
77
+ def _matches_capability(path: Path, expected: tuple[int, ...], *, directory: bool = True) -> bool:
78
+ try:
79
+ st = path.lstat()
80
+ except OSError:
81
+ return False
82
+ if directory and not stat.S_ISDIR(st.st_mode):
83
+ return False
84
+ return ((st.st_dev, st.st_ino) == tuple(expected[:2])
85
+ and (len(expected) < 4 or (st.st_uid, st.st_gid) == tuple(expected[2:4])))
86
+
87
+
88
+ # Documentation, rules, and skills are intentionally optional in a wheel, but all
89
+ # executable runtime resources and the license are mandatory in source-mode installs.
90
+ _OPTIONAL_SOURCE_FILES = {
91
+ rel for rel in ALLOWED_FILES
92
+ if rel.startswith("rules/") or rel.startswith("docs/") or rel.startswith("skills/") or rel == "run_tests.py"
93
+ }
94
+ _REQUIRED_SOURCE_FILES = tuple(rel for rel in ALLOWED_FILES if rel not in _OPTIONAL_SOURCE_FILES)
95
+
96
+
97
+ def _license_path() -> Path:
98
+ """Locate LICENSE in a checkout, wheel package data, or dist-info.
99
+
100
+ Wheels do not have a repository parent, and setuptools commonly stores the
101
+ license under ``*.dist-info/licenses/LICENSE``. Keep this lookup resource
102
+ based and never derive a path from the process cwd.
103
+ """
104
+ try:
105
+ candidate = resources.files("fable_mode").joinpath("LICENSE")
106
+ if candidate.is_file():
107
+ return Path(candidate)
108
+ except (FileNotFoundError, OSError, TypeError):
109
+ pass
110
+ try:
111
+ dist = metadata.distribution("fable-engine")
112
+ for item in dist.files or ():
113
+ if str(item).replace("\\", "/").lower().endswith("/license") or str(item).lower() == "license":
114
+ candidate = Path(dist.locate_file(item))
115
+ if candidate.is_file():
116
+ return candidate
117
+ except (metadata.PackageNotFoundError, OSError):
118
+ pass
119
+ raise InstallError("packaged LICENSE resource is unavailable")
120
+
121
+
122
+ def _source_path(root: Path, rel: str) -> Path:
123
+ if rel == "LICENSE":
124
+ checkout = root / rel
125
+ if checkout.is_file():
126
+ return checkout
127
+ return _license_path()
128
+ return root / rel
129
+
130
+
131
+ @dataclass
132
+ class InstallResult:
133
+ install_dir: Path
134
+ executable_argv: list[str]
135
+ mode: str
136
+ transaction: "InstallTransaction | None" = None
137
+
138
+
139
+ class InstallTransaction:
140
+ def __init__(self, install_dir: Path, backup_dir: Path | None,
141
+ install_identity: tuple[int, ...] | None = None,
142
+ backup_identity: tuple[int, ...] | None = None):
143
+ self.install_dir = install_dir
144
+ self.backup_dir = backup_dir
145
+ self.install_identity = install_identity
146
+ self.backup_identity = backup_identity
147
+ self.done = False
148
+
149
+ def _verify_backup(self) -> None:
150
+ if self.backup_dir is None or self.backup_identity is None:
151
+ return
152
+ if not _matches_capability(self.backup_dir, self.backup_identity):
153
+ raise InstallError("previous installation backup was replaced; preserved (partial state)")
154
+
155
+ def commit(self) -> None:
156
+ if self.done:
157
+ return
158
+ if self.backup_dir and self.backup_identity is not None:
159
+ try:
160
+ # Never trust a predictable backup pathname: bind cleanup to
161
+ # the inode and owner captured when the old install moved.
162
+ self._verify_backup()
163
+ safe_cleanup(self.backup_dir, expected_identity=self.backup_identity)
164
+ except (OSError, ValueError, InstallError) as exc:
165
+ # Keep the transaction rollbackable. The newly published
166
+ # install may already be referenced by host registrations, so
167
+ # callers must restore those registrations before removing it.
168
+ # Marking this transaction done here would strand both the new
169
+ # runtime and the old backup on a commit failure.
170
+ raise InstallError("previous installation backup is unsafe; preserved") from exc
171
+ self.done = True
172
+
173
+ def rollback(self) -> None:
174
+ if self.done:
175
+ return
176
+ # If the published replacement was itself replaced, do not delete it.
177
+ if (self.install_dir.exists() or self.install_dir.is_symlink()) and not _matches_capability(
178
+ self.install_dir, self.install_identity or (), directory=True):
179
+ self.done = True
180
+ raise InstallError("rollback refused: replacement install path changed; partial state preserved")
181
+ try:
182
+ self._verify_backup()
183
+ except InstallError:
184
+ self.done = True
185
+ raise
186
+ if self.install_identity is not None and self.install_dir.exists():
187
+ try:
188
+ safe_cleanup(self.install_dir, expected_identity=self.install_identity)
189
+ except (OSError, ValueError) as exc:
190
+ self.done = True
191
+ raise InstallError("rollback could not remove published install; partial state preserved") from exc
192
+ if self.backup_dir and self.backup_identity is not None:
193
+ # Revalidate both path capabilities immediately before publication.
194
+ if not _matches_capability(self.backup_dir, self.backup_identity) or self.install_dir.exists():
195
+ self.done = True
196
+ raise InstallError("rollback refused changed backup/install path; partial state preserved")
197
+ try:
198
+ os.replace(self.backup_dir, self.install_dir)
199
+ except OSError as exc:
200
+ self.done = True
201
+ raise InstallError("rollback failed; partial state preserved") from exc
202
+ self.done = True
203
+
204
+
205
+ class Installer:
206
+ def __init__(self, install_dir: Path | None = None, *, source: Path | None = None):
207
+ default = (Path(os.environ["LOCALAPPDATA"]) / "Programs" / "FableMode" if os.name == "nt" and os.environ.get("LOCALAPPDATA")
208
+ else Path.home() / ".local" / "share" / "fable-mode")
209
+ self.install_dir = Path(install_dir or os.environ.get("FABLE_INSTALL_DIR", default)).expanduser().absolute()
210
+ self.source = Path(source or source_root()).absolute()
211
+ self.previous_registrations: list[dict] = []
212
+
213
+ def _assert_install_target(self) -> tuple[int, int, int, int] | None:
214
+ _reject_path(self.install_dir.parent)
215
+ if not self.install_dir.exists() and not self.install_dir.is_symlink():
216
+ return None
217
+ if self.install_dir.is_symlink() or not self.install_dir.is_dir():
218
+ raise InstallError("installation target is not a directory")
219
+ initial_cap = _capability(self.install_dir)
220
+ marker = self.install_dir / MARKER
221
+ _regular_file(marker)
222
+ try:
223
+ data = json.loads(marker.read_text(encoding="utf-8"))
224
+ except Exception as exc:
225
+ raise InstallError("installation marker is invalid") from exc
226
+ if (not isinstance(data, dict) or data.get("product") != PRODUCT
227
+ or data.get("format") != 1 or not isinstance(data.get("files"), dict)):
228
+ raise InstallError("refusing to replace an unmarked directory")
229
+ registrations = data.get("registrations", [])
230
+ self.previous_registrations = registrations if isinstance(registrations, list) else []
231
+ if not _matches_capability(self.install_dir, initial_cap):
232
+ raise InstallError("installation target changed while validating")
233
+ valid, detail = verify_installation(self.install_dir)
234
+ if not valid:
235
+ raise InstallError(f"existing installation is invalid: {detail}")
236
+ if not _matches_capability(self.install_dir, initial_cap):
237
+ raise InstallError("installation target changed while validating")
238
+ return initial_cap
239
+
240
+ def _copy_source(self, stage: Path) -> dict[str, str]:
241
+ # Validate the checked-in manifest before copying anything. This keeps
242
+ # the source and wheel/frozen manifests reviewable and prevents a source
243
+ # override from silently changing the allowlist.
244
+ try:
245
+ manifest_path = self.source / "fable_mode" / "resources.json"
246
+ _regular_file(manifest_path)
247
+ validate_manifest(json.loads(manifest_path.read_text(encoding="utf-8")))
248
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
249
+ raise InstallError(f"invalid packaged resource manifest: {exc}") from exc
250
+ seen_casefold: set[str] = set()
251
+ files: dict[str, str] = {}
252
+ for rel in ALLOWED_FILES:
253
+ src = _source_path(self.source, rel)
254
+ rel_path = Path(rel)
255
+ normalized = rel_path.as_posix().casefold()
256
+ if rel_path.is_absolute() or ".." in rel_path.parts or normalized in seen_casefold:
257
+ raise InstallError(f"unsafe or colliding manifest path: {rel}")
258
+ seen_casefold.add(normalized)
259
+ try:
260
+ source_stat = src.lstat()
261
+ except FileNotFoundError:
262
+ if rel in _OPTIONAL_SOURCE_FILES:
263
+ # Documentation, rules, and skills are optional in a wheel; runtime files are not.
264
+ continue
265
+ raise InstallError(f"missing canonical source file: {rel}")
266
+ if stat.S_ISLNK(source_stat.st_mode):
267
+ raise InstallError(f"symlink source entry: {rel}")
268
+ _regular_file(src)
269
+ dst = stage / rel_path
270
+ _reject_path(dst.parent)
271
+ dst.parent.mkdir(parents=True, exist_ok=True)
272
+ # Keep every installer-created subdirectory private even when the
273
+ # process umask is permissive (notably for wheel/frozen installs).
274
+ if os.name != "nt":
275
+ p = dst.parent
276
+ while p != stage and p != p.parent:
277
+ os.chmod(p, 0o700)
278
+ p = p.parent
279
+ shutil.copyfile(src, dst, follow_symlinks=False)
280
+ os.chmod(dst, 0o700 if rel == "fable_mode_entry.py" else 0o600)
281
+ files[rel] = _hash(dst)
282
+ wrapper = self.source / "fable_mode_entry.py"
283
+ _regular_file(wrapper)
284
+ dst = stage / "fable_mode_entry.py"
285
+ shutil.copyfile(wrapper, dst, follow_symlinks=False)
286
+ os.chmod(dst, 0o700)
287
+ files["fable_mode_entry.py"] = _hash(dst)
288
+ return files
289
+
290
+ def _copy_frozen(self, stage: Path, executable: Path) -> dict[str, str]:
291
+ _regular_file(executable)
292
+ name = "fable-mode.exe" if os.name == "nt" else "fable-mode"
293
+ dst = stage / name
294
+ shutil.copyfile(executable, dst, follow_symlinks=False)
295
+ os.chmod(dst, 0o700)
296
+ return {name: _hash(dst)}
297
+
298
+ def install(self, *, frozen_executable: Path | None = None, dry_run: bool = False) -> InstallResult:
299
+ """Publish an installation transactionally; caller may rollback registration."""
300
+ mode = "frozen" if frozen_executable is not None else "source"
301
+ # Dry-run must not inspect or reject an existing application/data
302
+ # directory: platform runtimes may already have created the parent.
303
+ # It performs no filesystem or host operation beyond computing paths.
304
+ if dry_run:
305
+ if frozen_executable:
306
+ argv = [str(frozen_executable), "serve"]
307
+ else:
308
+ argv = [sys.executable, str(self.install_dir / "runtime" / "fable_mode_entry.py"), "serve"]
309
+ return InstallResult(self.install_dir, argv, mode, None)
310
+ original_identity = self._assert_install_target()
311
+ parent = self.install_dir.parent
312
+ _reject_path(parent)
313
+ parent.mkdir(parents=True, exist_ok=True)
314
+ _reject_path(parent)
315
+ stage_dir: Path | None = None
316
+ backup: Path | None = None
317
+ try:
318
+ stage_dir = Path(tempfile.mkdtemp(prefix=f".{PRODUCT}.stage-", dir=str(parent)))
319
+ mark_owned_directory(stage_dir)
320
+ os.chmod(stage_dir, 0o700)
321
+ runtime = stage_dir / "runtime" if mode == "source" else stage_dir
322
+ if mode == "source":
323
+ runtime.mkdir(mode=0o700)
324
+ files = self._copy_frozen(runtime, Path(frozen_executable)) if frozen_executable else self._copy_source(runtime)
325
+ # Carry prior registration ownership across replacement. This
326
+ # prevents install-without-registration from orphaning an entry
327
+ # that still points at the retired runtime.
328
+ stage_identity = _capability(stage_dir)
329
+ migrated_records: list[dict] = []
330
+ for prior in self.previous_registrations:
331
+ if isinstance(prior, dict):
332
+ record = dict(prior)
333
+ record["install_dir"] = str(self.install_dir)
334
+ # Marker records historically bind the install directory
335
+ # to its device/inode pair (not uid/gid capability fields).
336
+ record["install_identity"] = [stage_identity[0], stage_identity[1]]
337
+ migrated_records.append(record)
338
+ marker = {"product": PRODUCT, "format": 1, "mode": mode, "files": files,
339
+ "registrations": migrated_records}
340
+ marker_path = stage_dir / MARKER
341
+ fd = os.open(marker_path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
342
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
343
+ json.dump(marker, handle, indent=2, sort_keys=True)
344
+ handle.write("\n")
345
+ handle.flush()
346
+ os.fsync(handle.fileno())
347
+ if original_identity is not None:
348
+ # Revalidate the preflight capability immediately before the
349
+ # destructive rename. In particular, never mark/own whatever
350
+ # happens to occupy the current pathname at this point.
351
+ if not _matches_capability(self.install_dir, original_identity):
352
+ raise InstallError("installation target changed; replacement preserved")
353
+ for _ in range(10):
354
+ candidate = parent / f".{PRODUCT}.previous-{uuid.uuid4().hex}"
355
+ if not candidate.exists() and not candidate.is_symlink():
356
+ backup = candidate
357
+ break
358
+ if backup is None:
359
+ raise InstallError("could not allocate a unique backup path")
360
+ os.replace(self.install_dir, backup)
361
+ if not _matches_capability(backup, original_identity):
362
+ raise InstallError("backup identity changed; replacement not published")
363
+ else:
364
+ # A target that appeared after preflight must not be silently
365
+ # overwritten by the staged replacement.
366
+ if self.install_dir.exists() or self.install_dir.is_symlink():
367
+ raise InstallError("installation target appeared; replacement preserved")
368
+ os.replace(stage_dir, self.install_dir)
369
+ # The stage inode is the cleanup capability. Do not mark/own the
370
+ # current pathname afresh after publication.
371
+ if not _matches_capability(self.install_dir, stage_identity):
372
+ raise InstallError("published installation identity changed")
373
+ install_identity = stage_identity
374
+ backup_identity = original_identity
375
+ stage_dir = None
376
+ if mode == "frozen":
377
+ exe = self.install_dir / ("fable-mode.exe" if os.name == "nt" else "fable-mode")
378
+ argv = [str(exe), "serve"]
379
+ else:
380
+ argv = [sys.executable, str(self.install_dir / "runtime" / "fable_mode_entry.py"), "serve"]
381
+ return InstallResult(self.install_dir, argv, mode,
382
+ InstallTransaction(self.install_dir, backup, install_identity, backup_identity))
383
+ except Exception as exc:
384
+ if stage_dir is not None:
385
+ try:
386
+ safe_cleanup(stage_dir)
387
+ except (OSError, ValueError):
388
+ pass
389
+ if backup and backup.exists() and not self.install_dir.exists():
390
+ if original_identity is not None and _matches_capability(backup, original_identity):
391
+ try:
392
+ os.replace(backup, self.install_dir)
393
+ except OSError as restore_exc:
394
+ raise InstallError("installation failed; old install could not be restored (partial state)") from restore_exc
395
+ elif original_identity is not None:
396
+ raise InstallError("installation failed; backup was replaced and old install is preserved as partial state") from exc
397
+ if isinstance(exc, InstallError):
398
+ raise
399
+ raise InstallError(f"installation failed: {exc}") from exc
400
+
401
+ def record_registrations(self, records: list[dict]) -> None:
402
+ """Persist registrations created by this install for guarded cleanup."""
403
+ marker = self.install_dir / MARKER
404
+ _regular_file(marker)
405
+ data = json.loads(marker.read_text(encoding="utf-8"))
406
+ data["registrations"] = records
407
+ fd, temp_name = tempfile.mkstemp(prefix=f".{MARKER}.", dir=str(self.install_dir))
408
+ temp = Path(temp_name)
409
+ try:
410
+ with os.fdopen(fd, "w", encoding="utf-8") as handle:
411
+ json.dump(data, handle, indent=2, sort_keys=True); handle.write("\n"); handle.flush(); os.fsync(handle.fileno())
412
+ os.replace(temp, marker)
413
+ finally:
414
+ if temp.exists(): temp.unlink()
415
+
416
+ def uninstall(self, *, dry_run: bool = False) -> bool:
417
+ identity = self._assert_install_target()
418
+ if dry_run:
419
+ return True
420
+ if identity is None:
421
+ return True
422
+ valid, detail = verify_installation(self.install_dir)
423
+ if not valid:
424
+ raise InstallError(f"installation manifest/tree is invalid: {detail}")
425
+ try:
426
+ try:
427
+ marker_data = json.loads((self.install_dir / MARKER).read_text(encoding="utf-8"))
428
+ from .adapters import cleanup_recorded_registrations
429
+ skipped = cleanup_recorded_registrations(
430
+ marker_data.get("registrations", []), strict=True,
431
+ install_dir=self.install_dir, home=Path.home())
432
+ if skipped:
433
+ # Never delete the marker that contains previous_entries
434
+ # recovery state while a host cannot be inspected or has
435
+ # changed. The caller can retry uninstall after restoring
436
+ # the host executable/config; silently removing this state
437
+ # would strand a stale fable-engine registration.
438
+ detail = ", ".join(skipped)
439
+ raise InstallError(
440
+ "registration cleanup unresolved; installation and recovery "
441
+ f"metadata preserved ({detail})")
442
+ except InstallError:
443
+ raise
444
+ except (OSError, ValueError, TypeError, json.JSONDecodeError) as exc:
445
+ # A malformed/unreadable marker is itself recovery-critical.
446
+ # verify_installation normally catches this earlier, but keep
447
+ # this path fail-closed if the file changes after verification.
448
+ raise InstallError(
449
+ "could not inspect registration recovery metadata; installation preserved") from exc
450
+ safe_cleanup(self.install_dir, expected_identity=identity)
451
+ except (OSError, ValueError) as exc:
452
+ raise InstallError("refusing to remove an unsafe installation directory") from exc
453
+ return True
454
+
455
+
456
+ def verify_installation(install_dir: Path) -> tuple[bool, str]:
457
+ """Verify marker hashes and reject links/extra path traversal in the manifest."""
458
+ install_dir = Path(install_dir)
459
+ marker = install_dir / MARKER
460
+ try:
461
+ _regular_file(marker)
462
+ data = json.loads(marker.read_text(encoding="utf-8"))
463
+ if not isinstance(data, dict) or data.get("product") != PRODUCT or data.get("format") != 1:
464
+ return False, "wrong installation marker"
465
+ files = data.get("files")
466
+ if not isinstance(files, dict):
467
+ return False, "invalid file manifest"
468
+ mode = data.get("mode")
469
+ if mode == "source":
470
+ expected_names = set(_REQUIRED_SOURCE_FILES) | {"fable_mode_entry.py"}
471
+ # Optional docs/rules are included when present in a source tree,
472
+ # but are never allowed to replace or omit runtime resources.
473
+ expected_names.update(name for name in files if name in _OPTIONAL_SOURCE_FILES)
474
+ if set(files) != expected_names:
475
+ # Check if this installation matches its own packaged resources.json (e.g. prior version during upgrade)
476
+ res_path = install_dir / "runtime" / "fable_mode" / "resources.json"
477
+ if res_path.is_file():
478
+ try:
479
+ res_data = json.loads(res_path.read_text(encoding="utf-8"))
480
+ if isinstance(res_data, dict) and res_data.get("format") == 1:
481
+ res_files = res_data.get("files", [])
482
+ res_opt = {r for r in res_files if r.startswith("rules/") or r.startswith("docs/") or r.startswith("skills/")}
483
+ res_req = {r for r in res_files if r not in res_opt}
484
+ valid_manifest_names = set(res_req) | {"fable_mode_entry.py"}
485
+ valid_manifest_names.update(name for name in files if name in res_opt)
486
+ if set(files) == valid_manifest_names:
487
+ expected_names = valid_manifest_names
488
+ except Exception:
489
+ pass
490
+ elif mode == "frozen":
491
+ expected_names = {"fable-mode.exe" if os.name == "nt" else "fable-mode"}
492
+ else:
493
+ return False, "unknown installation mode"
494
+ if set(files) != expected_names:
495
+ return False, "installation manifest does not match the canonical mode manifest"
496
+ names: set[str] = set()
497
+ if os.name != "nt" and stat.S_IMODE(install_dir.stat().st_mode) & 0o077:
498
+ return False, "installation directory is not private"
499
+ for rel, expected in files.items():
500
+ rel_path = Path(rel)
501
+ folded = rel_path.as_posix().casefold()
502
+ if rel_path.is_absolute() or ".." in rel_path.parts or folded in names:
503
+ return False, "unsafe or colliding path in manifest"
504
+ names.add(folded)
505
+ p = install_dir / ("runtime" / rel_path if mode == "source" else rel_path)
506
+ _regular_file(p)
507
+ if not isinstance(expected, str) or _hash(p) != expected:
508
+ return False, f"hash mismatch: {rel}"
509
+ # Enumerate the complete install tree. Hashing listed files alone
510
+ # would allow an attacker to leave executable/configuration extras
511
+ # beside an otherwise valid installation.
512
+ expected_files = {MARKER}
513
+ expected_files.update(("runtime/" + name) if mode == "source" else name for name in files)
514
+ expected_dirs = set()
515
+ for rel in expected_files:
516
+ parent = Path(rel).parent
517
+ while str(parent) not in {"", "."}:
518
+ expected_dirs.add(parent.as_posix())
519
+ parent = parent.parent
520
+ seen_files: set[str] = set()
521
+ seen_dirs: set[str] = set()
522
+ for root, dirs, names in os.walk(install_dir, topdown=True, followlinks=False):
523
+ root_path = Path(root)
524
+ for name in dirs:
525
+ child = root_path / name
526
+ st = child.lstat()
527
+ if (stat.S_ISLNK(st.st_mode) or not stat.S_ISDIR(st.st_mode)
528
+ or (os.name != "nt" and stat.S_IMODE(st.st_mode) & 0o077)):
529
+ return False, "unsafe installation tree node"
530
+ seen_dirs.add(child.relative_to(install_dir).as_posix())
531
+ for name in names:
532
+ child = root_path / name
533
+ _regular_file(child)
534
+ seen_files.add(child.relative_to(install_dir).as_posix())
535
+ if seen_files != expected_files or seen_dirs != expected_dirs:
536
+ return False, "installation tree contains unexpected or missing entries"
537
+ if mode == "source":
538
+ resources_path = install_dir / "runtime" / "fable_mode" / "resources.json"
539
+ _regular_file(resources_path)
540
+ resources = json.loads(resources_path.read_text(encoding="utf-8"))
541
+ if expected_names != (set(_REQUIRED_SOURCE_FILES) | {"fable_mode_entry.py"}):
542
+ validate_manifest(resources, allowed_files=resources.get("files"))
543
+ else:
544
+ validate_manifest(resources)
545
+ return True, "ok"
546
+ except (OSError, ValueError, TypeError, InstallError, json.JSONDecodeError) as exc:
547
+ return False, str(exc)
548
+
549
+
550
+ if __name__ == "__main__":
551
+ from .launcher import main
552
+ raise SystemExit(main())
553
+