agent-bios 0.16.0 → 0.17.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 +3 -2
- package/README.md +98 -24
- package/claude/CLAUDE.md +1 -1
- package/claude/guides/tooling-gotchas.md +1 -1
- package/claude/hooks/tooling-gotchas-hook.py +9 -16
- package/claude/skills/understand/SKILL.md +83 -0
- package/codex/AGENTS.md +1 -1
- package/codex/guides/tooling-gotchas.md +1 -1
- package/compose/bootstrap/SKILL.md +12 -2
- package/compose/corpus.py +6 -6
- package/compose/corpus_catalog.py +74 -25
- package/compose/corpus_install.py +66 -13
- package/compose/corpus_session.py +105 -6
- package/compose/corpus_store.py +11 -2
- package/compose/corpus_transaction.py +20 -0
- package/compose/corpus_ui.py +23 -12
- package/compose/corpus_understand.py +522 -0
- package/compose/domains.json +1 -0
- package/compose/register-hooks.py +6 -8
- package/install.sh +21 -0
- package/launch/agent-launch.py +201 -18
- package/launch/agent-launch.zsh +16 -2
- package/launch/i18n/en.toml +23 -0
- package/launch/i18n/ja.toml +23 -0
- package/launch/i18n/ko.toml +23 -0
- package/launch/shell_integration.py +267 -0
- package/package.json +4 -2
- package/provenance.json +1 -1
|
@@ -40,23 +40,59 @@ CLAUDE_HOOK_EVENTS = frozenset((
|
|
|
40
40
|
"UserPromptSubmit", "SessionStart", "SessionEnd", "Stop", "SubagentStart",
|
|
41
41
|
"SubagentStop", "PreCompact", "PermissionRequest", "TeammateIdle",
|
|
42
42
|
"TaskCompleted", "ConfigChange", "WorktreeCreate", "WorktreeRemove",
|
|
43
|
+
"Setup", "InstructionsLoaded", "UserPromptExpansion", "MessageDisplay",
|
|
44
|
+
"PermissionDenied", "PostToolBatch", "TaskCreated", "StopFailure",
|
|
45
|
+
"CwdChanged", "DirectoryAdded", "FileChanged", "PostCompact",
|
|
46
|
+
"PreModelSwitch", "PostModelSwitch", "Elicitation", "ElicitationResult",
|
|
43
47
|
))
|
|
48
|
+
# Command-event vocabulary from the two hosts' hook references (2026-09-11).
|
|
49
|
+
# Authoring accepts the union; compilation checks the selected host. A host-only
|
|
50
|
+
# event stays authored and becomes unavailable on the other host, never renamed.
|
|
51
|
+
CODEX_HOOK_EVENTS = frozenset((
|
|
52
|
+
"PreToolUse", "PermissionRequest", "PostToolUse", "PreCompact", "PostCompact",
|
|
53
|
+
"SessionStart", "SessionEnd", "UserPromptSubmit", "SubagentStart",
|
|
54
|
+
"SubagentStop", "Stop", "Interrupt",
|
|
55
|
+
))
|
|
56
|
+
HOOK_EVENTS_BY_HOST = {"claude": CLAUDE_HOOK_EVENTS, "codex": CODEX_HOOK_EVENTS}
|
|
57
|
+
HOOK_EVENTS = CLAUDE_HOOK_EVENTS | CODEX_HOOK_EVENTS
|
|
44
58
|
|
|
45
59
|
|
|
46
60
|
class CatalogError(ValueError):
|
|
47
61
|
"""A catalog cannot be compiled safely."""
|
|
48
62
|
|
|
49
63
|
|
|
50
|
-
def validate_hook_binding(hook: Any) -> None:
|
|
64
|
+
def validate_hook_binding(hook: Any, host: str | None = None) -> None:
|
|
51
65
|
if not isinstance(hook, dict) or set(hook) != {"event", "matcher"}:
|
|
52
66
|
raise CatalogError("hook binding must contain only event and matcher")
|
|
53
67
|
event, matcher = hook.get("event"), hook.get("matcher")
|
|
54
|
-
|
|
55
|
-
|
|
68
|
+
events = HOOK_EVENTS if host is None else HOOK_EVENTS_BY_HOST[host]
|
|
69
|
+
if not isinstance(event, str) or event not in events:
|
|
70
|
+
raise CatalogError(f"unsupported {host + ' ' if host else ''}hook event {event!r}")
|
|
56
71
|
if not isinstance(matcher, str) or not matcher or any(char in matcher for char in "\n\r\x00"):
|
|
57
72
|
raise CatalogError("hook matcher must be one non-empty line")
|
|
58
73
|
|
|
59
74
|
|
|
75
|
+
def validate_native_hook_config(value: Any, host: str) -> None:
|
|
76
|
+
"""Validate compiler-owned command registrations, not arbitrary host config."""
|
|
77
|
+
if not isinstance(value, dict):
|
|
78
|
+
raise CatalogError("native hooks must be an event mapping")
|
|
79
|
+
for event, groups in value.items():
|
|
80
|
+
if not isinstance(groups, list) or not groups:
|
|
81
|
+
raise CatalogError("native hook event must contain matcher groups")
|
|
82
|
+
for group in groups:
|
|
83
|
+
if not isinstance(group, dict) or set(group) != {"matcher", "hooks"}:
|
|
84
|
+
raise CatalogError("invalid native hook matcher group")
|
|
85
|
+
validate_hook_binding({"event": event, "matcher": group["matcher"]}, host)
|
|
86
|
+
handlers = group["hooks"]
|
|
87
|
+
if not isinstance(handlers, list) or not handlers:
|
|
88
|
+
raise CatalogError("native hook group must contain commands")
|
|
89
|
+
for handler in handlers:
|
|
90
|
+
if (not isinstance(handler, dict) or set(handler) != {"type", "command"}
|
|
91
|
+
or handler["type"] != "command" or not isinstance(handler["command"], str)
|
|
92
|
+
or not handler["command"] or "\x00" in handler["command"]):
|
|
93
|
+
raise CatalogError("invalid native hook command")
|
|
94
|
+
|
|
95
|
+
|
|
60
96
|
def _read_utf8(path: pathlib.Path) -> str:
|
|
61
97
|
if path.is_symlink():
|
|
62
98
|
raise CatalogError(f"symlink input is not a corpus member: {path}")
|
|
@@ -667,10 +703,10 @@ def _plugin_namespace(ref: str) -> str:
|
|
|
667
703
|
def _native_hook_carrier(item: dict[str, Any]) -> tuple[str, dict[str, str]]:
|
|
668
704
|
source = item.get("origin", {}).get("source_path")
|
|
669
705
|
if item.get("kind") != "hook" or not isinstance(source, str):
|
|
670
|
-
raise CatalogError("event item has no installed
|
|
706
|
+
raise CatalogError("event item has no installed hook carrier provenance")
|
|
671
707
|
matched = re.fullmatch(r"claude/hooks/([A-Za-z0-9][A-Za-z0-9._-]*\.py)", source)
|
|
672
708
|
if not matched:
|
|
673
|
-
raise CatalogError("event item origin is not an installed
|
|
709
|
+
raise CatalogError("event item origin is not an installed canonical hooks/*.py carrier")
|
|
674
710
|
member = f"hooks/{matched.group(1)}"
|
|
675
711
|
if member not in item["members"]:
|
|
676
712
|
raise CatalogError("event item no longer retains its installed hook entrypoint")
|
|
@@ -680,7 +716,7 @@ def _native_hook_carrier(item: dict[str, Any]) -> tuple[str, dict[str, str]]:
|
|
|
680
716
|
raise CatalogError(f"native hook entrypoint is not valid Python: {exc.msg}") from exc
|
|
681
717
|
binding = item.get("hook")
|
|
682
718
|
if not isinstance(binding, dict):
|
|
683
|
-
raise CatalogError("event item has no registered
|
|
719
|
+
raise CatalogError("event item has no registered hook binding")
|
|
684
720
|
return member, binding
|
|
685
721
|
|
|
686
722
|
|
|
@@ -716,9 +752,9 @@ def _assert_native_member_safety(item: dict[str, Any], carrier: str, surface: st
|
|
|
716
752
|
raise CatalogError("delegated item must retain exactly its one agents/*.md carrier")
|
|
717
753
|
for member in item["members"]:
|
|
718
754
|
first = pathlib.PurePosixPath(member).parts[0]
|
|
719
|
-
if first in {"skills", "commands", ".claude-plugin"}:
|
|
755
|
+
if first in {"skills", "commands", ".claude-plugin", ".codex-plugin", ".mcp.json"}:
|
|
720
756
|
raise CatalogError(f"native plugin member would auto-discover {member!r}")
|
|
721
|
-
if member
|
|
757
|
+
if member in {"hooks/hooks.json", "hooks.json", "settings.json", "config.toml"}:
|
|
722
758
|
raise CatalogError(f"native plugin member would override generated registration {member!r}")
|
|
723
759
|
if first == "agents" and member != carrier:
|
|
724
760
|
raise CatalogError(f"native plugin member adds an unselected agent carrier {member!r}")
|
|
@@ -736,15 +772,19 @@ def _write_plugin_manifest(root: pathlib.Path, namespace: str, item: dict[str, A
|
|
|
736
772
|
return relative.as_posix()
|
|
737
773
|
|
|
738
774
|
|
|
739
|
-
def
|
|
775
|
+
def _emit_native_item(
|
|
740
776
|
item: dict[str, Any], destination: pathlib.Path, namespace: str, base_instruction_text: str,
|
|
777
|
+
host: str, reference_root: pathlib.Path,
|
|
741
778
|
) -> tuple[list[str], dict[str, Any] | None]:
|
|
742
779
|
root = destination / "items" / _safe_ref_path(item["ref"])
|
|
743
780
|
if item["surface"] == "event":
|
|
744
781
|
member, binding = _native_hook_carrier(item)
|
|
782
|
+
validate_hook_binding(binding, host)
|
|
745
783
|
_assert_native_member_safety(item, member, "event")
|
|
746
|
-
emitted = [_write_plugin_manifest(root, namespace, item)]
|
|
747
|
-
|
|
784
|
+
emitted = [_write_plugin_manifest(root, namespace, item)] if host == "claude" else []
|
|
785
|
+
reference = reference_root / "items" / _safe_ref_path(item["ref"]) / member
|
|
786
|
+
target = f'"${{CLAUDE_PLUGIN_ROOT}}/{member}"' if host == "claude" else shlex.quote(str(reference))
|
|
787
|
+
command = f'{shlex.quote(sys.executable)} {target}'
|
|
748
788
|
hooks = {"hooks": {binding["event"]: [{
|
|
749
789
|
"matcher": binding["matcher"],
|
|
750
790
|
"hooks": [{"type": "command", "command": command}],
|
|
@@ -752,8 +792,11 @@ def _emit_native_claude_item(
|
|
|
752
792
|
relative = pathlib.PurePosixPath("hooks") / "hooks.json"
|
|
753
793
|
_write_private(root / relative, json.dumps(hooks, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
754
794
|
emitted.append(relative.as_posix())
|
|
755
|
-
return emitted, {"ref": item["ref"], "plugin": namespace, "hook": binding,
|
|
795
|
+
return emitted, {"ref": item["ref"], "plugin": namespace, "hook": binding,
|
|
796
|
+
"entrypoint": member, "hooks": hooks["hooks"]}
|
|
756
797
|
if item["surface"] == "delegated":
|
|
798
|
+
if host != "claude":
|
|
799
|
+
raise CatalogError("native delegated adapter is unsupported for codex: authored Claude agent frontmatter requires a Codex agent projection")
|
|
757
800
|
member, name = _native_agent_carrier(item)
|
|
758
801
|
_assert_native_member_safety(item, member, "delegated")
|
|
759
802
|
# Carrier validation happens before any plugin write. The child receives
|
|
@@ -764,11 +807,11 @@ def _emit_native_claude_item(
|
|
|
764
807
|
emitted = [_write_plugin_manifest(root, namespace, item)]
|
|
765
808
|
return emitted, {"ref": item["ref"], "plugin": namespace, "agent_name": name,
|
|
766
809
|
"route": f"{namespace}:{name}"}
|
|
767
|
-
raise CatalogError(f"unsupported native
|
|
810
|
+
raise CatalogError(f"unsupported native surface {item['surface']!r}")
|
|
768
811
|
|
|
769
812
|
|
|
770
813
|
def compile_items(items: list[dict[str, Any]], destination: pathlib.Path, host: str,
|
|
771
|
-
native: bool = False) -> dict[str, Any]:
|
|
814
|
+
native: bool = False, reference_root: pathlib.Path | None = None) -> dict[str, Any]:
|
|
772
815
|
"""Emit selected items into one private destination.
|
|
773
816
|
|
|
774
817
|
``host`` is currently a validation seam for adapters. Claude and Codex use
|
|
@@ -782,6 +825,9 @@ def compile_items(items: list[dict[str, Any]], destination: pathlib.Path, host:
|
|
|
782
825
|
if not isinstance(items, list):
|
|
783
826
|
raise CatalogError("compiler requires an item list")
|
|
784
827
|
destination = pathlib.Path(destination)
|
|
828
|
+
# Emit bytes into staging while quoting paths for their final location. A
|
|
829
|
+
# string replacement after shell/Python quoting cannot relocate all paths.
|
|
830
|
+
reference_root = pathlib.Path(reference_root) if reference_root is not None else destination
|
|
785
831
|
if destination.exists() and destination.is_symlink():
|
|
786
832
|
raise CatalogError(f"compiler destination must not be a symlink: {destination}")
|
|
787
833
|
destination.mkdir(parents=True, exist_ok=True)
|
|
@@ -790,7 +836,7 @@ def compile_items(items: list[dict[str, Any]], destination: pathlib.Path, host:
|
|
|
790
836
|
unresolved = [item["ref"] for item in normalized if item.get("content_conflict")]
|
|
791
837
|
if unresolved:
|
|
792
838
|
raise CatalogError(f"content_conflict: selected item needs reconciliation: {', '.join(unresolved)}")
|
|
793
|
-
rewrites = _resource_rewrites(normalized,
|
|
839
|
+
rewrites = _resource_rewrites(normalized, reference_root)
|
|
794
840
|
files: list[str] = []
|
|
795
841
|
for item in normalized:
|
|
796
842
|
emitted: dict[str, str] = {}
|
|
@@ -799,10 +845,10 @@ def compile_items(items: list[dict[str, Any]], destination: pathlib.Path, host:
|
|
|
799
845
|
relative = pathlib.PurePosixPath("items") / _safe_ref_path(item["ref"]) / member
|
|
800
846
|
target = _destination_path(destination, relative.as_posix())
|
|
801
847
|
rewritten = _rewrite_resources(content, rewrites)
|
|
802
|
-
if native and
|
|
848
|
+
if native and item["surface"] == "event":
|
|
803
849
|
rewritten = _rewrite_native_hook_guide(item, rewritten, rewrites)
|
|
804
850
|
_write_private(target, rewritten)
|
|
805
|
-
emitted[member] = str(
|
|
851
|
+
emitted[member] = str(reference_root / relative)
|
|
806
852
|
files.append(relative.as_posix())
|
|
807
853
|
item["_emitted_members"] = emitted
|
|
808
854
|
|
|
@@ -822,7 +868,7 @@ def compile_items(items: list[dict[str, Any]], destination: pathlib.Path, host:
|
|
|
822
868
|
instruction_parts.extend(_rewrite_resources(item["body"], rewrites).rstrip("\n") for item in always)
|
|
823
869
|
instruction_parts.append("")
|
|
824
870
|
if router_path is not None:
|
|
825
|
-
instruction_parts.append(f"Relevant procedures: {
|
|
871
|
+
instruction_parts.append(f"Relevant procedures: {reference_root / 'router/relevant.md'}")
|
|
826
872
|
if requested:
|
|
827
873
|
instruction_parts.append("Requested procedures:")
|
|
828
874
|
for item in requested:
|
|
@@ -834,6 +880,7 @@ def compile_items(items: list[dict[str, Any]], destination: pathlib.Path, host:
|
|
|
834
880
|
assets: dict[str, Any] = {}
|
|
835
881
|
plugin_names: dict[str, str] = {}
|
|
836
882
|
plugin_roots: list[str] = []
|
|
883
|
+
hook_config: dict[str, list[dict[str, Any]]] = {}
|
|
837
884
|
agent_routes: list[dict[str, str]] = []
|
|
838
885
|
for item in normalized:
|
|
839
886
|
if item["surface"] not in {"event", "delegated"}:
|
|
@@ -842,27 +889,29 @@ def compile_items(items: list[dict[str, Any]], destination: pathlib.Path, host:
|
|
|
842
889
|
unavailable.append({"ref": item["ref"], "surface": item["surface"],
|
|
843
890
|
"reason": f"native {item['surface']} consumption is disabled; opt in with --corpus-native"})
|
|
844
891
|
continue
|
|
845
|
-
if host != "claude":
|
|
846
|
-
unavailable.append({"ref": item["ref"], "surface": item["surface"],
|
|
847
|
-
"reason": f"native {item['surface']} adapter is unsupported for {host}"})
|
|
848
|
-
continue
|
|
849
892
|
namespace = _plugin_namespace(item["ref"])
|
|
850
893
|
prior = plugin_names.get(namespace)
|
|
851
894
|
if prior is not None and prior != item["ref"]:
|
|
852
|
-
raise CatalogError(f"
|
|
895
|
+
raise CatalogError(f"native namespace collision: {prior} and {item['ref']}")
|
|
853
896
|
plugin_names[namespace] = item["ref"]
|
|
854
897
|
try:
|
|
855
|
-
emitted, route =
|
|
898
|
+
emitted, route = _emit_native_item(item, destination, namespace, base_instruction_text, host, reference_root)
|
|
856
899
|
except CatalogError as exc:
|
|
857
900
|
unavailable.append({"ref": item["ref"], "surface": item["surface"], "reason": str(exc)})
|
|
858
901
|
continue
|
|
859
902
|
root_relative = (pathlib.PurePosixPath("items") / _safe_ref_path(item["ref"])).as_posix()
|
|
860
|
-
|
|
903
|
+
if host == "claude":
|
|
904
|
+
plugin_roots.append(root_relative)
|
|
905
|
+
else:
|
|
906
|
+
for event, groups in route["hooks"].items():
|
|
907
|
+
hook_config.setdefault(event, []).extend(groups)
|
|
861
908
|
files.extend((pathlib.PurePosixPath(root_relative) / path).as_posix() for path in emitted)
|
|
862
909
|
if route and item["surface"] == "delegated":
|
|
863
910
|
agent_routes.append(route)
|
|
864
911
|
if native and host == "claude":
|
|
865
912
|
assets["claude_plugins"] = sorted(plugin_roots)
|
|
913
|
+
if native and host == "codex":
|
|
914
|
+
assets["codex_hooks"] = hook_config
|
|
866
915
|
instruction_text = base_instruction_text
|
|
867
916
|
if agent_routes:
|
|
868
917
|
instruction_text += "\nNative delegated agents:\n"
|
|
@@ -33,6 +33,7 @@ from corpus_transaction import (
|
|
|
33
33
|
operation_scope,
|
|
34
34
|
operation_scope_active,
|
|
35
35
|
pending_status,
|
|
36
|
+
reject_symlink_ancestors,
|
|
36
37
|
transaction_lock,
|
|
37
38
|
)
|
|
38
39
|
|
|
@@ -395,11 +396,17 @@ class CorpusInstaller:
|
|
|
395
396
|
"mode": "private-session-scoped",
|
|
396
397
|
}) + b"\n"
|
|
397
398
|
|
|
399
|
+
@staticmethod
|
|
400
|
+
def _safe_transaction_target(path: Path) -> None:
|
|
401
|
+
try:
|
|
402
|
+
reject_symlink_ancestors(path)
|
|
403
|
+
except TransactionError as exc:
|
|
404
|
+
raise InstallError(str(exc)) from exc
|
|
405
|
+
|
|
398
406
|
@staticmethod
|
|
399
407
|
def _file_version(path: Path, *, allow_bytes: bool = True) -> dict[str, Any]:
|
|
400
408
|
"""A journaled exact-state predicate; secrets use hash-only predicates."""
|
|
401
|
-
|
|
402
|
-
raise InstallError(f"refusing symlink transaction target: {path}")
|
|
409
|
+
CorpusInstaller._safe_transaction_target(path)
|
|
403
410
|
if not path.exists():
|
|
404
411
|
return {"exists": False}
|
|
405
412
|
if not path.is_file():
|
|
@@ -419,7 +426,9 @@ class CorpusInstaller:
|
|
|
419
426
|
|
|
420
427
|
@staticmethod
|
|
421
428
|
def _matches_version(path: Path, version: dict[str, Any]) -> bool:
|
|
422
|
-
|
|
429
|
+
try:
|
|
430
|
+
CorpusInstaller._safe_transaction_target(path)
|
|
431
|
+
except InstallError:
|
|
423
432
|
return False
|
|
424
433
|
if not version.get("exists"):
|
|
425
434
|
return not path.exists()
|
|
@@ -436,6 +445,7 @@ class CorpusInstaller:
|
|
|
436
445
|
_atomic_json(journal, value)
|
|
437
446
|
|
|
438
447
|
def _write_planned(self, path: Path, version: dict[str, Any], mode: int) -> None:
|
|
448
|
+
self._safe_transaction_target(path)
|
|
439
449
|
encoded = version.get("bytes_b64")
|
|
440
450
|
if not isinstance(encoded, str):
|
|
441
451
|
if not version.get("exists"):
|
|
@@ -445,12 +455,19 @@ class CorpusInstaller:
|
|
|
445
455
|
_atomic_bytes(path, base64.b64decode(encoded.encode("ascii"), validate=True), mode)
|
|
446
456
|
|
|
447
457
|
def _assert_preflight_paths(self, paths: list[dict[str, Any]]) -> None:
|
|
458
|
+
for entry in paths:
|
|
459
|
+
self._safe_transaction_target(Path(entry["path"]))
|
|
448
460
|
conflicts = [entry["path"] for entry in paths
|
|
449
461
|
if not self._matches_version(Path(entry["path"]), entry["before"])
|
|
450
462
|
and not self._matches_version(Path(entry["path"]), entry["after"])]
|
|
451
463
|
if conflicts:
|
|
452
464
|
raise InstallError("owned path changed during transaction preflight: " + ", ".join(conflicts))
|
|
453
465
|
|
|
466
|
+
def _projection_matches(self, entry: dict[str, Any]) -> bool:
|
|
467
|
+
path, after = Path(entry["path"]), entry["after"]
|
|
468
|
+
return self._matches_version(path, after) and (
|
|
469
|
+
not after.get("exists") or path.stat().st_mode & 0o777 == int(entry["mode"]))
|
|
470
|
+
|
|
454
471
|
def _old_record(self) -> dict[str, Any] | None:
|
|
455
472
|
return _read_json(self.record_path) if self.record_path.exists() else None
|
|
456
473
|
|
|
@@ -518,6 +535,31 @@ class CorpusInstaller:
|
|
|
518
535
|
f"export AGENT_BIOS_PACKAGE_ROOT={quoted}\n"
|
|
519
536
|
"exec python3 \"$AGENT_BIOS_PACKAGE_ROOT/launch/agent-launch.py\" \"$@\"\n").encode("utf-8")
|
|
520
537
|
|
|
538
|
+
def _shell_manager(self):
|
|
539
|
+
path = Path(__file__).resolve().parent.parent / "launch/shell_integration.py"
|
|
540
|
+
spec = importlib.util.spec_from_file_location("agent_bios_shell_integration", path)
|
|
541
|
+
if spec is None or spec.loader is None:
|
|
542
|
+
raise InstallError("shell connection manager is unavailable")
|
|
543
|
+
module = importlib.util.module_from_spec(spec)
|
|
544
|
+
spec.loader.exec_module(module)
|
|
545
|
+
return module.ShellIntegration(self.env, self.repo)
|
|
546
|
+
|
|
547
|
+
def _shell_paths(self, action: str) -> list[dict[str, Any]]:
|
|
548
|
+
try:
|
|
549
|
+
manager = self._shell_manager()
|
|
550
|
+
# A receipt-less managed script may survive an interrupted opt-in.
|
|
551
|
+
# With neither, do not inspect or claim the user's startup file.
|
|
552
|
+
if not manager.has_connection():
|
|
553
|
+
return []
|
|
554
|
+
# Install owns and validates the upcoming launcher projection; it
|
|
555
|
+
# must also be able to repair a missing/non-executable entrypoint.
|
|
556
|
+
changes = manager.plan(action, installing=action == "restore")["changes"]
|
|
557
|
+
except (OSError, RuntimeError) as exc:
|
|
558
|
+
raise InstallError(str(exc)) from exc
|
|
559
|
+
return [{"path": str(row["path"]), "before": self._planned_version(row["before"]),
|
|
560
|
+
"after": self._planned_version(row["after"]), "mode": row["mode"], "archive": False}
|
|
561
|
+
for row in changes]
|
|
562
|
+
|
|
521
563
|
@_serialized
|
|
522
564
|
def install(self, domains: str | None = None, dry_run: bool = False) -> dict[str, Any]:
|
|
523
565
|
files = self._package_files()
|
|
@@ -540,6 +582,7 @@ class CorpusInstaller:
|
|
|
540
582
|
if dry_run:
|
|
541
583
|
return {"dry_run": True, "release": str(release), "release_digest": digest,
|
|
542
584
|
"files": len(entries), "selection": requested}
|
|
585
|
+
shell_paths = self._shell_paths("restore")
|
|
543
586
|
# Stage the immutable baseline before publishing either source pointers
|
|
544
587
|
# or launcher projections. Store owns those source pointers; this
|
|
545
588
|
# coordinator owns all cross-owner ordering.
|
|
@@ -581,6 +624,7 @@ class CorpusInstaller:
|
|
|
581
624
|
paths = [{"path": str(path), "before": self._file_version(path),
|
|
582
625
|
"after": self._planned_version(content), "mode": mode}
|
|
583
626
|
for path, content, mode in owned]
|
|
627
|
+
paths.extend(shell_paths)
|
|
584
628
|
paths.extend([
|
|
585
629
|
{"path": str(self.record_path), "before": self._file_version(self.record_path),
|
|
586
630
|
"after": self._planned_version(_canonical(record) + b"\n"), "mode": 0o600},
|
|
@@ -621,7 +665,7 @@ class CorpusInstaller:
|
|
|
621
665
|
# is harmless to readers because the journal guard is already durable.
|
|
622
666
|
for entry in data["paths"]:
|
|
623
667
|
path = Path(entry["path"])
|
|
624
|
-
if not self.
|
|
668
|
+
if not self._projection_matches(entry):
|
|
625
669
|
self._write_planned(path, entry["after"], int(entry["mode"]))
|
|
626
670
|
data["phase"] = "source"
|
|
627
671
|
self._journal_write(journal, data)
|
|
@@ -645,7 +689,7 @@ class CorpusInstaller:
|
|
|
645
689
|
|
|
646
690
|
def _verify_transaction_install(self, data: dict[str, Any]) -> None:
|
|
647
691
|
for entry in data["paths"]:
|
|
648
|
-
if not self.
|
|
692
|
+
if not self._projection_matches(entry):
|
|
649
693
|
raise InstallError(f"transaction projection did not verify: {entry['path']}")
|
|
650
694
|
record = _read_json(self.record_path)
|
|
651
695
|
release = Path(record["package_root"])
|
|
@@ -756,13 +800,21 @@ class CorpusInstaller:
|
|
|
756
800
|
@_serialized
|
|
757
801
|
def uninstall(self, dry_run: bool = False) -> dict[str, Any]:
|
|
758
802
|
record = self._old_record()
|
|
803
|
+
if record is not None:
|
|
804
|
+
release = Path(record.get("package_root", ""))
|
|
805
|
+
if not _inside(release, self.runtime / "releases") or release.is_symlink() or not release.is_dir():
|
|
806
|
+
raise InstallError("private install record has unsafe package root")
|
|
807
|
+
self._validate_owned_record(record, release)
|
|
808
|
+
shell_removed: list[str] = []
|
|
809
|
+
try:
|
|
810
|
+
manager = self._shell_manager()
|
|
811
|
+
if manager.has_connection():
|
|
812
|
+
shell_removed = manager.apply("remove", dry_run)["changed_paths"]
|
|
813
|
+
except (OSError, RuntimeError) as exc:
|
|
814
|
+
raise InstallError(str(exc)) from exc
|
|
759
815
|
if record is None:
|
|
760
|
-
return {"removed":
|
|
761
|
-
|
|
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] = []
|
|
816
|
+
return {"removed": shell_removed, "preserved": ["no private install record"]}
|
|
817
|
+
removed: list[str] = list(shell_removed)
|
|
766
818
|
preserved: list[str] = []
|
|
767
819
|
for entry in [record.get("launcher"), *(record.get("config_files") or [])]:
|
|
768
820
|
if not isinstance(entry, dict):
|
|
@@ -794,7 +846,7 @@ class CorpusInstaller:
|
|
|
794
846
|
def _reset_layout(self, record: dict[str, Any], release: Path) -> tuple[list[Path], Path, Path, list[tuple[Path, bytes, int]]]:
|
|
795
847
|
local = [self.launch_root / name for name in ("presets.local.toml", "review-methods.local.toml", "launcher.local.toml")]
|
|
796
848
|
connections = self.launch_root.parent / "agent-bios"
|
|
797
|
-
cleanup = [*local, connections / "ingest-url"]
|
|
849
|
+
cleanup = [*local, connections / "ingest-url", self.user_root / "understand" / "state.json"]
|
|
798
850
|
owned = [(self.bin_root / "agent-launch", self._launcher_body(release), 0o755),
|
|
799
851
|
(self.launch_root / "profiles.toml", (release / "launch" / "agent-launch.toml").read_bytes(), 0o644)]
|
|
800
852
|
owned.extend((self.launch_root / "i18n" / p.name, p.read_bytes(), 0o644)
|
|
@@ -817,6 +869,7 @@ class CorpusInstaller:
|
|
|
817
869
|
cleanup, secret, _connections, owned = self._reset_layout(record, release)
|
|
818
870
|
targets = [{"path": str(path), "before": self._file_version(path), "after": {"exists": False}, "mode": 0o600,
|
|
819
871
|
"archive": True} for path in cleanup]
|
|
872
|
+
targets.extend(self._shell_paths("remove"))
|
|
820
873
|
targets.extend({"path": str(path), "before": self._file_version(path), "after": self._planned_version(body),
|
|
821
874
|
"mode": mode, "archive": False} for path, body, mode in owned)
|
|
822
875
|
status_path = self.state_root / "corpus-status.json"
|
|
@@ -913,7 +966,7 @@ class CorpusInstaller:
|
|
|
913
966
|
result = self._store(release).apply(data["intent"]["plan_id"], data["intent"]["expected_revision"])
|
|
914
967
|
data["result"] = result; data["phase"] = "projections"; _atomic_json(journal, data)
|
|
915
968
|
for entry in targets:
|
|
916
|
-
if not entry.get("archive") and not self.
|
|
969
|
+
if not entry.get("archive") and not self._projection_matches(entry): self._write_planned(Path(entry["path"]), entry["after"], int(entry["mode"]))
|
|
917
970
|
if prior.get("exists"):
|
|
918
971
|
if self._matches_version(secret, prior):
|
|
919
972
|
secret.unlink()
|
|
@@ -216,10 +216,20 @@ class CodexServer:
|
|
|
216
216
|
|
|
217
217
|
|
|
218
218
|
def config_flags(argv, exclude_developer=False):
|
|
219
|
+
argv = argv[:argv.index('--')] if '--' in argv else argv
|
|
219
220
|
result = []
|
|
220
221
|
index = 0
|
|
221
222
|
while index < len(argv):
|
|
222
223
|
token = argv[index]
|
|
224
|
+
if token in ('--enable', '--disable'):
|
|
225
|
+
if index + 1 >= len(argv):
|
|
226
|
+
raise SessionError(f"missing value for {token}")
|
|
227
|
+
result += ['-c', f"features.{argv[index + 1]}={'true' if token == '--enable' else 'false'}"]
|
|
228
|
+
index += 2
|
|
229
|
+
continue
|
|
230
|
+
if token.startswith(('--enable=', '--disable=')):
|
|
231
|
+
name, feature = token.split('=', 1)
|
|
232
|
+
result += ['-c', f"features.{feature}={'true' if name == '--enable' else 'false'}"]
|
|
223
233
|
if token in ('-c', '--config'):
|
|
224
234
|
if index + 1 >= len(argv):
|
|
225
235
|
raise SessionError(f"missing value for {token}")
|
|
@@ -274,6 +284,8 @@ def named_profile(argv):
|
|
|
274
284
|
|
|
275
285
|
|
|
276
286
|
def replace_developer(argv, text):
|
|
287
|
+
boundary = argv.index('--') if '--' in argv else len(argv)
|
|
288
|
+
argv, tail = argv[:boundary], argv[boundary:]
|
|
277
289
|
result, index = [], 0
|
|
278
290
|
while index < len(argv):
|
|
279
291
|
token = argv[index]
|
|
@@ -286,7 +298,7 @@ def replace_developer(argv, text):
|
|
|
286
298
|
continue
|
|
287
299
|
result.append(token)
|
|
288
300
|
index += 1
|
|
289
|
-
return [*result, '-c', 'developer_instructions=' + json.dumps(text)]
|
|
301
|
+
return [*result, '-c', 'developer_instructions=' + json.dumps(text), *tail]
|
|
290
302
|
|
|
291
303
|
|
|
292
304
|
def instruction_value(argv, host):
|
|
@@ -300,10 +312,15 @@ def instruction_value(argv, host):
|
|
|
300
312
|
return ''
|
|
301
313
|
|
|
302
314
|
|
|
303
|
-
def
|
|
315
|
+
def _native_assets(snapshot):
|
|
304
316
|
assets = snapshot.get('assets') or {}
|
|
305
|
-
if not isinstance(assets, dict) or set(assets) - {'claude_plugins'}:
|
|
317
|
+
if not isinstance(assets, dict) or set(assets) - {'claude_plugins', 'codex_hooks'}:
|
|
306
318
|
raise SessionError('invalid native snapshot assets')
|
|
319
|
+
return assets
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _claude_plugin_paths(snapshot):
|
|
323
|
+
assets = _native_assets(snapshot)
|
|
307
324
|
values = assets.get('claude_plugins', [])
|
|
308
325
|
if not isinstance(values, list) or not all(isinstance(value, str) for value in values):
|
|
309
326
|
raise SessionError('invalid native plugin paths')
|
|
@@ -324,6 +341,82 @@ def _claude_plugin_paths(snapshot):
|
|
|
324
341
|
return result
|
|
325
342
|
|
|
326
343
|
|
|
344
|
+
def _codex_hook_config(snapshot):
|
|
345
|
+
from corpus_catalog import CatalogError, validate_native_hook_config
|
|
346
|
+
hooks = _native_assets(snapshot).get('codex_hooks', {})
|
|
347
|
+
try:
|
|
348
|
+
validate_native_hook_config(hooks, 'codex')
|
|
349
|
+
except CatalogError as exc:
|
|
350
|
+
raise SessionError(f'invalid native snapshot hooks: {exc}') from exc
|
|
351
|
+
return hooks
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _toml_value(value):
|
|
355
|
+
"""Serialize the JSON values used by hook config as TOML inline values."""
|
|
356
|
+
if isinstance(value, str):
|
|
357
|
+
return json.dumps(value, ensure_ascii=False)
|
|
358
|
+
if isinstance(value, bool):
|
|
359
|
+
return str(value).lower()
|
|
360
|
+
if isinstance(value, (int, float)):
|
|
361
|
+
return str(value)
|
|
362
|
+
if isinstance(value, list):
|
|
363
|
+
return '[' + ', '.join(_toml_value(item) for item in value) + ']'
|
|
364
|
+
if isinstance(value, dict):
|
|
365
|
+
return '{' + ', '.join(_toml_value(key) + ' = ' + _toml_value(item)
|
|
366
|
+
for key, item in value.items()) + '}'
|
|
367
|
+
raise SessionError('hook config contains a value that TOML cannot represent')
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _with_codex_hooks(argv, hooks, config):
|
|
371
|
+
if not hooks:
|
|
372
|
+
return argv
|
|
373
|
+
# Codex loads every config layer's hooks independently. Copy only existing
|
|
374
|
+
# session-flag groups; copying effective user/project groups would run them twice.
|
|
375
|
+
layers = config.get('layers')
|
|
376
|
+
if not isinstance(layers, list):
|
|
377
|
+
raise SessionError('Codex native hooks require config/read layer provenance')
|
|
378
|
+
session_layers = [layer for layer in layers if layer.get('name', {}).get('type') == 'sessionFlags']
|
|
379
|
+
if len(session_layers) > 1:
|
|
380
|
+
raise SessionError('Codex reported ambiguous session hook configuration')
|
|
381
|
+
existing = session_layers[0].get('config', {}).get('hooks', {}) if session_layers else {}
|
|
382
|
+
if not isinstance(existing, dict):
|
|
383
|
+
raise SessionError('Codex session hooks are not an event mapping')
|
|
384
|
+
flags = []
|
|
385
|
+
for event, groups in sorted(hooks.items()):
|
|
386
|
+
prior = existing.get(event, [])
|
|
387
|
+
if not isinstance(prior, list):
|
|
388
|
+
raise SessionError(f'Codex session hooks.{event} is not a matcher list')
|
|
389
|
+
flags += ['-c', f'hooks.{event}={_toml_value(prior + groups)}']
|
|
390
|
+
boundary = argv.index('--') if '--' in argv else len(argv)
|
|
391
|
+
return [*argv[:boundary], *flags, *argv[boundary:]]
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def validate_codex_hooks(command, snapshot, argv, cwd, env):
|
|
395
|
+
"""Prove discovery on this runtime; preserve native enablement and trust."""
|
|
396
|
+
hooks = _codex_hook_config(snapshot)
|
|
397
|
+
if not hooks:
|
|
398
|
+
return
|
|
399
|
+
expected = {(event[0].lower() + event[1:], group['matcher'], handler['command'])
|
|
400
|
+
for event, groups in hooks.items() for group in groups for handler in group['hooks']}
|
|
401
|
+
with CodexServer(command, config_flags(argv), cwd, env) as server:
|
|
402
|
+
result = server.call('hooks/list', {'cwds': [str(cwd)]})
|
|
403
|
+
config = server.call('config/read', {'cwd': str(cwd), 'includeLayers': False})
|
|
404
|
+
rows = result.get('data')
|
|
405
|
+
if not isinstance(rows, list) or len(rows) != 1 or rows[0].get('errors'):
|
|
406
|
+
raise SessionError('Codex could not discover the selected session hooks')
|
|
407
|
+
found = {(row.get('eventName'), row.get('matcher'), row.get('command')): row
|
|
408
|
+
for row in rows[0].get('hooks', []) if row.get('source') == 'sessionFlags'}
|
|
409
|
+
if not expected.issubset(found):
|
|
410
|
+
raise SessionError('Codex did not discover every selected session hook; check host hook policy and runtime support')
|
|
411
|
+
if config.get('config', {}).get('features', {}).get('hooks') is False:
|
|
412
|
+
print('agent-bios: selected Codex hooks are disabled by the effective native hooks feature setting.', file=sys.stderr)
|
|
413
|
+
pending = sum(not found[key].get('enabled') or found[key].get('trustStatus') != 'trusted'
|
|
414
|
+
for key in expected)
|
|
415
|
+
if pending:
|
|
416
|
+
print(f'agent-bios: {pending} selected Codex hook(s) need native review or enablement; '
|
|
417
|
+
'open /hooks in this session. Registration does not establish execution.', file=sys.stderr)
|
|
418
|
+
|
|
419
|
+
|
|
327
420
|
def validate_claude_plugins(command, snapshot, cwd, env):
|
|
328
421
|
"""Native validation is manifest-only; assert actual carriers separately."""
|
|
329
422
|
for raw in _claude_plugin_paths(snapshot):
|
|
@@ -386,12 +479,14 @@ def compose_argv(command, argv, host, snapshot, cwd=None, env=None, include_glob
|
|
|
386
479
|
'effective developer instructions cannot be read without rebuilding native '
|
|
387
480
|
'profile semantics'
|
|
388
481
|
)
|
|
482
|
+
hooks = _codex_hook_config(snapshot)
|
|
389
483
|
with CodexServer(command, config_flags(argv, exclude_developer=True), cwd, env) as server:
|
|
390
|
-
config = server.call('config/read', {'cwd': str(cwd or pathlib.Path.cwd()), 'includeLayers':
|
|
484
|
+
config = server.call('config/read', {'cwd': str(cwd or pathlib.Path.cwd()), 'includeLayers': bool(hooks)})
|
|
391
485
|
native = config.get('config', {}).get('developer_instructions') or ''
|
|
392
486
|
if not isinstance(native, str):
|
|
393
487
|
raise SessionError('effective developer_instructions is not text')
|
|
394
|
-
|
|
488
|
+
result = replace_developer(argv, '\n\n'.join(x for x in (native, content, contract) if x))
|
|
489
|
+
return _with_codex_hooks(result, hooks, config)
|
|
395
490
|
boundary = argv.index('--') if '--' in argv else len(argv)
|
|
396
491
|
options, tail = argv[:boundary], argv[boundary:]
|
|
397
492
|
result, index = [], 0
|
|
@@ -682,14 +777,18 @@ def launch(command, argv, state_root, host, snapshot, cwd=None, env=None, resume
|
|
|
682
777
|
env = restore_environment(record, env)
|
|
683
778
|
if not _record_instruction_choice(record, env):
|
|
684
779
|
_claude_exclusion_version(command, record['cwd'], env)
|
|
780
|
+
pinned = _verified_launch_snapshot(state_root, {'path': record['snapshot_path'], 'content_ref': record['content_ref']})
|
|
685
781
|
if host == 'claude':
|
|
686
|
-
pinned = _verified_launch_snapshot(state_root, {'path': record['snapshot_path'], 'content_ref': record['content_ref']})
|
|
687
782
|
validate_claude_plugins(command, pinned, record['cwd'], env)
|
|
783
|
+
else:
|
|
784
|
+
validate_codex_hooks(command, pinned, record['argv'], record['cwd'], env)
|
|
688
785
|
argv = record['argv']
|
|
689
786
|
native = ['resume', resume_id, *argv] if host == 'codex' else ['--resume', resume_id, *argv]
|
|
690
787
|
return subprocess.call([command, *native], cwd=record['cwd'], env=env)
|
|
691
788
|
snapshot = _verified_launch_snapshot(state_root, snapshot)
|
|
692
789
|
argv = compose_argv(command, argv, host, snapshot, cwd, env, include_global_instructions)
|
|
790
|
+
if host == 'codex':
|
|
791
|
+
validate_codex_hooks(command, snapshot, argv, cwd, env)
|
|
693
792
|
if host == 'claude':
|
|
694
793
|
validate_claude_plugins(command, snapshot, cwd, env)
|
|
695
794
|
record = prepare(state_root, host, snapshot, argv, cwd, env, include_global_instructions)
|
package/compose/corpus_store.py
CHANGED
|
@@ -174,8 +174,17 @@ def _snapshot_file_digests(root: Path, files: list[str]) -> dict[str, str]:
|
|
|
174
174
|
|
|
175
175
|
|
|
176
176
|
def _snapshot_assets(value: Any, files: list[str]) -> dict[str, Any]:
|
|
177
|
-
if not isinstance(value, dict) or set(value) - {"claude_plugins"}:
|
|
177
|
+
if not isinstance(value, dict) or set(value) - {"claude_plugins", "codex_hooks"}:
|
|
178
178
|
raise ValidationError("invalid snapshot native assets")
|
|
179
|
+
if "codex_hooks" in value:
|
|
180
|
+
try:
|
|
181
|
+
from corpus_catalog import CatalogError, validate_native_hook_config
|
|
182
|
+
except ImportError:
|
|
183
|
+
from .corpus_catalog import CatalogError, validate_native_hook_config
|
|
184
|
+
try:
|
|
185
|
+
validate_native_hook_config(value["codex_hooks"], "codex")
|
|
186
|
+
except CatalogError as exc:
|
|
187
|
+
raise ValidationError(f"invalid snapshot native hooks: {exc}") from exc
|
|
179
188
|
plugins = value.get("claude_plugins", [])
|
|
180
189
|
if not isinstance(plugins, list) or not all(isinstance(path, str) for path in plugins):
|
|
181
190
|
raise ValidationError("snapshot plugins must be relative paths")
|
|
@@ -1304,7 +1313,7 @@ class CorpusStore:
|
|
|
1304
1313
|
staging = root.with_name(f".{root.name}.staging-{uuid.uuid4().hex}")
|
|
1305
1314
|
staging.mkdir(parents=True, exist_ok=False)
|
|
1306
1315
|
try:
|
|
1307
|
-
compiled = catalog.compile_items(_copy_json(selected), staging, host, native=True) if native else catalog.compile_items(_copy_json(selected), staging, host)
|
|
1316
|
+
compiled = catalog.compile_items(_copy_json(selected), staging, host, native=True, reference_root=root) if native else catalog.compile_items(_copy_json(selected), staging, host)
|
|
1308
1317
|
if not isinstance(compiled, dict) or not isinstance(compiled.get("instruction_text"), str):
|
|
1309
1318
|
raise CorpusStoreError("catalog compiler returned invalid output")
|
|
1310
1319
|
files = _snapshot_relative_paths(compiled.get("files"))
|
|
@@ -14,6 +14,7 @@ import hashlib
|
|
|
14
14
|
import json
|
|
15
15
|
import os
|
|
16
16
|
from pathlib import Path
|
|
17
|
+
import sys
|
|
17
18
|
import threading
|
|
18
19
|
from typing import Any, Iterator
|
|
19
20
|
|
|
@@ -30,6 +31,25 @@ class TransactionPendingError(TransactionError):
|
|
|
30
31
|
pass
|
|
31
32
|
|
|
32
33
|
|
|
34
|
+
def reject_symlink_ancestors(path: Path) -> None:
|
|
35
|
+
"""Refuse redirected user paths, allowing only macOS's fixed system aliases.
|
|
36
|
+
|
|
37
|
+
/var, /tmp and /etc are root-owned aliases to /private on macOS. They precede
|
|
38
|
+
ordinary temporary HOME/state roots and are not user-controlled redirections.
|
|
39
|
+
The exception names their exact destinations and never applies to the leaf,
|
|
40
|
+
a same-named link elsewhere, or a user-owned link.
|
|
41
|
+
"""
|
|
42
|
+
for ancestor in (path, *path.parents):
|
|
43
|
+
if not ancestor.is_symlink():
|
|
44
|
+
continue
|
|
45
|
+
system_alias = (sys.platform == "darwin" and ancestor != path
|
|
46
|
+
and ancestor in {Path("/var"), Path("/tmp"), Path("/etc")}
|
|
47
|
+
and ancestor.lstat().st_uid == 0
|
|
48
|
+
and ancestor.parent / os.readlink(ancestor) == Path("/private") / ancestor.name)
|
|
49
|
+
if not system_alias:
|
|
50
|
+
raise TransactionError(f"refusing symlink transaction target: {ancestor}")
|
|
51
|
+
|
|
52
|
+
|
|
33
53
|
def _key(state_root: Path) -> str:
|
|
34
54
|
return str(Path(state_root).expanduser().resolve())
|
|
35
55
|
|