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