From 79ed4c13b61691950a4d1a3ee43fa8340c99617f Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:44:50 +0900 Subject: [PATCH 1/3] feat(adapters): codex T4 hook wiring for automatic session capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit with `vouch capture ingest-codex` available, codex can self-capture the way claude-code T4 does. the ticket assumed the `notify` setting in config.toml, but codex only honours notify in user-global config — it is a restricted key in project-local layers — and the #179 rule forbids touching ~/.codex. codex's hooks system is the project-local equivalent: `.codex/hooks.json` (loaded in trusted projects) fires Stop when a turn completes, with a payload carrying the session id and transcript path. T4 ships that file through json_merge, so an existing user hooks.json is deep-merged into, never overwritten, and re-runs don't duplicate the hook. the handler is a new --hook mode on ingest-codex: read the stop payload from stdin, resolve the session's rollout (transcript_path when present, else by session id in the rollout filename), ingest idempotently, and exit 0 no matter what — the same never-break-the- host rule capture observe follows. stop fires per turn, not per session end, so the finalize policy the ticket left open is resolved as idempotent re-ingest: the dedup guard now refreshes a session's still-PENDING proposal in place when the rollout grew (same id, updated summary, audit-logged), reports an unchanged rollout as a no-op, and never resurrects a proposal a human already decided. storage gains update_proposal, a pure-I/O in-place rewrite of a pending proposal file. wiring only — everything still lands through propose_page; nothing touches approve(). closes #388 --- adapters/codex/README.md | 26 ++++++ adapters/codex/hooks.json | 15 +++ adapters/codex/install.yaml | 11 +++ src/vouch/cli.py | 34 +++++-- src/vouch/codex_rollout.py | 119 ++++++++++++++++++++++-- src/vouch/storage.py | 14 +++ tests/test_codex_rollout.py | 166 +++++++++++++++++++++++++++++++++- tests/test_install_adapter.py | 51 +++++++++++ 8 files changed, 419 insertions(+), 17 deletions(-) create mode 100644 adapters/codex/hooks.json diff --git a/adapters/codex/README.md b/adapters/codex/README.md index ae028608..562b7290 100644 --- a/adapters/codex/README.md +++ b/adapters/codex/README.md @@ -52,6 +52,32 @@ yourself. The skill bodies are identical to the claude-code slash commands (enforced by a sync test), so the flows behave the same on every host. +## Automatic session capture (T4) + +`vouch install-mcp codex` (default tier T4) wires automatic capture +through codex's hooks system: `.codex/hooks.json` registers a `Stop` +hook that runs `vouch capture ingest-codex --hook` when a turn +completes. The handler reads the hook payload, resolves the session's +rollout file, and rolls it into ONE pending session-summary proposal — +the same review-gated summary a claude-code session produces. Because +`Stop` fires per turn, re-ingest is idempotent: an unchanged session +is a no-op, a session that grew refreshes its pending proposal in +place, and a proposal you've already reviewed is never resurrected. + +Failure semantics match `capture observe`: the `--hook` mode exits 0 +no matter what, so a capture problem can never break your codex turn. +Nothing is auto-approved — review with `vouch review`. + +Hooks merge, not clobber: if you already have a `.codex/hooks.json`, +the installer deep-merges the vouch hook in next to yours (re-runs +don't duplicate it). Project-local hooks run in trusted projects only, +so trust the project when codex asks. + +Why not codex's `notify` setting: codex honours `notify` only in +user-global config (`~/.codex/config.toml`), which a project-scoped +install never touches. If you prefer notify anyway, point it at a +wrapper that calls `vouch capture ingest-codex --hook` yourself. + ## Notes - Codex respects MCP tool naming verbatim, so the tools appear as diff --git a/adapters/codex/hooks.json b/adapters/codex/hooks.json new file mode 100644 index 00000000..acaf8d04 --- /dev/null +++ b/adapters/codex/hooks.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "vouch capture ingest-codex --hook", + "statusMessage": "vouch capture" + } + ] + } + ] + } +} diff --git a/adapters/codex/install.yaml b/adapters/codex/install.yaml index 016be7ba..9d4cb447 100644 --- a/adapters/codex/install.yaml +++ b/adapters/codex/install.yaml @@ -38,3 +38,14 @@ tiers: - { src: ../openclaw/skills/vouch-record/SKILL.md, dst: .codex/skills/vouch-record/SKILL.md } - { src: ../openclaw/skills/vouch-followup/SKILL.md, dst: .codex/skills/vouch-followup/SKILL.md } - { src: ../openclaw/skills/vouch-standup/SKILL.md, dst: .codex/skills/vouch-standup/SKILL.md } + # T4 = automatic session capture -- see issue #388. codex's hooks system + # fires Stop when a turn completes; the handler re-ingests the session's + # rollout idempotently (`vouch capture ingest-codex --hook` exits 0 even + # on failure, so capture can never break a codex turn), updating the + # session's single PENDING summary proposal as the session grows. + # hooks live in their own `.codex/hooks.json` file (project-local in + # trusted projects) and json_merge preserves any hooks the user already + # has. note: the legacy `notify` setting can't be used here -- codex only + # honours it in user-global config, which the #179 rule forbids touching. + T4: + - { src: hooks.json, dst: .codex/hooks.json, json_merge: true } diff --git a/src/vouch/cli.py b/src/vouch/cli.py index 329799a1..8cd475a4 100644 --- a/src/vouch/cli.py +++ b/src/vouch/cli.py @@ -2079,22 +2079,44 @@ def capture_finalize_all_cmd(session_id: str | None, max_age_seconds: float) -> "--latest", is_flag=True, help="Resolve the newest codex rollout recorded for this project (by cwd).", ) +@click.option( + "--hook", "hook_mode", is_flag=True, + help="Read a codex Stop-hook payload from stdin; never fails the host " + "(exits 0 even on errors, like `capture observe`).", +) @click.option( "--codex-home", type=click.Path(file_okay=False, path_type=Path), default=None, help="Codex state dir holding sessions/ (default: $CODEX_HOME or ~/.codex).", ) def capture_ingest_codex_cmd( - rollout: Path | None, latest: bool, codex_home: Path | None + rollout: Path | None, latest: bool, hook_mode: bool, codex_home: Path | None ) -> None: """Ingest one codex session rollout into a PENDING summary proposal. - Codex has no live hook stream; it persists each session as a rollout - jsonl instead. This maps the rollout's tool calls into the same - observation shape `capture observe` produces and reuses the existing - rollup, so the result is the same review-gated summary a claude - session yields. Re-ingesting a session is a no-op; review with + Codex has no live notify stream vouch can use project-locally; it + persists each session as a rollout jsonl instead. This maps the + rollout's tool calls into the same observation shape `capture observe` + produces and reuses the existing rollup, so the result is the same + review-gated summary a claude session yields. Re-ingesting an + unchanged session is a no-op; a session that grew since the last + ingest refreshes its one PENDING proposal in place. Review with `vouch review`. """ + if hook_mode: + # Hook wire (codex `Stop` event): parse the stdin payload, resolve + # the session's rollout, ingest idempotently. Exits 0 no matter + # what — capture must never break the user's codex turn. + try: + raw = "" if sys.stdin.isatty() else sys.stdin.read() + payload = json.loads(raw) if raw.strip() else {} + if isinstance(payload, dict): + codex_rollout_mod.ingest_hook_payload( + _capture_store(), payload, codex_home=codex_home + ) + except Exception: + # the hook contract is exit 0 — never surface an error here. + pass + return if (rollout is None) == (not latest): raise click.ClickException("pass exactly one of ROLLOUT or --latest") store = _load_store() diff --git a/src/vouch/codex_rollout.py b/src/vouch/codex_rollout.py index c94684c7..b23d409a 100644 --- a/src/vouch/codex_rollout.py +++ b/src/vouch/codex_rollout.py @@ -31,7 +31,8 @@ from pathlib import Path from typing import Any -from . import capture +from . import audit, capture +from .models import Proposal, ProposalStatus from .proposals import propose_page from .storage import KBStore @@ -297,14 +298,46 @@ def find_latest_rollout(cwd: Path, *, codex_home: Path | None = None) -> Path | return best -def find_existing_proposal(store: KBStore, session_id: str) -> str | None: - """Id of any proposal (any status) already filed for this session.""" +def find_existing_proposal(store: KBStore, session_id: str) -> Proposal | None: + """Any proposal (any status) already filed for this session.""" for proposal in store.list_proposals(None): if proposal.session_id == session_id: - return proposal.id + return proposal return None +def find_rollout_by_session_id( + session_id: str, *, codex_home: Path | None = None +) -> Path | None: + """The rollout file for one session id — codex embeds the id in the + filename (``rollout--.jsonl``), so no file needs opening. + + The session id comes from a hook payload, so it's matched as a literal + filename suffix rather than interpolated into the glob pattern: a + payload carrying glob metacharacters (``*``, ``?``, ``[``) can't widen + the search or change its semantics. + """ + sessions = (codex_home or default_codex_home()) / "sessions" + if not sessions.is_dir() or not session_id.strip(): + return None + suffix = f"-{session_id}.jsonl" + best: Path | None = None + for path in sessions.rglob("rollout-*.jsonl"): + if not path.name.endswith(suffix): + continue + if best is None or path.name > best.name: + best = path + return best + + +def _comparable_body(body: str) -> str: + """The summary body minus its generation timestamp, so re-ingesting an + unchanged rollout compares equal across runs.""" + return "\n".join( + line for line in body.splitlines() if not line.startswith("- generated:") + ) + + def ingest_rollout( store: KBStore, path: Path, @@ -316,8 +349,12 @@ def ingest_rollout( Honours the same ``capture:`` config as live capture (``enabled``, ``min_observations``) so the two front doors gate identically, and - dedups on the rollout's session id: re-ingesting reports the existing - proposal instead of filing a second one. + dedups on the rollout's session id: at most one proposal per session, + ever. Because codex's Stop hook fires per *turn* rather than at session + end, re-ingesting a session that grew since the last ingest refreshes + the still-PENDING proposal in place (same id, updated summary) instead + of filing a duplicate; an unchanged rollout is a flat no-op, and a + decided proposal is history — it blocks re-ingest regardless. """ cfg = capture.load_config(store) session = parse_rollout(path) @@ -330,11 +367,11 @@ def ingest_rollout( } existing = find_existing_proposal(store, session.session_id) - if existing is not None: + if existing is not None and existing.status != ProposalStatus.PENDING: return { "session_id": session.session_id, "captured": len(session.observations), - "summary_proposal_id": existing, + "summary_proposal_id": existing.id, "skipped": "already-ingested", } @@ -358,12 +395,42 @@ def ingest_rollout( generated_at=generated_at or session.started_at, first_prompt=session.first_prompt, ) + resolved_actor = actor or os.environ.get("VOUCH_AGENT") or CODEX_ACTOR + + if existing is not None: + if _comparable_body(body) == _comparable_body( + str(existing.payload.get("body", "")) + ): + return { + "session_id": session.session_id, + "captured": len(session.observations), + "summary_proposal_id": existing.id, + "skipped": "already-ingested", + } + refreshed = existing.model_copy(deep=True) + refreshed.payload["title"] = title.strip() + refreshed.payload["body"] = body + store.update_proposal(refreshed) + audit.log_event( + store.kb_dir, + event="proposal.page.update", + actor=resolved_actor, + object_ids=[existing.id], + data={"reason": "codex rollout re-ingest", "captured": len(session.observations)}, + ) + return { + "session_id": session.session_id, + "captured": len(session.observations), + "summary_proposal_id": existing.id, + "updated": True, + } + proposal = propose_page( store, title=title, body=body, page_type=capture.CAPTURE_PAGE_TYPE, - proposed_by=actor or os.environ.get("VOUCH_AGENT") or CODEX_ACTOR, + proposed_by=resolved_actor, session_id=session.session_id, rationale="ingested codex session rollout", ) @@ -372,3 +439,37 @@ def ingest_rollout( "captured": len(session.observations), "summary_proposal_id": proposal.id, } + + +def ingest_hook_payload( + store: KBStore | None, + payload: dict[str, Any], + *, + codex_home: Path | None = None, +) -> dict[str, Any] | None: + """Handle one codex Stop-hook payload; never raises. + + The hook wire (`vouch capture ingest-codex --hook`) must exit 0 even on + failure — a capture problem must never break the user's codex turn, + the same rule ``capture observe`` follows. Returns the ingest result, + or None when there was nothing safe to do. + """ + try: + if store is None: + return None + session_id = str(payload.get("session_id") or "") + if not session_id: + return None + rollout: Path | None = None + transcript = payload.get("transcript_path") + if isinstance(transcript, str) and transcript.endswith(".jsonl"): + candidate = Path(transcript) + if candidate.is_file(): + rollout = candidate + if rollout is None: + rollout = find_rollout_by_session_id(session_id, codex_home=codex_home) + if rollout is None: + return None + return ingest_rollout(store, rollout) + except Exception: + return None diff --git a/src/vouch/storage.py b/src/vouch/storage.py index 653ea806..2024164c 100644 --- a/src/vouch/storage.py +++ b/src/vouch/storage.py @@ -815,6 +815,20 @@ def put_proposal(self, proposal: Proposal) -> Proposal: ) from e return proposal + def update_proposal(self, proposal: Proposal) -> Proposal: + """Overwrite an existing *pending* proposal file in place. + + Pure I/O: the caller decides whether a refresh is legitimate (only + pending proposals may be rewritten — a decided one is history). + """ + path = self._proposal_path(proposal.id) + if not path.exists(): + raise ArtifactNotFoundError(f"proposal {proposal.id}") + path.write_text( + _yaml_dump(proposal.model_dump(mode="json")), encoding="utf-8" + ) + return proposal + def get_proposal(self, proposal_id: str) -> Proposal: for path in (self._proposal_path(proposal_id), self._decided_path(proposal_id)): if path.exists(): diff --git a/tests/test_codex_rollout.py b/tests/test_codex_rollout.py index 353b6046..2969008c 100644 --- a/tests/test_codex_rollout.py +++ b/tests/test_codex_rollout.py @@ -139,7 +139,9 @@ def test_reingest_same_session_is_noop(store: KBStore) -> None: def test_ingest_below_min_files_nothing(store: KBStore) -> None: - store.config_path.write_text("capture:\n min_observations: 99\n") + store.config_path.write_text( + "capture:\n min_observations: 99\n", encoding="utf-8" + ) result = cr.ingest_rollout(store, BASIC) assert result["skipped"] == "below-min" assert result["summary_proposal_id"] is None @@ -147,7 +149,9 @@ def test_ingest_below_min_files_nothing(store: KBStore) -> None: def test_ingest_noop_when_capture_disabled(store: KBStore) -> None: - store.config_path.write_text("capture:\n enabled: false\n") + store.config_path.write_text( + "capture:\n enabled: false\n", encoding="utf-8" + ) result = cr.ingest_rollout(store, BASIC) assert result["skipped"] == "disabled" assert store.list_proposals(None) == [] @@ -159,6 +163,164 @@ def test_ingest_never_writes_approved_content(store: KBStore) -> None: assert store.list_pages() == [] +# --- per-turn re-ingest: refresh the pending proposal (vouchdev/vouch#388) -- + + +def _grown_copy(tmp_path: Path) -> Path: + """BASIC plus one more tool call, same session id — a later turn.""" + grown = tmp_path / f"rollout-2026-07-01T09-30-00-{BASIC_SESSION}.jsonl" + extra = { + "timestamp": "2026-07-01T09:30:00.000Z", + "type": "response_item", + "payload": { + "type": "function_call", "name": "exec_command", + "arguments": json.dumps({"cmd": "ruff check src"}), + "call_id": "call_extra", + }, + } + grown.write_text( + BASIC.read_text(encoding="utf-8") + json.dumps(extra) + "\n", + encoding="utf-8", + ) + return grown + + +def test_reingest_grown_session_updates_pending_in_place( + store: KBStore, tmp_path: Path +) -> None: + first = cr.ingest_rollout(store, BASIC) + pid = first["summary_proposal_id"] + second = cr.ingest_rollout(store, _grown_copy(tmp_path)) + assert second["updated"] is True + assert second["summary_proposal_id"] == pid + proposals = store.list_proposals(None) + assert len(proposals) == 1 # refreshed, not duplicated + assert "ruff check src" in proposals[0].payload["body"] + assert proposals[0].status == ProposalStatus.PENDING + + +def test_reingest_decided_session_stays_decided( + store: KBStore, tmp_path: Path +) -> None: + """A proposal the human already reviewed is history — a later turn must + not resurrect or mutate it.""" + from vouch.proposals import approve + + first = cr.ingest_rollout(store, BASIC) + approve(store, first["summary_proposal_id"], approved_by="alice-example") + result = cr.ingest_rollout(store, _grown_copy(tmp_path)) + assert result["skipped"] == "already-ingested" + assert len(store.list_proposals(ProposalStatus.PENDING)) == 0 + + +def test_reingest_update_lands_in_audit_log(store: KBStore, tmp_path: Path) -> None: + from vouch import audit + + cr.ingest_rollout(store, BASIC) + cr.ingest_rollout(store, _grown_copy(tmp_path)) + events = [e.event for e in audit.read_events(store.kb_dir)] + assert "proposal.page.update" in events + + +# --- hook wire (--hook): codex Stop event ------------------------------------ + + +def test_ingest_hook_payload_files_proposal(store: KBStore) -> None: + result = cr.ingest_hook_payload( + store, + {"session_id": BASIC_SESSION, "transcript_path": str(BASIC), + "hook_event_name": "Stop", "cwd": str(store.kb_dir.parent)}, + ) + assert result is not None + assert result["summary_proposal_id"] + + +def test_ingest_hook_payload_resolves_by_session_id( + store: KBStore, tmp_path: Path +) -> None: + home = tmp_path / "codex-home" + day = home / "sessions" / "2026" / "07" / "01" + day.mkdir(parents=True) + rollout = day / f"rollout-2026-07-01T09-00-00-{BASIC_SESSION}.jsonl" + rollout.write_text(BASIC.read_text(encoding="utf-8"), encoding="utf-8") + result = cr.ingest_hook_payload( + store, {"session_id": BASIC_SESSION}, codex_home=home + ) + assert result is not None + assert result["summary_proposal_id"] + + +def test_ingest_hook_payload_never_raises(store: KBStore, tmp_path: Path) -> None: + assert cr.ingest_hook_payload(None, {"session_id": "x"}) is None + assert cr.ingest_hook_payload(store, {}) is None + assert ( + cr.ingest_hook_payload( + store, {"session_id": "no-such-session"}, + codex_home=tmp_path / "empty", + ) + is None + ) + + +def test_cli_hook_mode_files_proposal(store: KBStore, monkeypatch) -> None: + monkeypatch.chdir(store.kb_dir.parent) + payload = json.dumps({ + "session_id": BASIC_SESSION, + "transcript_path": str(BASIC), + "hook_event_name": "Stop", + }) + res = CliRunner().invoke( + cli, ["capture", "ingest-codex", "--hook"], input=payload + ) + assert res.exit_code == 0, res.output + assert len(store.list_proposals(ProposalStatus.PENDING)) == 1 + + +def test_cli_hook_mode_exits_zero_on_garbage(store: KBStore, monkeypatch) -> None: + monkeypatch.chdir(store.kb_dir.parent) + res = CliRunner().invoke( + cli, ["capture", "ingest-codex", "--hook"], input="{ not json" + ) + assert res.exit_code == 0, res.output + + +def test_cli_hook_mode_exits_zero_without_kb(tmp_path: Path, monkeypatch) -> None: + monkeypatch.chdir(tmp_path) # no .vouch anywhere above tmp_path + payload = json.dumps({"session_id": BASIC_SESSION, "transcript_path": str(BASIC)}) + res = CliRunner().invoke( + cli, ["capture", "ingest-codex", "--hook"], input=payload + ) + assert res.exit_code == 0, res.output + + +def test_find_rollout_by_session_id(tmp_path: Path) -> None: + home = tmp_path / "codex-home" + day = home / "sessions" / "2026" / "07" / "01" + day.mkdir(parents=True) + target = day / "rollout-2026-07-01T09-00-00-sess-42.jsonl" + target.write_text("{}", encoding="utf-8") + (day / "rollout-2026-07-01T10-00-00-sess-43.jsonl").write_text( + "{}", encoding="utf-8" + ) + assert cr.find_rollout_by_session_id("sess-42", codex_home=home) == target + assert cr.find_rollout_by_session_id("sess-99", codex_home=home) is None + + +def test_find_rollout_by_session_id_treats_glob_chars_literally( + tmp_path: Path, +) -> None: + """A session id from a hook payload is matched as a literal suffix, so + glob metacharacters can't widen the search to unrelated rollouts.""" + home = tmp_path / "codex-home" + day = home / "sessions" / "2026" / "07" / "01" + day.mkdir(parents=True) + (day / "rollout-2026-07-01T09-00-00-sess-42.jsonl").write_text( + "{}", encoding="utf-8" + ) + # `*` must not match the real session above. + assert cr.find_rollout_by_session_id("sess-*", codex_home=home) is None + + # --- --latest resolution ---------------------------------------------------- diff --git a/tests/test_install_adapter.py b/tests/test_install_adapter.py index e4adad83..64fe097f 100644 --- a/tests/test_install_adapter.py +++ b/tests/test_install_adapter.py @@ -523,6 +523,57 @@ def test_install_codex_t3_is_idempotent(tmp_path: Path) -> None: assert f".codex/skills/{name}/SKILL.md" in second.skipped +# --- codex: T4 hooks.json capture wiring (vouchdev/vouch#388) --------------- + + +def test_codex_t4_writes_hooks_json(tmp_path: Path) -> None: + """Fresh T4 install wires the Stop hook so a completed codex session + lands as a PENDING summary proposal with no manual steps.""" + result = install("codex", target=tmp_path, tier="T4") + hooks_path = tmp_path / ".codex" / "hooks.json" + assert hooks_path.is_file() + assert ".codex/hooks.json" in result.written + data = json.loads(hooks_path.read_text(encoding="utf-8")) + cmds = [ + h["command"] + for g in data["hooks"]["Stop"] + for h in g["hooks"] + ] + assert "vouch capture ingest-codex --hook" in cmds + + +def test_codex_t4_merges_into_existing_hooks_json(tmp_path: Path) -> None: + """An existing user hook is never silently overwritten — ours merges in + next to it via the json_merge machinery.""" + codex_dir = tmp_path / ".codex" + codex_dir.mkdir() + (codex_dir / "hooks.json").write_text(json.dumps({ + "hooks": { + "Stop": [ + {"hooks": [{"type": "command", "command": "my-own-stop-hook"}]} + ] + } + }), encoding="utf-8") + result = install("codex", target=tmp_path, tier="T4") + data = json.loads((codex_dir / "hooks.json").read_text(encoding="utf-8")) + cmds = [h["command"] for g in data["hooks"]["Stop"] for h in g["hooks"]] + assert "my-own-stop-hook" in cmds + assert "vouch capture ingest-codex --hook" in cmds + assert ".codex/hooks.json" in result.merged + + +def test_codex_t4_rerun_does_not_duplicate_hook(tmp_path: Path) -> None: + install("codex", target=tmp_path, tier="T4") + first = (tmp_path / ".codex" / "hooks.json").read_text(encoding="utf-8") + second = install("codex", target=tmp_path, tier="T4") + after = (tmp_path / ".codex" / "hooks.json").read_text(encoding="utf-8") + assert first == after + assert ".codex/hooks.json" in second.skipped + data = json.loads(after) + cmds = [h["command"] for g in data["hooks"]["Stop"] for h in g["hooks"]] + assert cmds.count("vouch capture ingest-codex --hook") == 1 + + # --- codex: config.toml deep-merge (vouchdev/vouch#384) --------------------- From f8020a2a695500eb1643c28ef1ab9d25033f43cf Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:49:18 +0900 Subject: [PATCH 2/3] test(adapters): live codex install gate against the real cli MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/test_openclaw_plugin_load_real.py set the pattern: exercise the real host cli when it's on PATH, skip cleanly where it isn't. the codex adapter had no equivalent, so regressions in the shipped config only surfaced when a user hit them — the old T1 silent-skip no-op is exactly the class of bug a live gate would have caught. the new suite installs the adapter into a temp project at T4, marks the project trusted inside an isolated CODEX_HOME, and asserts through `codex mcp list --json` that the vouch server is visible: next to a pre-seeded unrelated server on the merge path, alone on the fresh path, and absent for an untrusted project (the config must stay inert where codex says it does). a full-tier check covers the fenced AGENTS.md, the skills, and the Stop hook, and an isolation check pins every written artifact under the temp project. assertions target the observable contract — server listed, config parses, snippet present — not codex internals, so version bumps shouldn't break the suite. no network, no credentials, never touches the real ~/.codex; runs in the normal pytest invocation. closes #389 --- tests/test_codex_adapter_load_real.py | 199 ++++++++++++++++++++++++++ 1 file changed, 199 insertions(+) create mode 100644 tests/test_codex_adapter_load_real.py diff --git a/tests/test_codex_adapter_load_real.py b/tests/test_codex_adapter_load_real.py new file mode 100644 index 00000000..de230b49 --- /dev/null +++ b/tests/test_codex_adapter_load_real.py @@ -0,0 +1,199 @@ +"""Tier-2 e2e: the real Codex CLI reads the installed adapter (#389). + +The unit tests in test_install_adapter.py assert what the installer writes; +nothing there proves the real codex CLI accepts it — the old T1 silent-skip +behavior (installer no-op whenever `.codex/config.toml` existed) is exactly +the class of bug only a live gate catches. This suite closes that gap, +following the pattern tests/test_openclaw_plugin_load_real.py set: install +the adapter into a temp project at the highest shipped tier, mark the +project trusted inside an isolated ``CODEX_HOME``, and assert through codex +itself (``codex mcp list --json``) that the vouch server is visible — next +to a pre-seeded unrelated server on the merge path, and alone on the +fresh-install path. + +Assertions target the observable contract (server listed, config parses, +snippet present), not codex internals, so codex version bumps shouldn't +break the suite. Skips when the ``codex`` CLI is not on PATH (e.g. GitHub +CI). Every codex invocation runs with a throwaway ``CODEX_HOME`` and a temp +project cwd, so the user's real ``~/.codex`` is never touched; listing +configured servers needs no network and no credentials. +""" + +from __future__ import annotations + +import json +import os +import shutil +import subprocess +import tomllib +from pathlib import Path + +import pytest + +from vouch.install_adapter import install + +CODEX = shutil.which("codex") + +pytestmark = pytest.mark.skipif(CODEX is None, reason="codex CLI not on PATH") + + +def _run_codex( + env: dict[str, str], cwd: Path, *args: str +) -> subprocess.CompletedProcess[str]: + assert CODEX is not None + return subprocess.run( + [CODEX, *args], + env=env, + cwd=cwd, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + + +def _isolated_env(home: Path) -> dict[str, str]: + home.mkdir(parents=True, exist_ok=True) + return {**os.environ, "CODEX_HOME": str(home)} + + +def _trust(home: Path, project: Path) -> None: + # Codex loads project-scoped .codex/ layers only for trusted projects; + # this is the non-interactive equivalent of answering the trust prompt. + # The path is a TOML quoted key, so escape it with json.dumps (TOML + # basic strings share JSON's escaping) — a backslash or quote in the + # path would otherwise produce invalid TOML. + key = json.dumps(str(project)) + with (home / "config.toml").open("a", encoding="utf-8") as fh: + fh.write(f'[projects.{key}]\ntrust_level = "trusted"\n') + + +def _mcp_servers(env: dict[str, str], cwd: Path) -> list[dict]: + result = _run_codex(env, cwd, "mcp", "list", "--json") + assert result.returncode == 0, ( + f"codex mcp list failed.\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + # `codex mcp list --json` may emit a one-time notice before the JSON, + # so try a straight parse first and fall back to slicing from the first + # bracket; on failure surface stdout/stderr so the reason is actionable. + try: + servers = json.loads(result.stdout) + except json.JSONDecodeError: + start = result.stdout.find("[") + if start == -1: + raise AssertionError( + f"codex mcp list produced no JSON array.\n" + f"stdout: {result.stdout!r}\nstderr: {result.stderr!r}" + ) from None + try: + servers = json.loads(result.stdout[start:]) + except json.JSONDecodeError as e: + raise AssertionError( + f"codex mcp list JSON did not parse: {e}\n" + f"stdout: {result.stdout!r}\nstderr: {result.stderr!r}" + ) from e + assert isinstance(servers, list) + return servers + + +def test_merge_install_is_visible_to_codex(tmp_path: Path) -> None: + """The #384 regression at the live level: a project where codex is + already configured must end up with vouch wired next to the user's + existing server, as seen by codex itself.""" + home = tmp_path / "codex-home" + project = tmp_path / "project" + (project / ".codex").mkdir(parents=True) + (project / ".codex" / "config.toml").write_text( + 'model = "gpt-5"\n\n[mcp_servers.other]\ncommand = "other-server"\n', + encoding="utf-8", + ) + + install("codex", target=project, tier="T4") + + # the merged file is still valid toml with both servers side by side + merged = tomllib.loads( + (project / ".codex" / "config.toml").read_text(encoding="utf-8") + ) + assert merged["model"] == "gpt-5" + assert set(merged["mcp_servers"]) == {"other", "vouch"} + + env = _isolated_env(home) + _trust(home, project) + servers = {s["name"]: s for s in _mcp_servers(env, project)} + assert "vouch" in servers, f"vouch not listed: {sorted(servers)}" + assert "other" in servers, "user's pre-existing server disappeared" + transport = servers["vouch"]["transport"] + assert transport["command"] == "vouch" + assert transport["args"] == ["serve"] + + +def test_fresh_install_is_visible_to_codex(tmp_path: Path) -> None: + home = tmp_path / "codex-home" + project = tmp_path / "project" + project.mkdir() + + install("codex", target=project, tier="T4") + + env = _isolated_env(home) + _trust(home, project) + names = [s["name"] for s in _mcp_servers(env, project)] + assert names == ["vouch"], names + + +def test_untrusted_project_config_stays_inert(tmp_path: Path) -> None: + """Codex ignores project-scoped config for untrusted projects — the + install must not leak into a session the user never trusted.""" + home = tmp_path / "codex-home" + project = tmp_path / "project" + project.mkdir() + install("codex", target=project, tier="T4") + + env = _isolated_env(home) # note: no _trust() call + names = [s["name"] for s in _mcp_servers(env, project)] + assert "vouch" not in names, names + + +def test_full_tier_artifacts_present(tmp_path: Path) -> None: + """The T4 install ships every codex-readable surface: merged config, + fenced AGENTS.md, the nine skills, and the Stop hook.""" + home = tmp_path / "codex-home" + project = tmp_path / "project" + project.mkdir() + install("codex", target=project, tier="T4") + + agents = (project / "AGENTS.md").read_text(encoding="utf-8") + assert "" in agents + assert "VOUCH_AGENT=codex" in agents.replace("`", "") + + assert (project / ".codex" / "skills" / "vouch-recall" / "SKILL.md").is_file() + + hooks = json.loads( + (project / ".codex" / "hooks.json").read_text(encoding="utf-8") + ) + cmds = [h["command"] for g in hooks["hooks"]["Stop"] for h in g["hooks"]] + assert "vouch capture ingest-codex --hook" in cmds + + # and codex still accepts the whole tree + env = _isolated_env(home) + _trust(home, project) + assert [s["name"] for s in _mcp_servers(env, project)] == ["vouch"] + + +def test_nothing_written_outside_project_and_home(tmp_path: Path) -> None: + """#179 invariant at the live level: after an install plus a codex + invocation, the throwaway home holds only what we put there and the + project holds only adapter artifacts — the real ~/.codex is never in + play because CODEX_HOME is pinned for every invocation.""" + home = tmp_path / "codex-home" + project = tmp_path / "project" + project.mkdir() + result = install("codex", target=project, tier="T4") + for rel in (*result.written, *result.appended, *result.merged): + assert (project / rel).resolve().is_relative_to(project.resolve()) + + env = _isolated_env(home) + _trust(home, project) + _mcp_servers(env, project) + # the trust entry is the only file vouch's test wrote into the home; + # codex may add its own state (caches, logs) but only under that home. + assert (home / "config.toml").is_file() From 4b93764e6f99976db943dc9379fb3e393e1f111c Mon Sep 17 00:00:00 2001 From: dripsmvcp <138900956+dripsmvcp@users.noreply.github.com> Date: Mon, 6 Jul 2026 16:52:30 +0900 Subject: [PATCH 3/3] docs(adapters): codex adapter docs reflect the tiered install three surfaces went stale once the codex tier work landed. the adapters table row described codex as a one-file config.toml snippet; it now lists the tiered install. the codex adapter readme led with the manual ~/.codex edit; it now leads with `vouch install-mcp codex`, explains the project-local .codex/config.toml choice and the trust requirement, summarizes what each tier adds (mcp wire / AGENTS.md snippet / skills / capture hook) with the merge-safety guarantees, and keeps the manual edit as an explicit fallback section. getting-started treated codex as a one-liner aside inside the claude instructions; the install section now shows both hosts side by side. the prompts-location decision from the T3 ticket and the notify restriction from the T4 ticket stay documented in their sections. no code; placeholder examples only. closes #390 --- adapters/README.md | 2 +- adapters/codex/README.md | 96 +++++++++++++++++++++++----------------- docs/getting-started.md | 14 ++++-- 3 files changed, 68 insertions(+), 44 deletions(-) diff --git a/adapters/README.md b/adapters/README.md index f4edce8c..84bb8127 100644 --- a/adapters/README.md +++ b/adapters/README.md @@ -10,7 +10,7 @@ file you need into your project and edit it. |---|---|---| | [claude-code/](claude-code/) | Anthropic's Claude Code CLI | `.mcp.json` snippet, `CLAUDE.md` excerpt | | [cursor/](cursor/) | Cursor IDE | `mcp.json` snippet | -| [codex/](codex/) | OpenAI's Codex CLI | `config.toml` snippet | +| [codex/](codex/) | OpenAI's Codex CLI | tiered install: `.codex/config.toml` merge, `AGENTS.md` excerpt, skills, capture hook | | [continue/](continue/) | Continue.dev | `config.json` snippet | | [openclaw/](openclaw/) | OpenClaw plugin host | `.openclaw/plugins.json`, `AGENTS.md` excerpt | | [generic-mcp/](generic-mcp/) | Any MCP-speaking host | annotated reference | diff --git a/adapters/codex/README.md b/adapters/codex/README.md index 562b7290..5b8666c4 100644 --- a/adapters/codex/README.md +++ b/adapters/codex/README.md @@ -1,45 +1,43 @@ # Codex CLI adapter -Wires `vouch serve` into [OpenAI's Codex CLI][codex] as an MCP server. +Wires vouch into [OpenAI's Codex CLI][codex]: the MCP server, standing +`AGENTS.md` instructions, the vouch guided flows as skills, and +automatic session capture. [codex]: https://github.com/openai/codex -## Setup +## Install ```bash -vouch install-mcp codex +vouch install-mcp codex # everything (T1–T4) +vouch install-mcp codex --tier T1 # just the MCP wire ``` -This writes the `vouch` MCP entry into the *project-local* -`/.codex/config.toml` (deep-merged into any existing one, so -your other servers and settings are preserved) — never into -`~/.codex/config.toml`, per the project-scoped install rule. Codex -loads project-local `.codex/` config for trusted projects, so trust -the project when codex asks. +Everything lands in the *project*, never in `~/.codex` — the same +scope rule every adapter follows. Codex loads project-local `.codex/` +config for trusted projects, so trust the project when codex asks. -Prefer a user-global setup, or no installer? Add the entry to -`~/.codex/config.toml` (or `/.codex/config.toml`) by hand: +What each tier adds (tiers stack): -```toml -[mcp_servers.vouch] -command = "vouch" -args = ["serve"] +| Tier | File | What it does | +|---|---|---| +| T1 | `.codex/config.toml` | registers the `vouch` MCP server (`kb_search`, `kb_propose_claim`, …) with `VOUCH_AGENT=codex` for audit attribution | +| T2 | `AGENTS.md` | fenced snippet with the standing rules: recall first, all writes via proposals, review stays human | +| T3 | `.codex/skills/vouch-*/SKILL.md` | the nine vouch guided flows as project-local skills | +| T4 | `.codex/hooks.json` | `Stop` hook that auto-captures each session into a pending, review-gated summary | -[mcp_servers.vouch.env] -VOUCH_AGENT = "codex" -``` - -Restart any running `codex` session. +The install is idempotent and merge-safe: an existing +`.codex/config.toml` or `.codex/hooks.json` is deep-merged into (your +entries always win on conflict), an existing `AGENTS.md` gets the +snippet appended inside fence markers, and re-runs are a flat no-op. ## Skills (T3) -`vouch install-mcp codex --tier T3` also drops the vouch guided flows -(`vouch-recall`, `vouch-status`, `vouch-resolve-issue`, -`vouch-propose-from-pr`, plus the company-brain set) into the -project-local `.codex/skills/` directory. Codex discovers skills from -`/.codex/skills/` in trusted projects, so they surface in the -session automatically — ask for a skill by name (e.g. "use the -vouch-recall skill for X") or let codex pick them up from context. +Codex discovers skills from `/.codex/skills/` in trusted +projects, so the flows (`vouch-recall`, `vouch-status`, +`vouch-resolve-issue`, `vouch-propose-from-pr`, plus the company-brain +set) surface in the session automatically — ask for one by name or let +codex pick them up from context. Why skills and not custom prompts: codex loads custom prompts only from `~/.codex/prompts/` (user-global) and has deprecated them in @@ -54,30 +52,48 @@ The skill bodies are identical to the claude-code slash commands ## Automatic session capture (T4) -`vouch install-mcp codex` (default tier T4) wires automatic capture -through codex's hooks system: `.codex/hooks.json` registers a `Stop` -hook that runs `vouch capture ingest-codex --hook` when a turn -completes. The handler reads the hook payload, resolves the session's -rollout file, and rolls it into ONE pending session-summary proposal — -the same review-gated summary a claude-code session produces. Because -`Stop` fires per turn, re-ingest is idempotent: an unchanged session -is a no-op, a session that grew refreshes its pending proposal in -place, and a proposal you've already reviewed is never resurrected. +`.codex/hooks.json` registers a `Stop` hook that runs `vouch capture +ingest-codex --hook` when a turn completes. The handler reads the hook +payload, resolves the session's rollout file, and rolls it into ONE +pending session-summary proposal — the same review-gated summary a +claude-code session produces. Because `Stop` fires per turn, re-ingest +is idempotent: an unchanged session is a no-op, a session that grew +refreshes its pending proposal in place, and a proposal you've already +reviewed is never resurrected. Failure semantics match `capture observe`: the `--hook` mode exits 0 no matter what, so a capture problem can never break your codex turn. Nothing is auto-approved — review with `vouch review`. -Hooks merge, not clobber: if you already have a `.codex/hooks.json`, -the installer deep-merges the vouch hook in next to yours (re-runs -don't duplicate it). Project-local hooks run in trusted projects only, -so trust the project when codex asks. +Past sessions can be ingested by hand too: + +```bash +vouch capture ingest-codex --latest # newest rollout for this project +vouch capture ingest-codex # a specific one +``` Why not codex's `notify` setting: codex honours `notify` only in user-global config (`~/.codex/config.toml`), which a project-scoped install never touches. If you prefer notify anyway, point it at a wrapper that calls `vouch capture ingest-codex --hook` yourself. +## Manual fallback + +No installer, or a user-global setup on purpose? Add the entry to +`~/.codex/config.toml` (or `/.codex/config.toml`) by hand: + +```toml +[mcp_servers.vouch] +command = "vouch" +args = ["serve"] + +[mcp_servers.vouch.env] +VOUCH_AGENT = "codex" +``` + +Restart any running `codex` session, then confirm with +`codex mcp list`. + ## Notes - Codex respects MCP tool naming verbatim, so the tools appear as diff --git a/docs/getting-started.md b/docs/getting-started.md index bb9ac038..b1003078 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -118,9 +118,17 @@ Add `-e VOUCH_AGENT=claude-code` to attribute the agent's proposals to it rather than your shell user. Confirm with `claude mcp list` (look for `vouch … ✓ Connected`). -Prefer a config file, or want the brain-first `CLAUDE.md`, slash commands, and -hooks too? Run `vouch install-mcp claude-code` — or drop this into `.mcp.json` -at the project root by hand: +Prefer a config file, or want the brain-first instructions, guided flows, and +capture hooks too? The installer ships the full tiered setup for either host: + +```bash +vouch install-mcp claude-code # .mcp.json + CLAUDE.md + slash commands + hooks +vouch install-mcp codex # .codex/config.toml + AGENTS.md + skills + capture +``` + +Both are idempotent and merge-safe — existing config files are deep-merged +into, never clobbered. Or drop this into `.mcp.json` at the project root by +hand: ```json {