agent-bios 0.14.0 → 0.16.0
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.
- package/DEPENDENCIES.md +35 -12
- package/README.md +346 -31
- package/claude/CLAUDE.md +2 -2
- package/claude/agents/frontier.md +1 -1
- package/claude/agents/sweep.md +3 -3
- package/claude/agents/workhorse.md +2 -2
- package/claude/guides/claude-prompting.md +119 -34
- package/claude/guides/cli-multi-model-workflow.md +33 -15
- package/claude/guides/gpt-prompting.md +148 -28
- package/claude/guides/review-request.md +27 -0
- package/claude/guides/session-distill-workflow.md +54 -2
- package/claude/guides/slide-writing/RUNBOOK.md +137 -0
- package/claude/guides/slide-writing/scripts/pair.py +979 -0
- package/claude/guides/slide-writing/scripts/render.mjs +82 -0
- package/claude/guides/slide-writing.md +195 -0
- package/claude/guides/svg-visualization-guide.md +9 -0
- package/claude/guides/verification-discipline.md +5 -1
- package/claude/hooks/tooling-gotchas-hook.py +7 -5
- package/codex/AGENTS.md +2 -2
- package/codex/agents/frontier.toml +2 -1
- package/codex/agents/reviewer.toml +1 -1
- package/codex/agents/sweep.toml +3 -3
- package/codex/agents/workhorse.toml +1 -1
- package/codex/config-additions.toml +1 -1
- package/codex/guides/claude-prompting.md +119 -34
- package/codex/guides/cli-multi-model-workflow.md +33 -15
- package/codex/guides/gpt-prompting.md +148 -28
- package/codex/guides/review-request.md +27 -0
- package/codex/guides/session-distill-workflow.md +54 -2
- package/codex/guides/slide-writing/RUNBOOK.md +137 -0
- package/codex/guides/slide-writing/scripts/pair.py +979 -0
- package/codex/guides/slide-writing/scripts/render.mjs +82 -0
- package/codex/guides/slide-writing.md +195 -0
- package/codex/guides/svg-visualization-guide.md +9 -0
- package/codex/guides/verification-discipline.md +5 -1
- package/compose/assemble.py +290 -14
- package/compose/bootstrap/SKILL.md +119 -0
- package/compose/check-domains.py +102 -9
- package/compose/corpus-state.py +1174 -0
- package/compose/corpus.py +387 -0
- package/compose/corpus_catalog.py +882 -0
- package/compose/corpus_install.py +1617 -0
- package/compose/corpus_session.py +726 -0
- package/compose/corpus_store.py +1414 -0
- package/compose/corpus_transaction.py +236 -0
- package/compose/corpus_ui.py +644 -0
- package/compose/domains.json +101 -100
- package/compose/write-update-cache.py +53 -0
- package/install.sh +174 -24
- package/launch/agent-launch.py +1327 -184
- package/launch/agent-launch.toml +12 -16
- package/launch/i18n/en.toml +113 -7
- package/launch/i18n/ja.toml +113 -7
- package/launch/i18n/ko.toml +113 -7
- package/learn/collect-learning.py +46 -19
- package/learn/migrate-learnings.py +10 -1
- package/package.json +13 -3
- package/provenance.json +1 -1
- package/session-cost.py +22 -2
- package/wrappers/codex-helm.sh +3 -3
|
@@ -0,0 +1,1617 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Private corpus installation and explicit legacy migration.
|
|
3
|
+
|
|
4
|
+
This command owns package-at-rest installation. It deliberately has no host
|
|
5
|
+
activation path: an installed corpus is stored and verifiable, while a launcher
|
|
6
|
+
activation creates the per-session snapshot and pin.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import base64
|
|
12
|
+
import contextlib
|
|
13
|
+
import hashlib
|
|
14
|
+
import importlib
|
|
15
|
+
import importlib.util
|
|
16
|
+
import json
|
|
17
|
+
import os
|
|
18
|
+
from pathlib import Path, PurePosixPath
|
|
19
|
+
import re
|
|
20
|
+
import shlex
|
|
21
|
+
import shutil
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
import time
|
|
25
|
+
import tomllib
|
|
26
|
+
import uuid
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from corpus_transaction import (
|
|
30
|
+
_valid_release,
|
|
31
|
+
TransactionError,
|
|
32
|
+
guard_pending,
|
|
33
|
+
operation_scope,
|
|
34
|
+
operation_scope_active,
|
|
35
|
+
pending_status,
|
|
36
|
+
transaction_lock,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
SCHEMA_VERSION = 1
|
|
41
|
+
PRIVATE_RECORD = "private-install.json"
|
|
42
|
+
CENTRAL_START = "<!-- agent-bios:central:start -->"
|
|
43
|
+
CENTRAL_END = "<!-- agent-bios:central:end -->"
|
|
44
|
+
PERSONAL_START = "<!-- agent-bios:personal-learnings:start -->"
|
|
45
|
+
PERSONAL_END = "<!-- agent-bios:personal-learnings:end -->"
|
|
46
|
+
CLAUDE_IMPORTS = frozenset(("@central/bundle.md", "@personal/learnings.md"))
|
|
47
|
+
ZSH_HOOK = '[ -r "$HOME/.config/agent-launch/shell.zsh" ] && source "$HOME/.config/agent-launch/shell.zsh"'
|
|
48
|
+
LEARNING_ID = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class InstallError(RuntimeError):
|
|
52
|
+
pass
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _serialized(method):
|
|
56
|
+
"""Keep installer metadata and its owned paths one transaction at a time."""
|
|
57
|
+
def wrapped(self, *args, **kwargs):
|
|
58
|
+
name = method.__name__
|
|
59
|
+
dry = bool(kwargs.get("dry_run", False))
|
|
60
|
+
if name == "install" and len(args) >= 2:
|
|
61
|
+
dry = dry or bool(args[1])
|
|
62
|
+
elif name in {"uninstall"} and args:
|
|
63
|
+
dry = dry or bool(args[0])
|
|
64
|
+
apply = bool(args[0]) if args and name in {"migrate", "reset"} else bool(kwargs.get("apply", False))
|
|
65
|
+
if name == "status" or dry or (name in {"migrate", "reset"} and not apply):
|
|
66
|
+
return method(self, *args, **kwargs)
|
|
67
|
+
with self._installer_lock():
|
|
68
|
+
if name in {"install", "uninstall", "reset"} and not operation_scope_active(self.state_root):
|
|
69
|
+
if any(row["kind"] == "migrate" for row in pending_status(self.state_root)["transactions"]):
|
|
70
|
+
raise InstallError("pending migration requires agent-bios migrate --apply --yes before other writes")
|
|
71
|
+
return method(self, *args, **kwargs)
|
|
72
|
+
return wrapped
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _sha256(path: Path) -> str:
|
|
76
|
+
digest = hashlib.sha256()
|
|
77
|
+
with path.open("rb") as source:
|
|
78
|
+
for chunk in iter(lambda: source.read(1024 * 1024), b""):
|
|
79
|
+
digest.update(chunk)
|
|
80
|
+
return digest.hexdigest()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _canonical(value: Any) -> bytes:
|
|
84
|
+
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _atomic_bytes(path: Path, data: bytes, mode: int | None = None) -> None:
|
|
88
|
+
path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
89
|
+
if path.is_symlink():
|
|
90
|
+
raise InstallError(f"refusing symlink output: {path}")
|
|
91
|
+
descriptor, raw = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
92
|
+
temporary = Path(raw)
|
|
93
|
+
try:
|
|
94
|
+
with os.fdopen(descriptor, "wb") as target:
|
|
95
|
+
target.write(data)
|
|
96
|
+
target.flush()
|
|
97
|
+
os.fsync(target.fileno())
|
|
98
|
+
if mode is not None:
|
|
99
|
+
temporary.chmod(mode)
|
|
100
|
+
elif path.exists():
|
|
101
|
+
temporary.chmod(path.stat().st_mode & 0o777)
|
|
102
|
+
os.replace(temporary, path)
|
|
103
|
+
finally:
|
|
104
|
+
temporary.unlink(missing_ok=True)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _atomic_json(path: Path, value: Any) -> None:
|
|
108
|
+
_atomic_bytes(path, _canonical(value) + b"\n", 0o600)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _read_json(path: Path) -> dict[str, Any]:
|
|
112
|
+
if path.is_symlink() or not path.is_file():
|
|
113
|
+
raise InstallError(f"missing or unsafe JSON record: {path}")
|
|
114
|
+
try:
|
|
115
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
116
|
+
except (OSError, ValueError) as exc:
|
|
117
|
+
raise InstallError(f"cannot read JSON record {path}: {exc}") from exc
|
|
118
|
+
if not isinstance(data, dict):
|
|
119
|
+
raise InstallError(f"JSON record must be an object: {path}")
|
|
120
|
+
return data
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _relative(value: str, label: str) -> str:
|
|
124
|
+
if not isinstance(value, str) or not value:
|
|
125
|
+
raise InstallError(f"{label} must be a non-empty relative path")
|
|
126
|
+
path = PurePosixPath(value.rstrip("/"))
|
|
127
|
+
if path.is_absolute() or not path.parts or any(part in {"", ".", ".."} for part in path.parts):
|
|
128
|
+
raise InstallError(f"unsafe {label}: {value!r}")
|
|
129
|
+
return path.as_posix()
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _inside(path: Path, root: Path) -> bool:
|
|
133
|
+
try:
|
|
134
|
+
path.absolute().relative_to(root.absolute())
|
|
135
|
+
return True
|
|
136
|
+
except ValueError:
|
|
137
|
+
return False
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _active_import_lines(body: str) -> list[int]:
|
|
141
|
+
"""Line numbers of real import directives, excluding fenced examples."""
|
|
142
|
+
result: list[int] = []
|
|
143
|
+
fenced = False
|
|
144
|
+
for index, line in enumerate(body.splitlines(keepends=True)):
|
|
145
|
+
stripped = line.strip()
|
|
146
|
+
if stripped.startswith(("```", "~~~")):
|
|
147
|
+
fenced = not fenced
|
|
148
|
+
continue
|
|
149
|
+
if not fenced and stripped in CLAUDE_IMPORTS:
|
|
150
|
+
result.append(index)
|
|
151
|
+
return result
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _remove_claude_imports(body: str) -> tuple[str, list[str]]:
|
|
155
|
+
lines = body.splitlines(keepends=True)
|
|
156
|
+
indexes = set(_active_import_lines(body))
|
|
157
|
+
removed = [lines[index].strip() for index in sorted(indexes)]
|
|
158
|
+
return "".join(line for index, line in enumerate(lines) if index not in indexes), removed
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _remove_spans(body: str) -> tuple[str | None, list[str]]:
|
|
162
|
+
"""Strip exact, non-overlapping managed marker spans or report ambiguity."""
|
|
163
|
+
spans = ((CENTRAL_START, CENTRAL_END, "central"), (PERSONAL_START, PERSONAL_END, "personal-learnings"))
|
|
164
|
+
found: list[tuple[int, int, str]] = []
|
|
165
|
+
for start, end, label in spans:
|
|
166
|
+
starts = [index for index in range(len(body)) if body.startswith(start, index)]
|
|
167
|
+
ends = [index for index in range(len(body)) if body.startswith(end, index)]
|
|
168
|
+
if not starts and not ends:
|
|
169
|
+
continue
|
|
170
|
+
if len(starts) != 1 or len(ends) != 1 or starts[0] >= ends[0]:
|
|
171
|
+
return None, [f"malformed {label} marker span"]
|
|
172
|
+
found.append((starts[0], ends[0] + len(end), label))
|
|
173
|
+
if len(found) == 2 and not (found[0][1] <= found[1][0] or found[1][1] <= found[0][0]):
|
|
174
|
+
return None, ["overlapping managed marker spans"]
|
|
175
|
+
text = body
|
|
176
|
+
removed: list[str] = []
|
|
177
|
+
for start, end, label in sorted(found, reverse=True):
|
|
178
|
+
text = text[:start] + text[end:]
|
|
179
|
+
removed.append(label)
|
|
180
|
+
return text.lstrip("\n"), list(reversed(removed))
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _strip_codex_config_additions(body: str) -> tuple[str | None, bool]:
|
|
184
|
+
"""Remove only the exact legacy config block and tagged feature line."""
|
|
185
|
+
begin, end, tag = "# >>> agent-bios additions >>>", "# <<< agent-bios additions <<<", "# agent-bios"
|
|
186
|
+
starts = body.count(begin)
|
|
187
|
+
ends = body.count(end)
|
|
188
|
+
if starts != ends or starts > 1:
|
|
189
|
+
return None, False
|
|
190
|
+
skipping = False
|
|
191
|
+
changed = False
|
|
192
|
+
lines: list[str] = []
|
|
193
|
+
for line in body.splitlines(keepends=True):
|
|
194
|
+
stripped = line.strip()
|
|
195
|
+
if stripped == begin:
|
|
196
|
+
skipping, changed = True, True
|
|
197
|
+
continue
|
|
198
|
+
if stripped == end:
|
|
199
|
+
skipping = False
|
|
200
|
+
continue
|
|
201
|
+
if skipping:
|
|
202
|
+
continue
|
|
203
|
+
if stripped.endswith(tag) and "multi_agent" in stripped:
|
|
204
|
+
changed = True
|
|
205
|
+
continue
|
|
206
|
+
lines.append(line)
|
|
207
|
+
result = "".join(lines)
|
|
208
|
+
if changed:
|
|
209
|
+
try:
|
|
210
|
+
tomllib.loads(result)
|
|
211
|
+
except tomllib.TOMLDecodeError:
|
|
212
|
+
return None, False
|
|
213
|
+
return result, changed
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
class CorpusInstaller:
|
|
217
|
+
"""Private install state plus carefully scoped legacy cleanup."""
|
|
218
|
+
|
|
219
|
+
def __init__(self, repo: Path, environ: dict[str, str] | None = None):
|
|
220
|
+
self.repo = Path(repo).resolve()
|
|
221
|
+
self.env = dict(os.environ if environ is None else environ)
|
|
222
|
+
home = Path(self.env.get("HOME", str(Path.home()))).expanduser()
|
|
223
|
+
self.state_root = Path(self.env.get("AGENT_BIOS_STATE_DIR", str(home / ".local/share/agent-bios"))).expanduser()
|
|
224
|
+
self.user_root = Path(self.env.get("AGENT_BIOS_CORPUS_DIR", str(home / ".config/agent-bios/corpus"))).expanduser()
|
|
225
|
+
self.claude_root = Path(self.env.get("CLAUDE_CONFIG_DIR", str(home / ".claude"))).expanduser()
|
|
226
|
+
self.codex_root = Path(self.env.get("CODEX_HOME", str(home / ".codex"))).expanduser()
|
|
227
|
+
self.zdotdir = Path(self.env.get("ZDOTDIR", str(home))).expanduser()
|
|
228
|
+
self.launch_root = home / ".config" / "agent-launch"
|
|
229
|
+
self.bin_root = home / ".local" / "bin"
|
|
230
|
+
self._installer_lock_depth = 0
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def runtime(self) -> Path:
|
|
234
|
+
return self.state_root / "runtime"
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def record_path(self) -> Path:
|
|
238
|
+
return self.runtime / PRIVATE_RECORD
|
|
239
|
+
|
|
240
|
+
@contextlib.contextmanager
|
|
241
|
+
def _installer_lock(self):
|
|
242
|
+
"""Use the store's lock; lock ordering cannot diverge by caller."""
|
|
243
|
+
try:
|
|
244
|
+
with transaction_lock(self.state_root):
|
|
245
|
+
yield
|
|
246
|
+
except TransactionError as exc:
|
|
247
|
+
raise InstallError(str(exc)) from exc
|
|
248
|
+
|
|
249
|
+
def _package_manifest(self) -> dict[str, Any]:
|
|
250
|
+
return _read_json(self.repo / "package.json")
|
|
251
|
+
|
|
252
|
+
def _package_files(self) -> list[str]:
|
|
253
|
+
manifest = self._package_manifest()
|
|
254
|
+
listed = manifest.get("files")
|
|
255
|
+
if not isinstance(listed, list) or not listed:
|
|
256
|
+
raise InstallError("package.json files must declare the private runtime payload")
|
|
257
|
+
paths: set[str] = {"package.json"} # npm includes this manifest even when files[] omits it.
|
|
258
|
+
prohibited = ("gates/", "design/", "benchmarks/", "research/", "packages/", "session-distill/")
|
|
259
|
+
for raw in listed:
|
|
260
|
+
relative = _relative(raw, "package files entry")
|
|
261
|
+
if relative.startswith(prohibited):
|
|
262
|
+
raise InstallError(f"author-only path is not a private runtime payload: {relative}")
|
|
263
|
+
source = self.repo / relative
|
|
264
|
+
# npm's prepack stamps this author-side provenance receipt. A
|
|
265
|
+
# checkout after postpack legitimately lacks it and no private
|
|
266
|
+
# runtime path consumes it, so it is not an installation blocker.
|
|
267
|
+
if relative == "provenance.json" and not source.exists():
|
|
268
|
+
continue
|
|
269
|
+
if source.is_symlink() or not source.exists():
|
|
270
|
+
raise InstallError(f"declared package file is missing or symlinked: {relative}")
|
|
271
|
+
if source.is_file():
|
|
272
|
+
paths.add(relative)
|
|
273
|
+
continue
|
|
274
|
+
if not source.is_dir():
|
|
275
|
+
raise InstallError(f"declared package entry is not file or directory: {relative}")
|
|
276
|
+
for child in sorted(source.rglob("*")):
|
|
277
|
+
if child.is_symlink():
|
|
278
|
+
raise InstallError(f"package payload contains symlink: {child}")
|
|
279
|
+
if child.is_file():
|
|
280
|
+
child_relative = child.relative_to(self.repo).as_posix()
|
|
281
|
+
if "/test_" in child_relative or child_relative.startswith("tests/"):
|
|
282
|
+
raise InstallError(f"test file is not a runtime payload: {child_relative}")
|
|
283
|
+
paths.add(child_relative)
|
|
284
|
+
return sorted(paths)
|
|
285
|
+
|
|
286
|
+
def _release_digest(self, files: list[str]) -> str:
|
|
287
|
+
digest = hashlib.sha256()
|
|
288
|
+
for relative in files:
|
|
289
|
+
source = self.repo / relative
|
|
290
|
+
digest.update(relative.encode("utf-8") + b"\0" + source.read_bytes() + b"\0")
|
|
291
|
+
return digest.hexdigest()
|
|
292
|
+
|
|
293
|
+
def _copy_release(self, files: list[str], dry_run: bool) -> tuple[Path, list[dict[str, str]], str]:
|
|
294
|
+
digest = self._release_digest(files)
|
|
295
|
+
release = self.runtime / "releases" / digest
|
|
296
|
+
entries = [{"path": relative, "sha256": _sha256(self.repo / relative)} for relative in files]
|
|
297
|
+
if dry_run:
|
|
298
|
+
return release, entries, digest
|
|
299
|
+
if release.exists():
|
|
300
|
+
if release.is_symlink() or not release.is_dir():
|
|
301
|
+
raise InstallError(f"immutable release path is unsafe: {release}")
|
|
302
|
+
self._verify_release(release, entries)
|
|
303
|
+
return release, entries, digest
|
|
304
|
+
release.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
305
|
+
staging = Path(tempfile.mkdtemp(prefix=f".{digest}.", dir=release.parent))
|
|
306
|
+
try:
|
|
307
|
+
for entry in entries:
|
|
308
|
+
source = self.repo / entry["path"]
|
|
309
|
+
target = staging / entry["path"]
|
|
310
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
311
|
+
shutil.copy2(source, target)
|
|
312
|
+
self._verify_release(staging, entries)
|
|
313
|
+
os.replace(staging, release)
|
|
314
|
+
except BaseException:
|
|
315
|
+
shutil.rmtree(staging, ignore_errors=True)
|
|
316
|
+
raise
|
|
317
|
+
return release, entries, digest
|
|
318
|
+
|
|
319
|
+
def _verify_release(self, release: Path, entries: list[dict[str, str]]) -> None:
|
|
320
|
+
for entry in entries:
|
|
321
|
+
target = release / _relative(entry.get("path", ""), "installed file")
|
|
322
|
+
if target.is_symlink() or not target.is_file() or _sha256(target) != entry.get("sha256"):
|
|
323
|
+
raise InstallError(f"private package release drifted: {target}")
|
|
324
|
+
|
|
325
|
+
def _private_module(self, package_root: Path, stem: str):
|
|
326
|
+
compose = str(package_root / "compose")
|
|
327
|
+
if compose not in sys.path:
|
|
328
|
+
sys.path.insert(0, compose)
|
|
329
|
+
path = package_root / "compose" / f"{stem}.py"
|
|
330
|
+
if not path.is_file() or path.is_symlink():
|
|
331
|
+
raise InstallError(f"private package lacks compose/{stem}.py")
|
|
332
|
+
name = f"agent_bios_private_{stem}_{hashlib.sha256(str(package_root).encode()).hexdigest()[:12]}"
|
|
333
|
+
spec = importlib.util.spec_from_file_location(name, path)
|
|
334
|
+
if spec is None or spec.loader is None:
|
|
335
|
+
raise InstallError(f"cannot load private {stem}")
|
|
336
|
+
module = importlib.util.module_from_spec(spec)
|
|
337
|
+
sys.modules[name] = module
|
|
338
|
+
spec.loader.exec_module(module)
|
|
339
|
+
return module
|
|
340
|
+
|
|
341
|
+
def _store(self, package_root: Path):
|
|
342
|
+
module = self._private_module(package_root, "corpus_store")
|
|
343
|
+
store = module.CorpusStore(package_root, self.state_root, self.user_root)
|
|
344
|
+
# CorpusStore delegates compile/load to a module name for normal package
|
|
345
|
+
# execution. Bind this instance to the release's exact catalog so a
|
|
346
|
+
# long-lived manager cannot retain an older release through sys.modules.
|
|
347
|
+
store._catalog_module = lambda: self._private_module(package_root, "corpus_catalog")
|
|
348
|
+
return store
|
|
349
|
+
|
|
350
|
+
def _catalog(self, package_root: Path) -> dict[str, Any]:
|
|
351
|
+
"""Load the catalog from the immutable release being verified."""
|
|
352
|
+
module = self._private_module(package_root, "corpus_catalog")
|
|
353
|
+
return module.load_catalog(package_root)
|
|
354
|
+
|
|
355
|
+
def _normalized_domains(self, raw: str | None) -> list[str]:
|
|
356
|
+
if raw is None or raw.strip() in {"", "all"}:
|
|
357
|
+
return ["all"]
|
|
358
|
+
catalog_path = self.repo / "compose" / "domains.json"
|
|
359
|
+
manifest = _read_json(catalog_path)
|
|
360
|
+
package_id = manifest.get("package_id", "@agent-bios/core")
|
|
361
|
+
known = set((manifest.get("domains") or {}).keys())
|
|
362
|
+
names = [value.strip() for value in raw.split(",") if value.strip()]
|
|
363
|
+
if names == ["none"]:
|
|
364
|
+
return []
|
|
365
|
+
if "none" in names:
|
|
366
|
+
raise InstallError("--domains none must be the only selection value")
|
|
367
|
+
unknown = sorted(set(names) - known)
|
|
368
|
+
if unknown:
|
|
369
|
+
raise InstallError(f"unknown domains: {', '.join(unknown)}")
|
|
370
|
+
return [f"{package_id}/{name}" for name in sorted(set(names))]
|
|
371
|
+
|
|
372
|
+
def _write_launcher_status(self, package_root: Path, selection: list[str]) -> None:
|
|
373
|
+
"""Keep the legacy launcher panel readable without claiming activation."""
|
|
374
|
+
manifest = _read_json(package_root / "compose" / "domains.json")
|
|
375
|
+
available = sorted((manifest.get("domains") or {}).keys())
|
|
376
|
+
applied = available if selection == ["all"] else sorted(value.rsplit("/", 1)[-1] for value in selection)
|
|
377
|
+
_atomic_json(self.state_root / "corpus-status.json", {
|
|
378
|
+
"repo": str(package_root), "current_version": None, "latest_version": None,
|
|
379
|
+
"rolled_back_to": None, "versions": None, "summary": None,
|
|
380
|
+
"domains": {"available": available, "applied": applied}, "last_apply": None,
|
|
381
|
+
"deployed_corpus": None, "generated": int(time.time()),
|
|
382
|
+
"mode": "private-session-scoped",
|
|
383
|
+
})
|
|
384
|
+
|
|
385
|
+
def _launcher_status_bytes(self, package_root: Path, selection: list[str], generated: int | None = None) -> bytes:
|
|
386
|
+
"""The status projection is part of the same publication as its record."""
|
|
387
|
+
manifest = _read_json(package_root / "compose" / "domains.json")
|
|
388
|
+
available = sorted((manifest.get("domains") or {}).keys())
|
|
389
|
+
applied = available if selection == ["all"] else sorted(value.rsplit("/", 1)[-1] for value in selection)
|
|
390
|
+
return _canonical({
|
|
391
|
+
"repo": str(package_root), "current_version": None, "latest_version": None,
|
|
392
|
+
"rolled_back_to": None, "versions": None, "summary": None,
|
|
393
|
+
"domains": {"available": available, "applied": applied}, "last_apply": None,
|
|
394
|
+
"deployed_corpus": None, "generated": int(time.time()) if generated is None else generated,
|
|
395
|
+
"mode": "private-session-scoped",
|
|
396
|
+
}) + b"\n"
|
|
397
|
+
|
|
398
|
+
@staticmethod
|
|
399
|
+
def _file_version(path: Path, *, allow_bytes: bool = True) -> dict[str, Any]:
|
|
400
|
+
"""A journaled exact-state predicate; secrets use hash-only predicates."""
|
|
401
|
+
if path.is_symlink():
|
|
402
|
+
raise InstallError(f"refusing symlink transaction target: {path}")
|
|
403
|
+
if not path.exists():
|
|
404
|
+
return {"exists": False}
|
|
405
|
+
if not path.is_file():
|
|
406
|
+
raise InstallError(f"transaction target is not a regular file: {path}")
|
|
407
|
+
data = path.read_bytes()
|
|
408
|
+
result: dict[str, Any] = {"exists": True, "sha256": hashlib.sha256(data).hexdigest()}
|
|
409
|
+
if allow_bytes:
|
|
410
|
+
result["bytes_b64"] = base64.b64encode(data).decode("ascii")
|
|
411
|
+
return result
|
|
412
|
+
|
|
413
|
+
@staticmethod
|
|
414
|
+
def _planned_version(data: bytes | None) -> dict[str, Any]:
|
|
415
|
+
if data is None:
|
|
416
|
+
return {"exists": False}
|
|
417
|
+
return {"exists": True, "sha256": hashlib.sha256(data).hexdigest(),
|
|
418
|
+
"bytes_b64": base64.b64encode(data).decode("ascii")}
|
|
419
|
+
|
|
420
|
+
@staticmethod
|
|
421
|
+
def _matches_version(path: Path, version: dict[str, Any]) -> bool:
|
|
422
|
+
if path.is_symlink():
|
|
423
|
+
return False
|
|
424
|
+
if not version.get("exists"):
|
|
425
|
+
return not path.exists()
|
|
426
|
+
return path.is_file() and _sha256(path) == version.get("sha256")
|
|
427
|
+
|
|
428
|
+
def _transaction_path(self, transaction_id: str) -> Path:
|
|
429
|
+
if not re.fullmatch(r"[A-Za-z0-9._-]+", transaction_id):
|
|
430
|
+
raise InstallError("unsafe transaction id")
|
|
431
|
+
# Store keeps its source-only candidate at ``transactions/<id>``.
|
|
432
|
+
# The installer must never overwrite that source journal.
|
|
433
|
+
return self.runtime / "installer-transactions" / transaction_id / "journal.json"
|
|
434
|
+
|
|
435
|
+
def _journal_write(self, journal: Path, value: dict[str, Any]) -> None:
|
|
436
|
+
_atomic_json(journal, value)
|
|
437
|
+
|
|
438
|
+
def _write_planned(self, path: Path, version: dict[str, Any], mode: int) -> None:
|
|
439
|
+
encoded = version.get("bytes_b64")
|
|
440
|
+
if not isinstance(encoded, str):
|
|
441
|
+
if not version.get("exists"):
|
|
442
|
+
path.unlink(missing_ok=True)
|
|
443
|
+
return
|
|
444
|
+
raise InstallError(f"journal has no replay bytes for {path}")
|
|
445
|
+
_atomic_bytes(path, base64.b64decode(encoded.encode("ascii"), validate=True), mode)
|
|
446
|
+
|
|
447
|
+
def _assert_preflight_paths(self, paths: list[dict[str, Any]]) -> None:
|
|
448
|
+
conflicts = [entry["path"] for entry in paths
|
|
449
|
+
if not self._matches_version(Path(entry["path"]), entry["before"])
|
|
450
|
+
and not self._matches_version(Path(entry["path"]), entry["after"])]
|
|
451
|
+
if conflicts:
|
|
452
|
+
raise InstallError("owned path changed during transaction preflight: " + ", ".join(conflicts))
|
|
453
|
+
|
|
454
|
+
def _old_record(self) -> dict[str, Any] | None:
|
|
455
|
+
return _read_json(self.record_path) if self.record_path.exists() else None
|
|
456
|
+
|
|
457
|
+
def _safe_owned_path(self, path: Path, root: Path) -> None:
|
|
458
|
+
"""An ownership record cannot redirect writes through a symlinked parent."""
|
|
459
|
+
if not _inside(path, root):
|
|
460
|
+
raise InstallError(f"owned path is outside its private destination: {path}")
|
|
461
|
+
current = root.absolute()
|
|
462
|
+
if current.is_symlink():
|
|
463
|
+
raise InstallError(f"owned destination root is symlinked: {current}")
|
|
464
|
+
relative = path.absolute().relative_to(current)
|
|
465
|
+
for part in relative.parts:
|
|
466
|
+
current = current / part
|
|
467
|
+
if current.is_symlink():
|
|
468
|
+
raise InstallError(f"owned destination has symlink ancestor: {current}")
|
|
469
|
+
|
|
470
|
+
def _expected_owned(self, release: Path) -> dict[str, str]:
|
|
471
|
+
expected = {str(self.bin_root / "agent-launch"): hashlib.sha256(self._launcher_body(release)).hexdigest(),
|
|
472
|
+
str(self.launch_root / "profiles.toml"): _sha256(release / "launch" / "agent-launch.toml")}
|
|
473
|
+
i18n = release / "launch" / "i18n"
|
|
474
|
+
for source in sorted(i18n.glob("*.toml")):
|
|
475
|
+
expected[str(self.launch_root / "i18n" / source.name)] = _sha256(source)
|
|
476
|
+
return expected
|
|
477
|
+
|
|
478
|
+
def _validate_owned_record(self, record: dict[str, Any], release: Path) -> None:
|
|
479
|
+
expected = self._expected_owned(release)
|
|
480
|
+
launcher = record.get("launcher")
|
|
481
|
+
if launcher is not None:
|
|
482
|
+
if not isinstance(launcher, dict) or launcher.get("path") != str(self.bin_root / "agent-launch") \
|
|
483
|
+
or launcher.get("sha256") != expected[str(self.bin_root / "agent-launch")]:
|
|
484
|
+
raise InstallError("private install record has an invalid launcher ownership claim")
|
|
485
|
+
self._safe_owned_path(Path(launcher["path"]), self.bin_root)
|
|
486
|
+
seen: set[str] = set()
|
|
487
|
+
for entry in record.get("config_files") or []:
|
|
488
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("path"), str) or entry["path"] in seen:
|
|
489
|
+
raise InstallError("private install record has invalid config ownership entries")
|
|
490
|
+
seen.add(entry["path"])
|
|
491
|
+
if entry["path"] not in expected or entry.get("sha256") != expected[entry["path"]]:
|
|
492
|
+
raise InstallError(f"private install record has an invalid config ownership claim: {entry['path']}")
|
|
493
|
+
self._safe_owned_path(Path(entry["path"]), self.launch_root)
|
|
494
|
+
|
|
495
|
+
def _owned_hash(self, record: dict[str, Any] | None, path: Path) -> str | None:
|
|
496
|
+
if not record:
|
|
497
|
+
return None
|
|
498
|
+
for entry in [record.get("launcher"), *(record.get("config_files") or [])]:
|
|
499
|
+
if isinstance(entry, dict) and entry.get("path") == str(path):
|
|
500
|
+
value = entry.get("sha256")
|
|
501
|
+
return value if isinstance(value, str) else None
|
|
502
|
+
return None
|
|
503
|
+
|
|
504
|
+
def _write_owned(self, path: Path, content: bytes, old: dict[str, Any] | None, mode: int) -> dict[str, str] | None:
|
|
505
|
+
root = self.bin_root if path == self.bin_root / "agent-launch" else self.launch_root
|
|
506
|
+
self._safe_owned_path(path, root)
|
|
507
|
+
expected = self._owned_hash(old, path)
|
|
508
|
+
if path.exists() and (path.is_symlink() or (expected is None and _sha256(path) != hashlib.sha256(content).hexdigest())
|
|
509
|
+
or (expected is not None and _sha256(path) != expected)):
|
|
510
|
+
return None
|
|
511
|
+
_atomic_bytes(path, content, mode)
|
|
512
|
+
return {"path": str(path), "sha256": hashlib.sha256(content).hexdigest()}
|
|
513
|
+
|
|
514
|
+
def _launcher_body(self, package_root: Path) -> bytes:
|
|
515
|
+
quoted = shlex.quote(str(package_root))
|
|
516
|
+
return ("#!/bin/sh\n"
|
|
517
|
+
"export AGENT_BIOS_PRIVATE_CORPUS=1\n"
|
|
518
|
+
f"export AGENT_BIOS_PACKAGE_ROOT={quoted}\n"
|
|
519
|
+
"exec python3 \"$AGENT_BIOS_PACKAGE_ROOT/launch/agent-launch.py\" \"$@\"\n").encode("utf-8")
|
|
520
|
+
|
|
521
|
+
@_serialized
|
|
522
|
+
def install(self, domains: str | None = None, dry_run: bool = False) -> dict[str, Any]:
|
|
523
|
+
files = self._package_files()
|
|
524
|
+
requested = self._normalized_domains(domains)
|
|
525
|
+
prior = self._old_record()
|
|
526
|
+
# An update without an explicit selection must not broaden a saved
|
|
527
|
+
# core-only or domain-limited environment back to every domain.
|
|
528
|
+
if domains is None and isinstance((prior or {}).get("selection"), list):
|
|
529
|
+
requested = prior["selection"]
|
|
530
|
+
# A preview is strictly read-only. A real install first refuses an
|
|
531
|
+
# unrelated reset, then finishes only its own pending install before
|
|
532
|
+
# creating an immutable release directory.
|
|
533
|
+
if not dry_run:
|
|
534
|
+
pending = pending_status(self.state_root)["transactions"]
|
|
535
|
+
if any(row["owner"] == "installer" and row["kind"] == "reset" for row in pending):
|
|
536
|
+
raise InstallError("pending reset requires recovery before install")
|
|
537
|
+
with operation_scope(self.state_root):
|
|
538
|
+
self._recover_pending_transactions()
|
|
539
|
+
release, entries, digest = self._copy_release(files, dry_run)
|
|
540
|
+
if dry_run:
|
|
541
|
+
return {"dry_run": True, "release": str(release), "release_digest": digest,
|
|
542
|
+
"files": len(entries), "selection": requested}
|
|
543
|
+
# Stage the immutable baseline before publishing either source pointers
|
|
544
|
+
# or launcher projections. Store owns those source pointers; this
|
|
545
|
+
# coordinator owns all cross-owner ordering.
|
|
546
|
+
store = self._store(release)
|
|
547
|
+
prepare = getattr(store, "prepare_install", None)
|
|
548
|
+
if not callable(prepare):
|
|
549
|
+
raise InstallError("private corpus store does not support staged install recovery")
|
|
550
|
+
candidate = prepare(requested)
|
|
551
|
+
if not isinstance(candidate, dict) or not isinstance(candidate.get("details"), dict):
|
|
552
|
+
raise InstallError("private corpus store returned an invalid install candidate")
|
|
553
|
+
details = candidate["details"]
|
|
554
|
+
baseline_ref = details.get("baseline_ref")
|
|
555
|
+
if not isinstance(baseline_ref, str):
|
|
556
|
+
raise InstallError("private corpus store candidate has no baseline ref")
|
|
557
|
+
owned: list[tuple[Path, bytes, int]] = [
|
|
558
|
+
(self.bin_root / "agent-launch", self._launcher_body(release), 0o755),
|
|
559
|
+
(self.launch_root / "profiles.toml", (release / "launch" / "agent-launch.toml").read_bytes(), 0o644),
|
|
560
|
+
]
|
|
561
|
+
owned.extend((self.launch_root / "i18n" / source.name, source.read_bytes(), 0o644)
|
|
562
|
+
for source in sorted((release / "launch" / "i18n").glob("*.toml")))
|
|
563
|
+
conflicts: list[str] = []
|
|
564
|
+
for path, content, _mode in owned:
|
|
565
|
+
current = self._file_version(path)
|
|
566
|
+
expected = self._owned_hash(prior, path)
|
|
567
|
+
if current["exists"] and current.get("sha256") != hashlib.sha256(content).hexdigest() and \
|
|
568
|
+
(expected is None or current.get("sha256") != expected):
|
|
569
|
+
conflicts.append(str(path))
|
|
570
|
+
if conflicts:
|
|
571
|
+
raise InstallError("private install cannot replace user-owned paths: " + ", ".join(conflicts))
|
|
572
|
+
launcher = {"path": str(owned[0][0]), "sha256": hashlib.sha256(owned[0][1]).hexdigest()}
|
|
573
|
+
config = [{"path": str(path), "sha256": hashlib.sha256(content).hexdigest()}
|
|
574
|
+
for path, content, _mode in owned[1:]]
|
|
575
|
+
record = {
|
|
576
|
+
"schema_version": SCHEMA_VERSION, "package_root": str(release), "release_digest": digest,
|
|
577
|
+
"installed_files": entries, "selection": requested, "baseline_ref": baseline_ref,
|
|
578
|
+
"launcher": launcher, "config_files": config, "created_at": int(time.time()),
|
|
579
|
+
"mode": "private-session-scoped", "needs_action": [],
|
|
580
|
+
}
|
|
581
|
+
paths = [{"path": str(path), "before": self._file_version(path),
|
|
582
|
+
"after": self._planned_version(content), "mode": mode}
|
|
583
|
+
for path, content, mode in owned]
|
|
584
|
+
paths.extend([
|
|
585
|
+
{"path": str(self.record_path), "before": self._file_version(self.record_path),
|
|
586
|
+
"after": self._planned_version(_canonical(record) + b"\n"), "mode": 0o600},
|
|
587
|
+
{"path": str(self.state_root / "corpus-status.json"),
|
|
588
|
+
"before": self._file_version(self.state_root / "corpus-status.json"),
|
|
589
|
+
"after": self._planned_version(self._launcher_status_bytes(release, requested)), "mode": 0o600},
|
|
590
|
+
])
|
|
591
|
+
transaction_id = str(candidate.get("transaction_id") or uuid.uuid4().hex)
|
|
592
|
+
journal = self._transaction_path(transaction_id)
|
|
593
|
+
# Source candidates are persisted by CorpusStore under the transaction
|
|
594
|
+
# id. Keep only hashes here: personal source text is free-form and
|
|
595
|
+
# must never be accidentally archived by the installer journal.
|
|
596
|
+
association = {"transaction_id": transaction_id, "baseline_ref": baseline_ref,
|
|
597
|
+
"selected_baseline_ref": details.get("selected_baseline_ref"),
|
|
598
|
+
"items": details.get("items"),
|
|
599
|
+
"source_before_sha256": hashlib.sha256(_canonical(candidate.get("before"))).hexdigest(),
|
|
600
|
+
"source_after_sha256": hashlib.sha256(_canonical(candidate.get("after"))).hexdigest()}
|
|
601
|
+
journal_data = {"schema_version": SCHEMA_VERSION, "owner": "installer", "kind": "install", "state": "PREPARED",
|
|
602
|
+
"phase": "preflight", "candidate": association, "paths": paths,
|
|
603
|
+
"created_at": int(time.time())}
|
|
604
|
+
self._journal_write(journal, journal_data)
|
|
605
|
+
try:
|
|
606
|
+
with operation_scope(self.state_root):
|
|
607
|
+
self._finish_install_transaction(journal, journal_data, store, candidate)
|
|
608
|
+
return {"dry_run": False, "stored": True, "activation": "unverified", "record": record}
|
|
609
|
+
except BaseException as exc:
|
|
610
|
+
journal_data["state"] = "NEEDS_RECOVERY"
|
|
611
|
+
journal_data["error"] = type(exc).__name__
|
|
612
|
+
self._journal_write(journal, journal_data)
|
|
613
|
+
raise
|
|
614
|
+
|
|
615
|
+
def _finish_install_transaction(self, journal: Path, data: dict[str, Any], store: Any,
|
|
616
|
+
candidate: dict[str, Any] | None) -> None:
|
|
617
|
+
self._assert_preflight_paths(data["paths"])
|
|
618
|
+
data["state"], data["phase"] = "APPLYING", "projections"
|
|
619
|
+
self._journal_write(journal, data)
|
|
620
|
+
# Write candidate projections before source publication. A crash here
|
|
621
|
+
# is harmless to readers because the journal guard is already durable.
|
|
622
|
+
for entry in data["paths"]:
|
|
623
|
+
path = Path(entry["path"])
|
|
624
|
+
if not self._matches_version(path, entry["after"]):
|
|
625
|
+
self._write_planned(path, entry["after"], int(entry["mode"]))
|
|
626
|
+
data["phase"] = "source"
|
|
627
|
+
self._journal_write(journal, data)
|
|
628
|
+
commit = getattr(store, "commit_install", None)
|
|
629
|
+
if not callable(commit):
|
|
630
|
+
raise InstallError("private corpus store does not support staged install commit")
|
|
631
|
+
if candidate is None:
|
|
632
|
+
# The store persists the complete source candidate under this id;
|
|
633
|
+
# passing only it prevents installer journals from duplicating user
|
|
634
|
+
# authoring bytes (which could include sensitive free text).
|
|
635
|
+
commit({"transaction_id": data["candidate"]["transaction_id"]})
|
|
636
|
+
else:
|
|
637
|
+
commit(candidate)
|
|
638
|
+
data["phase"] = "verify"
|
|
639
|
+
self._journal_write(journal, data)
|
|
640
|
+
# The record/status are included in paths and now exact. Verify the
|
|
641
|
+
# installed source and every owned projection before success is visible.
|
|
642
|
+
self._verify_transaction_install(data)
|
|
643
|
+
data["state"], data["phase"], data["committed_at"] = "COMMITTED", "complete", int(time.time())
|
|
644
|
+
self._journal_write(journal, data)
|
|
645
|
+
|
|
646
|
+
def _verify_transaction_install(self, data: dict[str, Any]) -> None:
|
|
647
|
+
for entry in data["paths"]:
|
|
648
|
+
if not self._matches_version(Path(entry["path"]), entry["after"]):
|
|
649
|
+
raise InstallError(f"transaction projection did not verify: {entry['path']}")
|
|
650
|
+
record = _read_json(self.record_path)
|
|
651
|
+
release = Path(record["package_root"])
|
|
652
|
+
self._verify_release(release, record["installed_files"])
|
|
653
|
+
self._validate_owned_record(record, release)
|
|
654
|
+
source = _read_json(self.runtime / "state.json")
|
|
655
|
+
if source.get("last_successful_install_ref") != record.get("baseline_ref"):
|
|
656
|
+
raise InstallError("staged corpus baseline was not published")
|
|
657
|
+
|
|
658
|
+
def _recover_pending_transactions(self) -> None:
|
|
659
|
+
root = self.runtime / "installer-transactions"
|
|
660
|
+
if not root.exists():
|
|
661
|
+
return
|
|
662
|
+
for journal in sorted(root.glob("*/journal.json")):
|
|
663
|
+
data = _read_json(journal)
|
|
664
|
+
if data.get("state") not in {"PREPARED", "APPLYING", "NEEDS_RECOVERY"}:
|
|
665
|
+
continue
|
|
666
|
+
if data.get("kind") != "install" or not isinstance(data.get("candidate"), dict) \
|
|
667
|
+
or not isinstance(data.get("paths"), list):
|
|
668
|
+
raise InstallError(f"unrecoverable transaction journal: {journal}")
|
|
669
|
+
association = data["candidate"]
|
|
670
|
+
release_record = next((entry for entry in data["paths"] if entry.get("path") == str(self.record_path)), None)
|
|
671
|
+
if not isinstance(release_record, dict):
|
|
672
|
+
raise InstallError(f"transaction journal lacks install record: {journal}")
|
|
673
|
+
encoded = release_record.get("after", {}).get("bytes_b64")
|
|
674
|
+
if not isinstance(encoded, str):
|
|
675
|
+
raise InstallError(f"transaction journal lacks record bytes: {journal}")
|
|
676
|
+
record = json.loads(base64.b64decode(encoded.encode("ascii"), validate=True))
|
|
677
|
+
release = Path(record.get("package_root", ""))
|
|
678
|
+
if not release.is_dir() or release.is_symlink():
|
|
679
|
+
raise InstallError(f"transaction release is unavailable: {journal}")
|
|
680
|
+
try:
|
|
681
|
+
with operation_scope(self.state_root):
|
|
682
|
+
self._finish_install_transaction(journal, data, self._store(release), None)
|
|
683
|
+
except BaseException as exc:
|
|
684
|
+
data["state"], data["error"] = "NEEDS_RECOVERY", type(exc).__name__
|
|
685
|
+
self._journal_write(journal, data)
|
|
686
|
+
raise InstallError(f"pending install requires recovery: {journal}") from exc
|
|
687
|
+
|
|
688
|
+
@_serialized
|
|
689
|
+
def verify(self) -> dict[str, Any]:
|
|
690
|
+
try:
|
|
691
|
+
guard_pending(self.state_root)
|
|
692
|
+
except TransactionError as exc:
|
|
693
|
+
raise InstallError(str(exc)) from exc
|
|
694
|
+
record = self._old_record()
|
|
695
|
+
if record is None:
|
|
696
|
+
raise InstallError("no private install record")
|
|
697
|
+
if record.get("schema_version") != SCHEMA_VERSION:
|
|
698
|
+
raise InstallError("private install record schema mismatch")
|
|
699
|
+
if record.get("needs_action"):
|
|
700
|
+
raise InstallError("private install is incomplete; resolve owned-path conflicts: "
|
|
701
|
+
+ ", ".join(str(path) for path in record["needs_action"]))
|
|
702
|
+
release = Path(record.get("package_root", ""))
|
|
703
|
+
releases = self.runtime / "releases"
|
|
704
|
+
if not _inside(release, releases) or release.is_symlink() or not release.is_dir():
|
|
705
|
+
raise InstallError("private install record has unsafe package root")
|
|
706
|
+
entries = record.get("installed_files")
|
|
707
|
+
if not isinstance(entries, list) or not entries:
|
|
708
|
+
raise InstallError("private install record has no installed files")
|
|
709
|
+
self._verify_release(release, entries)
|
|
710
|
+
self._validate_owned_record(record, release)
|
|
711
|
+
catalog = self._catalog(release)
|
|
712
|
+
if not catalog.get("items"):
|
|
713
|
+
raise InstallError("private catalog is empty")
|
|
714
|
+
source_state = _read_json(self.runtime / "state.json")
|
|
715
|
+
if source_state.get("last_successful_install_ref") != record.get("baseline_ref"):
|
|
716
|
+
raise InstallError("private baseline does not match install record")
|
|
717
|
+
for entry in [record.get("launcher"), *(record.get("config_files") or [])]:
|
|
718
|
+
if not isinstance(entry, dict):
|
|
719
|
+
continue
|
|
720
|
+
path = Path(entry.get("path", ""))
|
|
721
|
+
if path.is_symlink() or not path.is_file() or _sha256(path) != entry.get("sha256"):
|
|
722
|
+
raise InstallError(f"private owned file drifted: {path}")
|
|
723
|
+
launcher_status = _read_json(self.state_root / "corpus-status.json")
|
|
724
|
+
domains = launcher_status.get("domains")
|
|
725
|
+
if launcher_status.get("repo") != str(release) or not isinstance(domains, dict) or \
|
|
726
|
+
not isinstance(domains.get("available"), list) or not isinstance(domains.get("applied"), list):
|
|
727
|
+
raise InstallError("launcher corpus-status projection is missing or malformed")
|
|
728
|
+
return {"stored": True, "catalog_items": len(catalog["items"]), "baseline_ref": record["baseline_ref"],
|
|
729
|
+
"activation": "unverified"}
|
|
730
|
+
|
|
731
|
+
@_serialized
|
|
732
|
+
def status(self) -> dict[str, Any]:
|
|
733
|
+
try:
|
|
734
|
+
pending = pending_status(self.state_root)
|
|
735
|
+
except TransactionError as exc:
|
|
736
|
+
raise InstallError(str(exc)) from exc
|
|
737
|
+
record = self._old_record()
|
|
738
|
+
if not record:
|
|
739
|
+
return {"installed": False, "activation": "unverified", **pending}
|
|
740
|
+
return {"installed": True, "package_root": record.get("package_root"),
|
|
741
|
+
"baseline_ref": record.get("baseline_ref"), "needs_action": record.get("needs_action", []),
|
|
742
|
+
"activation": "unverified", **pending}
|
|
743
|
+
|
|
744
|
+
def _active_intents(self) -> list[Path]:
|
|
745
|
+
root = self.runtime / "activations"
|
|
746
|
+
if not root.is_dir():
|
|
747
|
+
return []
|
|
748
|
+
active = []
|
|
749
|
+
for journal in root.glob("*/journal.json"):
|
|
750
|
+
with contextlib.suppress(InstallError):
|
|
751
|
+
data = _read_json(journal)
|
|
752
|
+
if data.get("state") in {"PREPARED", "HOST_OBSERVED"}:
|
|
753
|
+
active.append(journal)
|
|
754
|
+
return active
|
|
755
|
+
|
|
756
|
+
@_serialized
|
|
757
|
+
def uninstall(self, dry_run: bool = False) -> dict[str, Any]:
|
|
758
|
+
record = self._old_record()
|
|
759
|
+
if record is None:
|
|
760
|
+
return {"removed": [], "preserved": ["no private install record"]}
|
|
761
|
+
release = Path(record.get("package_root", ""))
|
|
762
|
+
if not _inside(release, self.runtime / "releases") or release.is_symlink() or not release.is_dir():
|
|
763
|
+
raise InstallError("private install record has unsafe package root")
|
|
764
|
+
self._validate_owned_record(record, release)
|
|
765
|
+
removed: list[str] = []
|
|
766
|
+
preserved: list[str] = []
|
|
767
|
+
for entry in [record.get("launcher"), *(record.get("config_files") or [])]:
|
|
768
|
+
if not isinstance(entry, dict):
|
|
769
|
+
continue
|
|
770
|
+
path = Path(entry.get("path", ""))
|
|
771
|
+
expected = entry.get("sha256")
|
|
772
|
+
if path.is_file() and not path.is_symlink() and isinstance(expected, str) and _sha256(path) == expected:
|
|
773
|
+
if not dry_run:
|
|
774
|
+
path.unlink()
|
|
775
|
+
removed.append(str(path))
|
|
776
|
+
elif path.exists():
|
|
777
|
+
preserved.append(str(path))
|
|
778
|
+
intents = self._active_intents()
|
|
779
|
+
if intents:
|
|
780
|
+
preserved.extend(str(path) for path in intents)
|
|
781
|
+
elif _inside(release, self.runtime / "releases") and release.is_dir() and not release.is_symlink():
|
|
782
|
+
if not dry_run:
|
|
783
|
+
shutil.rmtree(release)
|
|
784
|
+
removed.append(str(release))
|
|
785
|
+
else:
|
|
786
|
+
preserved.append(str(release))
|
|
787
|
+
if not intents and not dry_run:
|
|
788
|
+
self.record_path.unlink(missing_ok=True)
|
|
789
|
+
# User package/overlays/learnings and session snapshots/pins are purposely
|
|
790
|
+
# not enumerated or deleted here. No whole state-root removal occurs.
|
|
791
|
+
return {"removed": removed, "preserved": preserved,
|
|
792
|
+
"retained": [str(self.user_root), str(self.state_root / "sessions")]}
|
|
793
|
+
|
|
794
|
+
def _reset_layout(self, record: dict[str, Any], release: Path) -> tuple[list[Path], Path, Path, list[tuple[Path, bytes, int]]]:
|
|
795
|
+
local = [self.launch_root / name for name in ("presets.local.toml", "review-methods.local.toml", "launcher.local.toml")]
|
|
796
|
+
connections = self.launch_root.parent / "agent-bios"
|
|
797
|
+
cleanup = [*local, connections / "ingest-url"]
|
|
798
|
+
owned = [(self.bin_root / "agent-launch", self._launcher_body(release), 0o755),
|
|
799
|
+
(self.launch_root / "profiles.toml", (release / "launch" / "agent-launch.toml").read_bytes(), 0o644)]
|
|
800
|
+
owned.extend((self.launch_root / "i18n" / p.name, p.read_bytes(), 0o644)
|
|
801
|
+
for p in sorted((release / "launch" / "i18n").glob("*.toml")))
|
|
802
|
+
return cleanup, connections / "token", connections, owned
|
|
803
|
+
|
|
804
|
+
def _learning_versions(self) -> dict[str, dict[str, Any]]:
|
|
805
|
+
"""Learning events are authoring input, not reset-owned cleanup."""
|
|
806
|
+
return {host: self._file_version(self.user_root / "learnings" / host / "events.jsonl")
|
|
807
|
+
for host in ("claude", "codex")}
|
|
808
|
+
|
|
809
|
+
def _reset_preview(self) -> dict[str, Any]:
|
|
810
|
+
record = self._old_record()
|
|
811
|
+
if record is None:
|
|
812
|
+
raise InstallError("cannot reset before private install")
|
|
813
|
+
release = Path(record.get("package_root", ""))
|
|
814
|
+
if not _inside(release, self.runtime / "releases") or release.is_symlink() or not release.is_dir():
|
|
815
|
+
raise InstallError("private install record has unsafe package root")
|
|
816
|
+
self._validate_owned_record(record, release)
|
|
817
|
+
cleanup, secret, _connections, owned = self._reset_layout(record, release)
|
|
818
|
+
targets = [{"path": str(path), "before": self._file_version(path), "after": {"exists": False}, "mode": 0o600,
|
|
819
|
+
"archive": True} for path in cleanup]
|
|
820
|
+
targets.extend({"path": str(path), "before": self._file_version(path), "after": self._planned_version(body),
|
|
821
|
+
"mode": mode, "archive": False} for path, body, mode in owned)
|
|
822
|
+
status_path = self.state_root / "corpus-status.json"
|
|
823
|
+
targets.append({"path": str(status_path), "before": self._file_version(status_path),
|
|
824
|
+
"after": self._planned_version(self._launcher_status_bytes(
|
|
825
|
+
release, record["selection"], record.get("created_at") if isinstance(record.get("created_at"), int) else 0)),
|
|
826
|
+
"mode": 0o600, "archive": False})
|
|
827
|
+
secret_before = self._file_version(secret, allow_bytes=False)
|
|
828
|
+
pending = pending_status(self.state_root)
|
|
829
|
+
learning = self._learning_versions()
|
|
830
|
+
fingerprint = hashlib.sha256(_canonical({"record": _sha256(self.record_path), "runtime": self._file_version(self.runtime / "state.json"),
|
|
831
|
+
"user": self._file_version(self.user_root / "state.json"), "targets": targets,
|
|
832
|
+
"secret": secret_before, "learning": learning, "pending": pending["transactions"]})).hexdigest()
|
|
833
|
+
return {"preview": True, "expected_revision": fingerprint, "pending": pending,
|
|
834
|
+
"archive": [entry["path"] for entry in targets if entry["archive"] and entry["before"]["exists"]],
|
|
835
|
+
"delete_without_archive": [str(secret)] if secret_before["exists"] else [],
|
|
836
|
+
"restore_defaults": [entry["path"] for entry in targets if not entry["archive"]],
|
|
837
|
+
"retained": [str(self.state_root / "sessions"), str(self.user_root / "learnings")],
|
|
838
|
+
"record": record, "release": release, "targets": targets, "secret_before": secret_before,
|
|
839
|
+
"learning_before": learning}
|
|
840
|
+
|
|
841
|
+
@_serialized
|
|
842
|
+
def reset(self, apply: bool = False, yes: bool = False, expected_revision: str | None = None) -> dict[str, Any]:
|
|
843
|
+
"""Preview first; an apply accepts exactly that observed generation."""
|
|
844
|
+
preview = self._reset_preview()
|
|
845
|
+
if not apply:
|
|
846
|
+
return {key: value for key, value in preview.items() if key not in {"record", "release", "targets", "secret_before", "learning_before"}}
|
|
847
|
+
if not yes or not isinstance(expected_revision, str):
|
|
848
|
+
raise InstallError("reset apply requires --yes and preview expected_revision")
|
|
849
|
+
pending = [row for row in preview["pending"]["transactions"] if row["owner"] == "installer"]
|
|
850
|
+
if any(row["kind"] == "install" for row in pending):
|
|
851
|
+
raise InstallError("pending install requires recovery before reset")
|
|
852
|
+
for row in pending:
|
|
853
|
+
data = _read_json(Path(row["path"]))
|
|
854
|
+
if data.get("owner") != "installer" or data.get("kind") != "reset":
|
|
855
|
+
raise InstallError(f"pending reset requires fresh preview: {row['path']}")
|
|
856
|
+
if data.get("intent", {}).get("accepted_revision") == expected_revision:
|
|
857
|
+
return self._recover_reset(Path(row["path"]), data)
|
|
858
|
+
if preview["expected_revision"] != expected_revision:
|
|
859
|
+
raise InstallError("reset preview is stale; preview again before applying")
|
|
860
|
+
record, release, targets = preview["record"], preview["release"], preview["targets"]
|
|
861
|
+
with operation_scope(self.state_root):
|
|
862
|
+
plan = self._store(release).plan({"operation": "reset"})
|
|
863
|
+
reset_id = f"{int(time.time())}-{uuid.uuid4().hex[:12]}"
|
|
864
|
+
journal = self.runtime / "resets" / reset_id / "journal.json"
|
|
865
|
+
archive = self.user_root / "history" / reset_id / "launcher-config"
|
|
866
|
+
data = {"schema_version": SCHEMA_VERSION, "owner": "installer", "kind": "reset", "state": "PREPARED", "phase": "preflight",
|
|
867
|
+
"archive": str(archive), "targets": targets, "secret_before": preview["secret_before"], "learning_before": preview["learning_before"],
|
|
868
|
+
"intent": {"plan_id": plan["plan_id"], "expected_revision": plan["expected_revision"],
|
|
869
|
+
"accepted_revision": expected_revision, "record_sha256": _sha256(self.record_path)},
|
|
870
|
+
"retained": preview["retained"], "created_at": int(time.time())}
|
|
871
|
+
_atomic_json(journal, data)
|
|
872
|
+
# Keep the old publication guard until the replacement intent is durable.
|
|
873
|
+
for row in pending:
|
|
874
|
+
prior = _read_json(Path(row["path"]))
|
|
875
|
+
prior["state"] = "SUPERSEDED"
|
|
876
|
+
_atomic_json(Path(row["path"]), prior)
|
|
877
|
+
return self._recover_reset(journal, data)
|
|
878
|
+
|
|
879
|
+
def _source_pair_ok(self, data: dict[str, Any]) -> bool:
|
|
880
|
+
plan_path = self.runtime / "transactions" / data["intent"]["plan_id"] / "journal.json"
|
|
881
|
+
plan = _read_json(plan_path).get("plan", {})
|
|
882
|
+
before, after = plan.get("before", {}), plan.get("after", {})
|
|
883
|
+
runtime, user = _read_json(self.runtime / "state.json"), _read_json(self.user_root / "state.json")
|
|
884
|
+
return runtime in (before.get("runtime"), after.get("runtime")) and user in (before.get("user"), after.get("user"))
|
|
885
|
+
|
|
886
|
+
def _recover_reset(self, journal: Path, data: dict[str, Any]) -> dict[str, Any]:
|
|
887
|
+
"""Preflight every target and source pair before changing one byte."""
|
|
888
|
+
try:
|
|
889
|
+
if data["intent"].get("record_sha256") != _sha256(self.record_path) or not self._source_pair_ok(data):
|
|
890
|
+
raise InstallError("reset state changed; obtain a fresh preview")
|
|
891
|
+
if data.get("learning_before") != self._learning_versions():
|
|
892
|
+
raise InstallError("reset learning source changed; obtain a fresh preview")
|
|
893
|
+
targets = data.get("targets")
|
|
894
|
+
if not isinstance(targets, list):
|
|
895
|
+
raise InstallError("pending reset requires fresh preview")
|
|
896
|
+
for entry in targets:
|
|
897
|
+
if not (self._matches_version(Path(entry["path"]), entry["before"]) or self._matches_version(Path(entry["path"]), entry["after"])):
|
|
898
|
+
raise InstallError(f"reset target changed; obtain a fresh preview: {entry['path']}")
|
|
899
|
+
secret = self.launch_root.parent / "agent-bios" / "token"; prior = data.get("secret_before", {"exists": False})
|
|
900
|
+
if not (self._matches_version(secret, prior) or not secret.exists()):
|
|
901
|
+
raise InstallError("reset token changed; obtain a fresh preview")
|
|
902
|
+
release = Path(self._old_record()["package_root"])
|
|
903
|
+
archive = Path(data["archive"])
|
|
904
|
+
data.update({"state": "APPLYING", "phase": "archive"}); _atomic_json(journal, data)
|
|
905
|
+
for entry in targets:
|
|
906
|
+
path = Path(entry["path"])
|
|
907
|
+
if entry.get("archive") and entry["before"].get("exists"):
|
|
908
|
+
target = archive / path.name
|
|
909
|
+
if not target.exists(): self._write_planned(target, entry["before"], 0o600)
|
|
910
|
+
if entry.get("archive") and not self._matches_version(path, entry["after"]): self._write_planned(path, entry["after"], int(entry["mode"]))
|
|
911
|
+
data["phase"] = "source"; _atomic_json(journal, data)
|
|
912
|
+
with operation_scope(self.state_root):
|
|
913
|
+
result = self._store(release).apply(data["intent"]["plan_id"], data["intent"]["expected_revision"])
|
|
914
|
+
data["result"] = result; data["phase"] = "projections"; _atomic_json(journal, data)
|
|
915
|
+
for entry in targets:
|
|
916
|
+
if not entry.get("archive") and not self._matches_version(Path(entry["path"]), entry["after"]): self._write_planned(Path(entry["path"]), entry["after"], int(entry["mode"]))
|
|
917
|
+
if prior.get("exists"):
|
|
918
|
+
if self._matches_version(secret, prior):
|
|
919
|
+
secret.unlink()
|
|
920
|
+
elif secret.exists():
|
|
921
|
+
raise InstallError("reset token changed; obtain a fresh preview")
|
|
922
|
+
data.update({"state": "COMMITTED", "phase": "complete", "committed_at": int(time.time())}); _atomic_json(journal, data)
|
|
923
|
+
return {"preview": False, "reset": data["result"], "archive": data["archive"], "retained": data["retained"], "recovered": True}
|
|
924
|
+
except BaseException as exc:
|
|
925
|
+
data.update({"state": "NEEDS_RECOVERY", "error": type(exc).__name__}); _atomic_json(journal, data); raise
|
|
926
|
+
|
|
927
|
+
def _legacy_manifest_files(self, needs_action: list[str], read=None) -> list[Path]:
|
|
928
|
+
manifest = self.state_root / "manifest.txt"
|
|
929
|
+
if manifest.is_symlink():
|
|
930
|
+
needs_action.append(f"unsafe legacy manifest symlink: {manifest}")
|
|
931
|
+
return []
|
|
932
|
+
content = read(manifest) if read else manifest.read_bytes() if manifest.is_file() else None
|
|
933
|
+
if content is None:
|
|
934
|
+
return []
|
|
935
|
+
private = self._old_record()
|
|
936
|
+
protected: set[str] = set()
|
|
937
|
+
if private is not None:
|
|
938
|
+
self._validate_owned_record(private, Path(private.get("package_root", "")))
|
|
939
|
+
protected = {str(entry["path"]) for entry in
|
|
940
|
+
[private["launcher"], *private.get("config_files", [])]}
|
|
941
|
+
owned = self._legacy_manifest_destinations()
|
|
942
|
+
paths: list[Path] = []
|
|
943
|
+
body = content.decode("utf-8")
|
|
944
|
+
for raw in body.splitlines():
|
|
945
|
+
if not raw.strip():
|
|
946
|
+
continue
|
|
947
|
+
path = Path(raw.strip())
|
|
948
|
+
if str(path) in protected:
|
|
949
|
+
continue
|
|
950
|
+
if not path.is_absolute() or path in {self.claude_root / "CLAUDE.md", self.codex_root / "AGENTS.md"}:
|
|
951
|
+
needs_action.append(f"unowned legacy manifest target: {path}")
|
|
952
|
+
continue
|
|
953
|
+
if path.is_symlink():
|
|
954
|
+
needs_action.append(f"unsafe legacy manifest target: {path}")
|
|
955
|
+
continue
|
|
956
|
+
try:
|
|
957
|
+
self._migration_target_root(path)
|
|
958
|
+
except InstallError as exc:
|
|
959
|
+
needs_action.append(str(exc))
|
|
960
|
+
continue
|
|
961
|
+
if path not in owned:
|
|
962
|
+
# A prior installer wrote broad directory walks into its manifest.
|
|
963
|
+
# Root containment proves only where a file lives, never who wrote it.
|
|
964
|
+
# A stale absent entry is harmless; an existing unknown file is not.
|
|
965
|
+
if path.exists():
|
|
966
|
+
needs_action.append(f"unowned legacy manifest target: {path}")
|
|
967
|
+
continue
|
|
968
|
+
if path.exists() and not path.is_file():
|
|
969
|
+
needs_action.append(f"legacy manifest target is not a regular file: {path}")
|
|
970
|
+
continue
|
|
971
|
+
paths.append(path)
|
|
972
|
+
return paths
|
|
973
|
+
|
|
974
|
+
def _legacy_manifest_destinations(self, package_root: Path | None = None) -> set[Path]:
|
|
975
|
+
"""Return only paths a legacy installer could derive from this source.
|
|
976
|
+
|
|
977
|
+
The old manifest included a recursive scan of ``central``. This map is
|
|
978
|
+
deliberately source-derived instead of treating a shared native root as
|
|
979
|
+
a claim of ownership, so a same-directory user file remains protected.
|
|
980
|
+
"""
|
|
981
|
+
source_root = (package_root or self.repo).resolve()
|
|
982
|
+
owned: set[Path] = {
|
|
983
|
+
self.state_root / "version.json",
|
|
984
|
+
self.claude_root / "central" / "bundle.md",
|
|
985
|
+
self.claude_root / "personal" / "learnings.jsonl",
|
|
986
|
+
self.codex_root / "personal" / "learnings.jsonl",
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
def source_file(relative: str) -> Path | None:
|
|
990
|
+
path = source_root / relative
|
|
991
|
+
return path if path.is_file() and not path.is_symlink() else None
|
|
992
|
+
|
|
993
|
+
native = (
|
|
994
|
+
("wrappers/codex-run.sh", self.codex_root / "bin" / "codex-run"),
|
|
995
|
+
("wrappers/codex-helm.sh", self.codex_root / "bin" / "codex-helm"),
|
|
996
|
+
("wrappers/claude-run.sh", self.claude_root / "bin" / "claude-run"),
|
|
997
|
+
("launch/agent-launch.py", self.bin_root / "agent-launch"),
|
|
998
|
+
("launch/agent-launch.toml", self.launch_root / "profiles.toml"),
|
|
999
|
+
("launch/agent-launch.zsh", self.launch_root / "shell.zsh"),
|
|
1000
|
+
)
|
|
1001
|
+
owned.update(destination for source, destination in native if source_file(source) is not None)
|
|
1002
|
+
i18n = source_root / "launch" / "i18n"
|
|
1003
|
+
if i18n.is_dir() and not i18n.is_symlink():
|
|
1004
|
+
owned.update(self.launch_root / "i18n" / source.name
|
|
1005
|
+
for source in i18n.glob("*.toml") if source.is_file() and not source.is_symlink())
|
|
1006
|
+
|
|
1007
|
+
catalog = self._catalog(source_root)
|
|
1008
|
+
for item in catalog.get("items", []):
|
|
1009
|
+
if not isinstance(item, dict):
|
|
1010
|
+
continue
|
|
1011
|
+
kind = item.get("kind")
|
|
1012
|
+
origin = item.get("origin")
|
|
1013
|
+
source = origin.get("source_path") if isinstance(origin, dict) else None
|
|
1014
|
+
members = item.get("members")
|
|
1015
|
+
if not isinstance(source, str) or not isinstance(members, dict):
|
|
1016
|
+
continue
|
|
1017
|
+
for member in members:
|
|
1018
|
+
if not isinstance(member, str):
|
|
1019
|
+
continue
|
|
1020
|
+
relative = PurePosixPath(member)
|
|
1021
|
+
if relative.is_absolute() or ".." in relative.parts or len(relative.parts) < 2:
|
|
1022
|
+
continue
|
|
1023
|
+
tail = Path(*relative.parts[1:])
|
|
1024
|
+
if kind == "guide" and source.startswith("claude/guides/") and relative.parts[0] == "guides":
|
|
1025
|
+
owned.update({self.claude_root / "guides" / tail,
|
|
1026
|
+
self.claude_root / "central" / "guides" / tail,
|
|
1027
|
+
self.codex_root / "guides" / tail})
|
|
1028
|
+
elif kind == "hook" and source.startswith("claude/hooks/") and relative.parts[0] == "hooks":
|
|
1029
|
+
owned.add(self.claude_root / "central" / "hooks" / tail)
|
|
1030
|
+
elif kind == "agent" and source.startswith("claude/agents/") and relative.parts[0] == "agents":
|
|
1031
|
+
owned.add(self.claude_root / "central" / "agents" / tail)
|
|
1032
|
+
elif kind == "skill" and source.startswith("claude/skills/") and relative.parts[0] == "skills":
|
|
1033
|
+
owned.update({self.claude_root / "skills" / tail,
|
|
1034
|
+
self.codex_root / "skills" / tail})
|
|
1035
|
+
|
|
1036
|
+
agents = source_root / "codex" / "agents"
|
|
1037
|
+
if agents.is_dir() and not agents.is_symlink():
|
|
1038
|
+
owned.update(self.codex_root / "agents" / source.name
|
|
1039
|
+
for source in agents.glob("*.toml") if source.is_file() and not source.is_symlink())
|
|
1040
|
+
return owned
|
|
1041
|
+
|
|
1042
|
+
def _migration_cleanup_destinations(self, release: Path) -> set[Path]:
|
|
1043
|
+
"""The pinned release plus bounded native edits define replay authority."""
|
|
1044
|
+
return self._legacy_manifest_destinations(release) | {
|
|
1045
|
+
self.state_root / "manifest.txt",
|
|
1046
|
+
self.claude_root / "CLAUDE.md",
|
|
1047
|
+
self.claude_root / "personal" / "learnings.md",
|
|
1048
|
+
self.claude_root / "settings.json",
|
|
1049
|
+
self.codex_root / "AGENTS.md",
|
|
1050
|
+
self.codex_root / "config.toml",
|
|
1051
|
+
self.zdotdir / ".zshrc",
|
|
1052
|
+
self.zdotdir / ".zshenv",
|
|
1053
|
+
self.zdotdir / ".zprofile",
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
def _has_legacy_learning_projection(self, host: str, prose: Path, read=None) -> bool:
|
|
1057
|
+
if prose.is_symlink():
|
|
1058
|
+
return True
|
|
1059
|
+
body = read(prose) if read else prose.read_bytes() if prose.is_file() else None
|
|
1060
|
+
if body is None:
|
|
1061
|
+
return False
|
|
1062
|
+
if host == "codex":
|
|
1063
|
+
return PERSONAL_START.encode() in body or PERSONAL_END.encode() in body
|
|
1064
|
+
# The published legacy installer seeds this exact empty automation-owned file.
|
|
1065
|
+
assembler = self._private_module(self.repo, "assemble")
|
|
1066
|
+
return body != assembler.PERSONAL_LEARNINGS_HEADER.encode("utf-8")
|
|
1067
|
+
|
|
1068
|
+
def _legacy_events(self, host: str, needs_action: list[str], read=None) -> tuple[list[dict[str, str]], Path | None]:
|
|
1069
|
+
home = self.claude_root if host == "claude" else self.codex_root
|
|
1070
|
+
source = home / "personal" / "learnings.jsonl"
|
|
1071
|
+
prose = home / "personal" / "learnings.md" if host == "claude" else home / "AGENTS.md"
|
|
1072
|
+
projection = self._has_legacy_learning_projection(host, prose, read)
|
|
1073
|
+
if source.is_symlink():
|
|
1074
|
+
needs_action.append(f"legacy {host} learning JSONL is symlinked: {source}")
|
|
1075
|
+
return [], None
|
|
1076
|
+
content = read(source) if read else source.read_bytes() if source.is_file() else None
|
|
1077
|
+
if projection and content is None:
|
|
1078
|
+
needs_action.append(f"legacy {host} learning projection has no immutable JSONL source: {prose}")
|
|
1079
|
+
return [], None
|
|
1080
|
+
if content is None:
|
|
1081
|
+
return [], None
|
|
1082
|
+
schema = _read_json(self.repo / "learn" / "learning.schema.json")
|
|
1083
|
+
allowed = set((schema.get("properties") or {}).keys())
|
|
1084
|
+
required = set(schema.get("required") or [])
|
|
1085
|
+
events: list[dict[str, str]] = []
|
|
1086
|
+
seen: dict[str, str] = {}
|
|
1087
|
+
body = content.decode("utf-8")
|
|
1088
|
+
for number, line in enumerate(body.splitlines(), 1):
|
|
1089
|
+
if not line.strip():
|
|
1090
|
+
continue
|
|
1091
|
+
try:
|
|
1092
|
+
raw = json.loads(line)
|
|
1093
|
+
except ValueError:
|
|
1094
|
+
needs_action.append(f"invalid legacy {host} learning JSONL at {source}:{number}")
|
|
1095
|
+
continue
|
|
1096
|
+
if (not isinstance(raw, dict) or set(raw) - allowed or required - set(raw)
|
|
1097
|
+
or raw.get("schema_version") != 1 or not isinstance(raw.get("learning_id"), str)
|
|
1098
|
+
or not LEARNING_ID.fullmatch(raw["learning_id"])
|
|
1099
|
+
or not isinstance(raw.get("lesson"), str) or not isinstance(raw.get("domain"), str)
|
|
1100
|
+
or not isinstance(raw.get("created"), str) or not isinstance(raw.get("supporting_sessions"), list)
|
|
1101
|
+
or not all(isinstance(value, str) for value in raw["supporting_sessions"])):
|
|
1102
|
+
needs_action.append(f"invalid collector v1 {host} learning record at {source}:{number}")
|
|
1103
|
+
continue
|
|
1104
|
+
prior = seen.get(raw["learning_id"])
|
|
1105
|
+
if prior is not None:
|
|
1106
|
+
if prior != line:
|
|
1107
|
+
needs_action.append(f"same learning_id has different legacy bytes at {source}:{number}")
|
|
1108
|
+
continue
|
|
1109
|
+
seen[raw["learning_id"]] = line
|
|
1110
|
+
events.append({"learning_id": raw["learning_id"], "raw": line})
|
|
1111
|
+
return events, source
|
|
1112
|
+
|
|
1113
|
+
def _event_destination_conflicts(self, host: str, events: list[dict[str, str]], needs_action: list[str]) -> None:
|
|
1114
|
+
target = self.user_root / "learnings" / host / "events.jsonl"
|
|
1115
|
+
if not target.exists():
|
|
1116
|
+
return
|
|
1117
|
+
if target.is_symlink():
|
|
1118
|
+
needs_action.append(f"unsafe private {host} learning event symlink: {target}")
|
|
1119
|
+
return
|
|
1120
|
+
existing: dict[str, str] = {}
|
|
1121
|
+
for number, line in enumerate(target.read_text(encoding="utf-8").splitlines(), 1):
|
|
1122
|
+
if not line.strip():
|
|
1123
|
+
continue
|
|
1124
|
+
try:
|
|
1125
|
+
record = json.loads(line)
|
|
1126
|
+
except ValueError:
|
|
1127
|
+
needs_action.append(f"invalid private {host} learning JSONL at {target}:{number}")
|
|
1128
|
+
continue
|
|
1129
|
+
learning_id = record.get("learning_id") if isinstance(record, dict) else None
|
|
1130
|
+
if not isinstance(learning_id, str):
|
|
1131
|
+
needs_action.append(f"invalid private {host} learning record at {target}:{number}")
|
|
1132
|
+
continue
|
|
1133
|
+
previous = existing.get(learning_id)
|
|
1134
|
+
if previous is not None and previous != line:
|
|
1135
|
+
needs_action.append(f"same learning_id has different private bytes at {target}:{number}")
|
|
1136
|
+
existing[learning_id] = line
|
|
1137
|
+
for event in events:
|
|
1138
|
+
current = existing.get(event["learning_id"])
|
|
1139
|
+
if current is not None and current != event["raw"]:
|
|
1140
|
+
needs_action.append(f"same learning_id has different source/private bytes: {event['learning_id']}")
|
|
1141
|
+
|
|
1142
|
+
def _legacy_hook_registration_needed(self, needs_action: list[str], read=None) -> bool:
|
|
1143
|
+
"""Use the assembler's exact command-ownership rule, after parse proof."""
|
|
1144
|
+
settings = self.claude_root / "settings.json"
|
|
1145
|
+
if settings.is_symlink():
|
|
1146
|
+
needs_action.append(f"unsafe Claude settings symlink: {settings}")
|
|
1147
|
+
return False
|
|
1148
|
+
content = read(settings) if read else settings.read_bytes() if settings.is_file() else None
|
|
1149
|
+
if content is None:
|
|
1150
|
+
return False
|
|
1151
|
+
try:
|
|
1152
|
+
data = json.loads(content.decode("utf-8"))
|
|
1153
|
+
except ValueError:
|
|
1154
|
+
needs_action.append(f"unreadable Claude settings for legacy hook removal: {settings}")
|
|
1155
|
+
return False
|
|
1156
|
+
hooks = data.get("hooks") if isinstance(data, dict) else None
|
|
1157
|
+
if not isinstance(hooks, dict):
|
|
1158
|
+
return False
|
|
1159
|
+
names = set((_read_json(self.repo / "compose" / "domains.json").get("hooks") or {}).keys())
|
|
1160
|
+
for entries in hooks.values():
|
|
1161
|
+
if not isinstance(entries, list):
|
|
1162
|
+
continue
|
|
1163
|
+
for entry in entries:
|
|
1164
|
+
for hook in entry.get("hooks", []) if isinstance(entry, dict) else []:
|
|
1165
|
+
command = hook.get("command", "") if isinstance(hook, dict) else ""
|
|
1166
|
+
if isinstance(command, str) and any(f"/hooks/{name}" in command for name in names):
|
|
1167
|
+
return True
|
|
1168
|
+
return False
|
|
1169
|
+
|
|
1170
|
+
def _legacy_hook_settings(self, release: Path, before: bytes) -> bytes:
|
|
1171
|
+
"""Plan the assembler's exact ownership-sensitive edit without touching the host."""
|
|
1172
|
+
module = self._private_module(release, "assemble")
|
|
1173
|
+
manifest = _read_json(release / "compose" / "domains.json")
|
|
1174
|
+
with tempfile.TemporaryDirectory(prefix="corpus-hook-plan-") as raw:
|
|
1175
|
+
root = Path(raw)
|
|
1176
|
+
_atomic_bytes(root / "settings.json", before, 0o600)
|
|
1177
|
+
module.merge_settings(root, [], release / "claude" / "settings.template.json",
|
|
1178
|
+
owned_names=manifest.get("hooks", {}))
|
|
1179
|
+
return (root / "settings.json").read_bytes()
|
|
1180
|
+
|
|
1181
|
+
def migration_plan(self) -> dict[str, Any]:
|
|
1182
|
+
return self._plan_migration()[0]
|
|
1183
|
+
|
|
1184
|
+
def _plan_migration(self) -> tuple[dict[str, Any], dict[str, dict[str, Any]]]:
|
|
1185
|
+
# A transformation and its version predicate must describe the same read.
|
|
1186
|
+
inputs: dict[str, dict[str, Any]] = {}
|
|
1187
|
+
|
|
1188
|
+
def read(path: Path) -> bytes | None:
|
|
1189
|
+
raw = str(path)
|
|
1190
|
+
if raw not in inputs:
|
|
1191
|
+
self._migration_target_root(path)
|
|
1192
|
+
inputs[raw] = self._file_version(path)
|
|
1193
|
+
if not inputs[raw]["exists"]:
|
|
1194
|
+
return None
|
|
1195
|
+
return base64.b64decode(inputs[raw]["bytes_b64"].encode("ascii"), validate=True)
|
|
1196
|
+
|
|
1197
|
+
needs_action: list[str] = []
|
|
1198
|
+
|
|
1199
|
+
def text(path: Path) -> str | None:
|
|
1200
|
+
if path.is_symlink():
|
|
1201
|
+
needs_action.append(f"unsafe native migration input symlink: {path}")
|
|
1202
|
+
return None
|
|
1203
|
+
content = read(path)
|
|
1204
|
+
return content.decode("utf-8") if content is not None else None
|
|
1205
|
+
|
|
1206
|
+
writes: list[dict[str, str]] = []
|
|
1207
|
+
claude_entry = self.claude_root / "CLAUDE.md"
|
|
1208
|
+
body = text(claude_entry)
|
|
1209
|
+
if body is not None:
|
|
1210
|
+
new, removed = _remove_claude_imports(body)
|
|
1211
|
+
if removed:
|
|
1212
|
+
writes.append({"path": str(claude_entry), "content": new, "kind": "claude-imports"})
|
|
1213
|
+
codex_entry = self.codex_root / "AGENTS.md"
|
|
1214
|
+
body = text(codex_entry)
|
|
1215
|
+
if body is not None:
|
|
1216
|
+
new, outcome = _remove_spans(body)
|
|
1217
|
+
if new is None:
|
|
1218
|
+
needs_action.extend(f"{codex_entry}: {message}" for message in outcome)
|
|
1219
|
+
elif outcome:
|
|
1220
|
+
writes.append({"path": str(codex_entry), "content": new, "kind": "codex-markers"})
|
|
1221
|
+
codex_config = self.codex_root / "config.toml"
|
|
1222
|
+
body = text(codex_config)
|
|
1223
|
+
if body is not None:
|
|
1224
|
+
stripped, changed = _strip_codex_config_additions(body)
|
|
1225
|
+
if stripped is None:
|
|
1226
|
+
needs_action.append(f"cannot safely remove legacy Codex config additions: {codex_config}")
|
|
1227
|
+
elif changed:
|
|
1228
|
+
writes.append({"path": str(codex_config), "content": stripped, "kind": "codex-config-additions"})
|
|
1229
|
+
for startup in (self.zdotdir / ".zshrc", self.zdotdir / ".zshenv", self.zdotdir / ".zprofile"):
|
|
1230
|
+
body = text(startup)
|
|
1231
|
+
if body is not None:
|
|
1232
|
+
lines = body.splitlines(keepends=True)
|
|
1233
|
+
changed = [line for line in lines if line.strip() == ZSH_HOOK]
|
|
1234
|
+
if changed:
|
|
1235
|
+
writes.append({"path": str(startup), "content": "".join(line for line in lines if line.strip() != ZSH_HOOK), "kind": "zsh-hook"})
|
|
1236
|
+
events: dict[str, list[dict[str, str]]] = {}
|
|
1237
|
+
learning_sources: dict[str, Path] = {}
|
|
1238
|
+
for host in ("claude", "codex"):
|
|
1239
|
+
captured, source = self._legacy_events(host, needs_action, read)
|
|
1240
|
+
if captured:
|
|
1241
|
+
events[host] = captured
|
|
1242
|
+
self._event_destination_conflicts(host, captured, needs_action)
|
|
1243
|
+
if source is not None:
|
|
1244
|
+
learning_sources[host] = source
|
|
1245
|
+
deletes = self._legacy_manifest_files(needs_action, read)
|
|
1246
|
+
manifest = self.state_root / "manifest.txt"
|
|
1247
|
+
if not manifest.is_symlink() and read(manifest) is not None:
|
|
1248
|
+
deletes.append(manifest)
|
|
1249
|
+
seed = self.claude_root / "personal" / "learnings.md"
|
|
1250
|
+
if not seed.is_symlink() and read(seed) is not None and not self._has_legacy_learning_projection("claude", seed, read):
|
|
1251
|
+
deletes.append(seed)
|
|
1252
|
+
# Legacy learning projections are removed only when their immutable source
|
|
1253
|
+
# was successfully parsed and is scheduled for the private event store.
|
|
1254
|
+
for host, source in learning_sources.items():
|
|
1255
|
+
deletes.append(Path(source))
|
|
1256
|
+
if host == "claude":
|
|
1257
|
+
projection = self.claude_root / "personal" / "learnings.md"
|
|
1258
|
+
if not projection.is_symlink() and read(projection) is not None:
|
|
1259
|
+
deletes.append(projection)
|
|
1260
|
+
remove_hooks = self._legacy_hook_registration_needed(needs_action, read)
|
|
1261
|
+
if not needs_action:
|
|
1262
|
+
for path in deletes:
|
|
1263
|
+
read(path)
|
|
1264
|
+
plan = {"schema_version": SCHEMA_VERSION, "writes": writes, "deletes": sorted({str(p) for p in deletes}),
|
|
1265
|
+
"events": events, "remove_hook_registrations": remove_hooks, "needs_action": needs_action}
|
|
1266
|
+
return plan, inputs
|
|
1267
|
+
|
|
1268
|
+
def _backup(self, root: Path, path: Path) -> Path:
|
|
1269
|
+
target = root / "files" / str(path).lstrip("/")
|
|
1270
|
+
_atomic_bytes(target, path.read_bytes(), 0o600)
|
|
1271
|
+
return target
|
|
1272
|
+
|
|
1273
|
+
def _migration_target_root(self, path: Path) -> Path:
|
|
1274
|
+
if not path.is_absolute() or ".." in path.parts:
|
|
1275
|
+
raise InstallError(f"unsafe migration target: {path}")
|
|
1276
|
+
roots = (self.claude_root, self.codex_root, self.launch_root, self.bin_root)
|
|
1277
|
+
if path in {self.state_root / name for name in ("manifest.txt", "version.json")}:
|
|
1278
|
+
root = self.state_root
|
|
1279
|
+
elif path in {self.zdotdir / name for name in (".zshrc", ".zshenv", ".zprofile")}:
|
|
1280
|
+
root = self.zdotdir
|
|
1281
|
+
else:
|
|
1282
|
+
root = next((r for r in roots if path != r and _inside(path, r)), None)
|
|
1283
|
+
if root is None:
|
|
1284
|
+
raise InstallError(f"migration target is outside owned roots: {path}")
|
|
1285
|
+
self._safe_owned_path(path, root)
|
|
1286
|
+
return root
|
|
1287
|
+
|
|
1288
|
+
def _migration_paths(self, plan: dict[str, Any], release: Path,
|
|
1289
|
+
inputs: dict[str, dict[str, Any]]) -> list[dict[str, Any]]:
|
|
1290
|
+
changes: dict[str, bytes | None] = {raw: None for raw in plan["deletes"]}
|
|
1291
|
+
for item in plan["writes"]:
|
|
1292
|
+
if item["path"] in changes:
|
|
1293
|
+
raise InstallError(f"migration both rewrites and deletes {item['path']}")
|
|
1294
|
+
changes[item["path"]] = item["content"].encode("utf-8")
|
|
1295
|
+
if plan["remove_hook_registrations"]:
|
|
1296
|
+
path = str(self.claude_root / "settings.json")
|
|
1297
|
+
if path in changes:
|
|
1298
|
+
raise InstallError("legacy manifest conflicts with managed hook settings")
|
|
1299
|
+
before = base64.b64decode(inputs[path]["bytes_b64"].encode("ascii"), validate=True)
|
|
1300
|
+
changes[path] = self._legacy_hook_settings(release, before)
|
|
1301
|
+
result = []
|
|
1302
|
+
for raw, content in sorted(changes.items()):
|
|
1303
|
+
path = Path(raw)
|
|
1304
|
+
self._migration_target_root(path)
|
|
1305
|
+
before = {key: value for key, value in inputs[raw].items() if key != "bytes_b64"}
|
|
1306
|
+
result.append({"path": raw, "before": before,
|
|
1307
|
+
"after": self._planned_version(content),
|
|
1308
|
+
"mode": (path.stat().st_mode & 0o777) if path.exists() else 0o600})
|
|
1309
|
+
return result
|
|
1310
|
+
|
|
1311
|
+
def _validate_migration(self, journal: Path, data: dict[str, Any]) -> Path:
|
|
1312
|
+
self._safe_owned_path(journal, self.runtime / "migrations")
|
|
1313
|
+
phases = {"preflight", "cleanup", "install", "verify", "complete"}
|
|
1314
|
+
if data.get("kind") != "migrate" or data.get("phase") not in phases or \
|
|
1315
|
+
not isinstance(data.get("paths"), list) or not isinstance(data.get("plan"), dict) or \
|
|
1316
|
+
not isinstance(data.get("inputs"), dict):
|
|
1317
|
+
raise InstallError(f"legacy migration journal has no safe replay plan; recover its backup first: {journal}")
|
|
1318
|
+
release_record = data.get("release")
|
|
1319
|
+
if not isinstance(release_record, dict):
|
|
1320
|
+
raise InstallError(f"migration journal lacks its pinned release: {journal}")
|
|
1321
|
+
try:
|
|
1322
|
+
release = _valid_release(self.state_root, release_record)
|
|
1323
|
+
except TransactionError as exc:
|
|
1324
|
+
raise InstallError(f"invalid migration release: {journal}: {exc}") from exc
|
|
1325
|
+
self._safe_owned_path(release, self.runtime / "releases")
|
|
1326
|
+
if data.get("backup_root") != str(journal.parent / "backup"):
|
|
1327
|
+
raise InstallError(f"unsafe migration backup root: {journal}")
|
|
1328
|
+
self._safe_owned_path(Path(data["backup_root"]), journal.parent)
|
|
1329
|
+
cleanup_destinations = self._migration_cleanup_destinations(release)
|
|
1330
|
+
seen = set()
|
|
1331
|
+
path_names = {entry.get("path") for entry in data["paths"] if isinstance(entry, dict)}
|
|
1332
|
+
for raw, version in data["inputs"].items():
|
|
1333
|
+
self._migration_target_root(Path(raw))
|
|
1334
|
+
if not isinstance(version, dict) or not isinstance(version.get("exists"), bool):
|
|
1335
|
+
raise InstallError(f"invalid migration input version: {journal}")
|
|
1336
|
+
encoded = version.get("bytes_b64")
|
|
1337
|
+
if encoded is not None:
|
|
1338
|
+
if raw in path_names:
|
|
1339
|
+
raise InstallError(f"migration target input retains duplicate replay bytes: {journal}")
|
|
1340
|
+
if not version["exists"] or not isinstance(encoded, str):
|
|
1341
|
+
raise InstallError(f"invalid retained migration input bytes: {journal}")
|
|
1342
|
+
try:
|
|
1343
|
+
body = base64.b64decode(encoded.encode("ascii"), validate=True)
|
|
1344
|
+
except (ValueError, AttributeError) as exc:
|
|
1345
|
+
raise InstallError(f"invalid retained migration input bytes: {journal}") from exc
|
|
1346
|
+
if hashlib.sha256(body).hexdigest() != version.get("sha256"):
|
|
1347
|
+
raise InstallError(f"retained migration input digest mismatch: {journal}")
|
|
1348
|
+
for entry in data["paths"]:
|
|
1349
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("path"), str) or entry["path"] in seen:
|
|
1350
|
+
raise InstallError(f"invalid migration path entries: {journal}")
|
|
1351
|
+
seen.add(entry["path"])
|
|
1352
|
+
if data["inputs"].get(entry["path"]) != entry.get("before"):
|
|
1353
|
+
raise InstallError(f"migration input and target versions disagree: {journal}")
|
|
1354
|
+
path = Path(entry["path"])
|
|
1355
|
+
self._migration_target_root(path)
|
|
1356
|
+
if path not in cleanup_destinations:
|
|
1357
|
+
raise InstallError(f"migration target is not a pinned legacy cleanup destination: {path}")
|
|
1358
|
+
if not all(isinstance(entry.get(k), dict) for k in ("before", "after")):
|
|
1359
|
+
raise InstallError(f"migration path lacks exact state: {journal}")
|
|
1360
|
+
for key in ("before", "after"):
|
|
1361
|
+
version = entry[key]
|
|
1362
|
+
if not isinstance(version.get("exists"), bool):
|
|
1363
|
+
raise InstallError(f"invalid migration file state: {journal}")
|
|
1364
|
+
if version["exists"]:
|
|
1365
|
+
if not re.fullmatch(r"[0-9a-f]{64}", str(version.get("sha256", ""))):
|
|
1366
|
+
raise InstallError(f"invalid migration file digest: {journal}")
|
|
1367
|
+
if key == "after":
|
|
1368
|
+
try:
|
|
1369
|
+
body = base64.b64decode(version["bytes_b64"].encode("ascii"), validate=True)
|
|
1370
|
+
except (KeyError, TypeError, ValueError, AttributeError) as exc:
|
|
1371
|
+
raise InstallError(f"invalid migration replay bytes: {journal}") from exc
|
|
1372
|
+
if hashlib.sha256(body).hexdigest() != version["sha256"]:
|
|
1373
|
+
raise InstallError(f"migration replay digest mismatch: {journal}")
|
|
1374
|
+
elif set(version) != {"exists"}:
|
|
1375
|
+
raise InstallError(f"absent migration file carries replay data: {journal}")
|
|
1376
|
+
return release
|
|
1377
|
+
|
|
1378
|
+
def _verify_transferred_events(self, plan: dict[str, Any]) -> None:
|
|
1379
|
+
"""Keep every planned legacy event byte-for-byte present until commit."""
|
|
1380
|
+
for host, events in plan.get("events", {}).items():
|
|
1381
|
+
target = self.user_root / "learnings" / host / "events.jsonl"
|
|
1382
|
+
self._safe_owned_path(target, self.user_root)
|
|
1383
|
+
if target.is_symlink() or not target.is_file():
|
|
1384
|
+
raise InstallError(f"legacy learning transfer changed: {target}")
|
|
1385
|
+
conflicts: list[str] = []
|
|
1386
|
+
self._event_destination_conflicts(host, events, conflicts)
|
|
1387
|
+
if conflicts:
|
|
1388
|
+
raise InstallError("legacy learning transfer changed: " + "; ".join(conflicts))
|
|
1389
|
+
lines = set(target.read_text(encoding="utf-8").splitlines())
|
|
1390
|
+
missing = [event["learning_id"] for event in events if event["raw"] not in lines]
|
|
1391
|
+
if missing:
|
|
1392
|
+
raise InstallError(f"legacy learning transfer changed: {target}: " + ", ".join(missing))
|
|
1393
|
+
|
|
1394
|
+
def _verify_migration_native_state(self, release: Path, data: dict[str, Any], *, child_required: bool) -> None:
|
|
1395
|
+
"""Check cleanup outputs and untouched native inputs before a resume commits.
|
|
1396
|
+
|
|
1397
|
+
The private child owns only its recorded launcher/config projections.
|
|
1398
|
+
Those exact paths may move from the migration's retired state to the
|
|
1399
|
+
child state; every other native path must remain at the journaled state.
|
|
1400
|
+
"""
|
|
1401
|
+
child = CorpusInstaller(release, self.env)
|
|
1402
|
+
child_owned = set(child._expected_owned(release))
|
|
1403
|
+
targets = {entry["path"]: entry for entry in data["paths"]}
|
|
1404
|
+
changed_inputs = [raw for raw, before in data["inputs"].items()
|
|
1405
|
+
if raw not in targets and not self._matches_version(Path(raw), before)]
|
|
1406
|
+
if changed_inputs:
|
|
1407
|
+
raise InstallError("migration input changed after cleanup: " + ", ".join(changed_inputs))
|
|
1408
|
+
|
|
1409
|
+
child_needs_verification = False
|
|
1410
|
+
for raw, entry in targets.items():
|
|
1411
|
+
path = Path(raw)
|
|
1412
|
+
if raw in child_owned:
|
|
1413
|
+
if not self._matches_version(path, entry["after"]):
|
|
1414
|
+
child_needs_verification = True
|
|
1415
|
+
continue
|
|
1416
|
+
if not self._matches_version(path, entry["after"]):
|
|
1417
|
+
raise InstallError(f"migration target changed after cleanup: {path}")
|
|
1418
|
+
|
|
1419
|
+
if not child_required and not child_needs_verification:
|
|
1420
|
+
return
|
|
1421
|
+
child_pending = any(row["kind"] == "install" for row in pending_status(self.state_root)["transactions"])
|
|
1422
|
+
if child_pending:
|
|
1423
|
+
if child_required:
|
|
1424
|
+
raise InstallError("pending private install prevents migration verification")
|
|
1425
|
+
# ``install`` below owns replaying this child journal. Its preflight
|
|
1426
|
+
# validates its exact projections before it writes any of them.
|
|
1427
|
+
return
|
|
1428
|
+
record = child._old_record()
|
|
1429
|
+
if record is None or record.get("package_root") != str(release):
|
|
1430
|
+
raise InstallError("migration private install is missing its pinned release")
|
|
1431
|
+
child.verify()
|
|
1432
|
+
|
|
1433
|
+
def _finish_migration(self, journal: Path, data: dict[str, Any]) -> dict[str, Any]:
|
|
1434
|
+
release = self._validate_migration(journal, data)
|
|
1435
|
+
plan, paths = data["plan"], data["paths"]
|
|
1436
|
+
try:
|
|
1437
|
+
with operation_scope(self.state_root):
|
|
1438
|
+
if data["phase"] in {"preflight", "cleanup"}:
|
|
1439
|
+
self._assert_preflight_paths(paths)
|
|
1440
|
+
targets = {entry["path"] for entry in paths}
|
|
1441
|
+
for raw, before in data["inputs"].items():
|
|
1442
|
+
if raw not in targets and not self._matches_version(Path(raw), before):
|
|
1443
|
+
raise InstallError(f"migration input changed: {raw}")
|
|
1444
|
+
for entry in paths:
|
|
1445
|
+
path = Path(entry["path"])
|
|
1446
|
+
if not entry["before"].get("exists"):
|
|
1447
|
+
continue
|
|
1448
|
+
backup = Path(data["backup_root"]) / "files" / str(path).lstrip("/")
|
|
1449
|
+
self._safe_owned_path(backup, journal.parent)
|
|
1450
|
+
if not self._matches_version(backup, entry["before"]):
|
|
1451
|
+
if not self._matches_version(path, entry["before"]):
|
|
1452
|
+
raise InstallError(f"original migration backup is missing or changed: {path}")
|
|
1453
|
+
self._backup(Path(data["backup_root"]), path)
|
|
1454
|
+
for host, events in plan["events"].items():
|
|
1455
|
+
conflicts: list[str] = []
|
|
1456
|
+
self._event_destination_conflicts(host, events, conflicts)
|
|
1457
|
+
if conflicts:
|
|
1458
|
+
raise InstallError("; ".join(conflicts))
|
|
1459
|
+
target = self.user_root / "learnings" / host / "events.jsonl"
|
|
1460
|
+
self._safe_owned_path(target, self.user_root)
|
|
1461
|
+
prior = target.read_text(encoding="utf-8").splitlines() if target.is_file() else []
|
|
1462
|
+
known = {json.loads(line)["learning_id"] for line in prior if line.strip()}
|
|
1463
|
+
additions = [event["raw"] for event in events if event["learning_id"] not in known]
|
|
1464
|
+
if additions:
|
|
1465
|
+
_atomic_bytes(target, ("\n".join(prior + additions) + "\n").encode("utf-8"), 0o600)
|
|
1466
|
+
self._verify_transferred_events(plan)
|
|
1467
|
+
data["state"], data["phase"] = "APPLYING", "cleanup"
|
|
1468
|
+
self._journal_write(journal, data)
|
|
1469
|
+
for entry in paths:
|
|
1470
|
+
path = Path(entry["path"])
|
|
1471
|
+
if not self._matches_version(path, entry["after"]):
|
|
1472
|
+
if not self._matches_version(path, entry["before"]):
|
|
1473
|
+
raise InstallError(f"migration target changed: {path}")
|
|
1474
|
+
self._write_planned(path, entry["after"], int(entry["mode"]))
|
|
1475
|
+
# This durable boundary prevents any retry from deleting a new projection.
|
|
1476
|
+
data["phase"] = "install"
|
|
1477
|
+
self._journal_write(journal, data)
|
|
1478
|
+
if data["phase"] == "install":
|
|
1479
|
+
self._verify_migration_native_state(release, data, child_required=False)
|
|
1480
|
+
self._verify_transferred_events(plan)
|
|
1481
|
+
installer = CorpusInstaller(release, self.env)
|
|
1482
|
+
current = installer._old_record()
|
|
1483
|
+
child_pending = any(row["kind"] == "install" for row in
|
|
1484
|
+
pending_status(self.state_root)["transactions"])
|
|
1485
|
+
if current is not None and current.get("package_root") == str(release) and not child_pending:
|
|
1486
|
+
# A lost parent receipt must not publish a second installation.
|
|
1487
|
+
installer.verify()
|
|
1488
|
+
else:
|
|
1489
|
+
installer.install()
|
|
1490
|
+
data["phase"] = "verify"
|
|
1491
|
+
self._journal_write(journal, data)
|
|
1492
|
+
self._verify_migration_native_state(release, data, child_required=True)
|
|
1493
|
+
self._verify_transferred_events(plan)
|
|
1494
|
+
self.verify()
|
|
1495
|
+
# The last pre-commit observation is deliberately repeated: a
|
|
1496
|
+
# cooperative old collector or native writer can race a resume.
|
|
1497
|
+
# It cannot make a noncooperating producer atomic after this check.
|
|
1498
|
+
self._verify_migration_native_state(release, data, child_required=True)
|
|
1499
|
+
self._verify_transferred_events(plan)
|
|
1500
|
+
data.update(state="COMMITTED", phase="complete", committed_at=int(time.time()))
|
|
1501
|
+
self._journal_write(journal, data)
|
|
1502
|
+
return {"preview": False, "migration_id": journal.parent.name,
|
|
1503
|
+
"backup_root": data["backup_root"], "package_root": str(release), **plan}
|
|
1504
|
+
except BaseException as exc:
|
|
1505
|
+
data.update(state="NEEDS_RECOVERY", error=type(exc).__name__)
|
|
1506
|
+
self._journal_write(journal, data)
|
|
1507
|
+
raise
|
|
1508
|
+
|
|
1509
|
+
@_serialized
|
|
1510
|
+
def migrate(self, apply: bool = False, yes: bool = False) -> dict[str, Any]:
|
|
1511
|
+
pending = pending_status(self.state_root)["transactions"]
|
|
1512
|
+
migrations = [item for item in pending if item["kind"] == "migrate"]
|
|
1513
|
+
if len(migrations) > 1:
|
|
1514
|
+
raise InstallError("multiple pending migration journals require explicit recovery")
|
|
1515
|
+
if migrations:
|
|
1516
|
+
journal = Path(migrations[0]["path"])
|
|
1517
|
+
data = _read_json(journal)
|
|
1518
|
+
self._validate_migration(journal, data)
|
|
1519
|
+
if not apply:
|
|
1520
|
+
return {"preview": True, **data["plan"], "recovery_journal": str(journal), "phase": data["phase"]}
|
|
1521
|
+
if not yes:
|
|
1522
|
+
raise InstallError("migration apply requires --yes")
|
|
1523
|
+
return self._finish_migration(journal, data)
|
|
1524
|
+
if apply:
|
|
1525
|
+
try:
|
|
1526
|
+
guard_pending(self.state_root)
|
|
1527
|
+
except TransactionError as exc:
|
|
1528
|
+
raise InstallError(str(exc)) from exc
|
|
1529
|
+
plan, inputs = self._plan_migration()
|
|
1530
|
+
if not apply:
|
|
1531
|
+
return {"preview": True, **plan}
|
|
1532
|
+
if not yes:
|
|
1533
|
+
raise InstallError("migration apply requires --yes")
|
|
1534
|
+
if plan["needs_action"]:
|
|
1535
|
+
raise InstallError("legacy migration has unresolved ownership: " + "; ".join(plan["needs_action"]))
|
|
1536
|
+
release, entries, digest = self._copy_release(self._package_files(), False)
|
|
1537
|
+
paths = self._migration_paths(plan, release, inputs)
|
|
1538
|
+
changed = [raw for raw, before in inputs.items() if not self._matches_version(Path(raw), before)]
|
|
1539
|
+
if changed:
|
|
1540
|
+
raise InstallError("migration input changed during planning; retry with a fresh plan: " + ", ".join(changed))
|
|
1541
|
+
# Refuse an unlisted/user-edited shared destination before retiring anything.
|
|
1542
|
+
prior = self._old_record()
|
|
1543
|
+
retiring = set(plan["deletes"])
|
|
1544
|
+
for raw, expected in self._expected_owned(release).items():
|
|
1545
|
+
path = Path(raw)
|
|
1546
|
+
self._safe_owned_path(path, self.bin_root if path.parent == self.bin_root else self.launch_root)
|
|
1547
|
+
if raw not in retiring and path.exists() and _sha256(path) != expected and \
|
|
1548
|
+
_sha256(path) != self._owned_hash(prior, path):
|
|
1549
|
+
raise InstallError(f"private install cannot replace user-owned paths: {path}")
|
|
1550
|
+
migration_id = f"{int(time.time())}-{uuid.uuid4().hex[:12]}"
|
|
1551
|
+
journal = self.runtime / "migrations" / migration_id / "journal.json"
|
|
1552
|
+
backup_root = journal.parent / "backup"
|
|
1553
|
+
path_inputs = {entry["path"] for entry in paths}
|
|
1554
|
+
journal_data = {"schema_version": SCHEMA_VERSION, "owner": "installer", "kind": "migrate",
|
|
1555
|
+
"state": "PREPARED", "phase": "preflight", "plan": plan, "paths": paths,
|
|
1556
|
+
"inputs": {raw: ({k: v for k, v in before.items() if k != "bytes_b64"}
|
|
1557
|
+
if raw in path_inputs else before)
|
|
1558
|
+
for raw, before in inputs.items()},
|
|
1559
|
+
"prior_install": self._file_version(self.record_path),
|
|
1560
|
+
"release": {"package_root": str(release), "installed_files": entries, "release_digest": digest},
|
|
1561
|
+
"backup_root": str(backup_root), "created_at": int(time.time())}
|
|
1562
|
+
self._journal_write(journal, journal_data)
|
|
1563
|
+
return self._finish_migration(journal, journal_data)
|
|
1564
|
+
|
|
1565
|
+
|
|
1566
|
+
def main(argv: list[str] | None = None) -> int:
|
|
1567
|
+
parser = argparse.ArgumentParser(prog="agent-bios corpus")
|
|
1568
|
+
parser.add_argument("--repo", help=argparse.SUPPRESS)
|
|
1569
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
1570
|
+
install = subparsers.add_parser("install")
|
|
1571
|
+
install.add_argument("--domains")
|
|
1572
|
+
install.add_argument("--dry-run", action="store_true")
|
|
1573
|
+
install.add_argument("--with", dest="with_capabilities")
|
|
1574
|
+
onboard = subparsers.add_parser("onboard")
|
|
1575
|
+
onboard.add_argument("--domains")
|
|
1576
|
+
onboard.add_argument("--dry-run", action="store_true")
|
|
1577
|
+
onboard.add_argument("--with", dest="with_capabilities")
|
|
1578
|
+
subparsers.add_parser("verify")
|
|
1579
|
+
subparsers.add_parser("status")
|
|
1580
|
+
uninstall = subparsers.add_parser("uninstall")
|
|
1581
|
+
uninstall.add_argument("--dry-run", action="store_true")
|
|
1582
|
+
migrate = subparsers.add_parser("migrate")
|
|
1583
|
+
migrate.add_argument("--dry-run", action="store_true")
|
|
1584
|
+
migrate.add_argument("--apply", action="store_true")
|
|
1585
|
+
migrate.add_argument("--yes", action="store_true")
|
|
1586
|
+
reset = subparsers.add_parser("reset")
|
|
1587
|
+
reset.add_argument("--dry-run", action="store_true")
|
|
1588
|
+
reset.add_argument("--apply", action="store_true")
|
|
1589
|
+
reset.add_argument("--yes", action="store_true")
|
|
1590
|
+
reset.add_argument("--expected-revision", help="reset preview generation to accept")
|
|
1591
|
+
args = parser.parse_args(argv)
|
|
1592
|
+
installer = CorpusInstaller(Path(args.repo) if args.repo else Path(__file__).resolve().parent.parent)
|
|
1593
|
+
try:
|
|
1594
|
+
if args.command in {"install", "onboard"}:
|
|
1595
|
+
if args.with_capabilities:
|
|
1596
|
+
raise InstallError("optional --with dependencies are not installed by private corpus mode")
|
|
1597
|
+
result = installer.install(args.domains, args.dry_run)
|
|
1598
|
+
elif args.command == "verify":
|
|
1599
|
+
result = installer.verify()
|
|
1600
|
+
elif args.command == "status":
|
|
1601
|
+
result = installer.status()
|
|
1602
|
+
elif args.command == "uninstall":
|
|
1603
|
+
result = installer.uninstall(args.dry_run)
|
|
1604
|
+
elif args.command == "reset":
|
|
1605
|
+
result = installer.reset(apply=args.apply and not args.dry_run, yes=args.yes,
|
|
1606
|
+
expected_revision=args.expected_revision)
|
|
1607
|
+
else:
|
|
1608
|
+
result = installer.migrate(apply=args.apply and not args.dry_run, yes=args.yes)
|
|
1609
|
+
print(json.dumps(result, ensure_ascii=False, indent=2, sort_keys=True))
|
|
1610
|
+
return 0
|
|
1611
|
+
except InstallError as exc:
|
|
1612
|
+
print(f"corpus-install: {exc}", file=sys.stderr)
|
|
1613
|
+
return 1
|
|
1614
|
+
|
|
1615
|
+
|
|
1616
|
+
if __name__ == "__main__":
|
|
1617
|
+
raise SystemExit(main())
|