Repository navigation
Give Codex NodeSpace hooks, and run the harness scenario on Codex (refs #3748) - #3775
Conversation
|
Pragmatic review: verdict approve with improvements, no blockers. Full findings are in the review below. |
malibio
left a comment
There was a problem hiding this comment.
Code Review Summary
Verdict: APPROVE WITH IMPROVEMENTS (no blockers; this is a net improvement and the core design is sound). Posted as a comment review because the PR is self-authored.
The shape is right: one hook command per event, state in a per-thread JSON file, the shared session module reused via snapshot() / restored rather than copied, user hooks preserved, a malformed hooks.json left untouched, the instructions block kept for untrusted-hook sessions, and the /hooks trust step printed. The rule checks pass: no compat code, no suppression, no raw console in production code, no node-type or lifecycle literals, no Sync-boundary markers, and the README is an existing package README. The Rust change is small and well tested.
Acceptance criteria (Codex part of #3748):
- Install and uninstall give and remove hooks: met (
codex-hooks.test.ts). - Project, orientation, consent rules and skill list at start: met per the live run; the hook-process test only covers the unreachable path.
- Skill edited mid-session announced on the next prompt: met per the live run; no automated test through the hook process.
- Task finished elsewhere refuses the next call: met per the live run, and at the session level by the snapshot round-trip test.
- Installer states the
/hooksstep and the block remains: met. - State survives between invocations and shared logic is reused: met, with the caveats below.
- Status duty settled and recorded: met (not available, systemMessage only). The issue comment says the systemMessage is "not yet observed in the UI", the one unverified claim.
- Codex driver and Codex column: met (issue comment). Pi, Antigravity and the completed table correctly remain open.
Findings
Critical Issues
None.
Suggested Improvements
- [Improvement]
packages/skill/plugins/codex/hook.ts, SessionEnd vs resume. The issue comment records thatSessionEndfires percodex execprocess, and the handler deletes the state file. The nextcodex exec resume <thread>then arrives asSessionStartwithsource: resumeandsaved === null, so theisContinuedbranch is never taken andsession.start()runs from scratch. That resetsfetched,listVersion,itemandblocked, andstart()callsendJournal(), wiping the thread's journal (the session's own earlier writes). The scenario passed, presumably because the item is re-learned within a turn, but a watched item that spans twoexecinvocations loses its watch and a refusal is forgotten across turns. The continuity logic and the SessionEnd cleanup contradict each other. Suggest: onSessionEnd, callsession.end()for the journal but keep the state file (the 7-day prune bounds it), and add a test that drives SessionStart(startup), SessionEnd, SessionStart(resume) and asserts the state carries over. - [Improvement] hook.ts, concurrent tool calls. Codex can run tool calls in parallel, and every PreToolUse/PostToolUse is load, mutate, write the whole file. The per-pid temp name makes the rename atomic and never torn, but it is last-writer-wins: a PreToolUse that loaded before a PostToolUse saved can overwrite the updated
fetched/item/lastCheckedAt, or drop apendingNoteor a refusal (blocked). The consequence is a missed or repeated note, or a refusal that lands one call late, not corruption. Cheapest mitigation: skip the write when the snapshot equals what was loaded (PreToolUse is the hot path and usually changes nothing, which also removes most of the race window); a lock file or re-read-and-merge would close the rest. At minimum record the limitation in the README. - [Improvement] hook.ts
load().parsed.session as unknown as SessionStateis an unchecked cast. A wrong-typed field (a missing one is handled by??) throws insidecreateSession/session.*, is swallowed bymain, and the session silently never works again until the file is pruned. Validate the few fields, or treat a throw on restore as "no state" and rewrite. Per project policy no shape-migration code: a plain reset is the right behaviour. - [Improvement] hook.ts
ensureStarted. It starts a session only when no state file exists. A session whose firststart()wasunreachable/no-cli(daemon not up yet) is saved withisReached: falseand is never retried for the thread's life, becausesavedis non-null from then on. Retryingstart()whenisReached === false(rate-limited by the watch interval) is cheap. Also note a mid-sessionstart()(hooks trusted late) callsendJournal()under the thread id and discards writes already journaled; harmless today because the item is not yet known, but worth a comment. - [Improvement] Tests: not everything asserted is exercised.
codex-hooks.test.tsruns the real script only for SessionStart (unreachable), UserPromptSubmit ({}), SessionEnd and a bad id. The hook's own output shapes are never asserted through the process:permissionDecision: "deny"plus reason, PostToolUseadditionalContextfrompendingNote, the UserPromptSubmit note + section join, andshellLineOf/outputOf(theBashtool name andtool_responseshapes). Those are exactly the contract with Codex. A fakenodespaceon PATH (the test already fakesgit) would let a test drive PreToolUse to a deny and PostToolUse to a note. The command wrapper (command -v bun/node --experimental-strip-typesbranch, and quoting of a path containing a space) is also untested; the test runshook.tsdirectly withnode. The bad-id test also asserts only empty output, not that no file was written. - [Improvement]
packages/cli/src/journal.rs, precedence and nesting. The precedence is correct and tested (NODESPACE_WRITE_JOURNALwins, empty falls through). The residual mis-attribution risk is the inverse of the one the README documents: a non-Codex harness started from a shell inside a Codex command inheritsCODEX_THREAD_ID, and any harness whose plugin variable is unset (plugin absent or untrusted) journals its writes under the Codex thread's file, where the Codex hook reads them as its own and can suppress a genuine foreign-change refusal. Low probability and bounded, but the variable is read unconditionally for every CLI write. Document it next to the constant (the comment currently says only that hooks cannot set the variable), or scope it. - [Improvement]
packages/skill/src/installer.ts. (1) Handlers are found bycommand.includes('"<script path>"'). IfCODEX_HOMEmoved since the last install, the old handlers carry a different path and are neither replaced nor removed by uninstall, and Codex then warns on every event for a missing script. Uninstall already reads the record's plugin root for files; use the recorded path for the hook match too. (2) The script path is interpolated into ashcommand inside double quotes, so aCODEX_HOMEcontaining",$or a backtick is interpreted by the shell. Single-quote with'\''escaping, or reject such a path. (3)needsHookTrustis set on every install with a hooks file, so the/hooksmessage prints again on a no-op reinstall; set it only when the handler set changed.
Nitpicks
- Nit:
removeHooksFiledeleteshooks.jsonwhen it holds only{"hooks": {}}, which may be a file the user created empty. The install record could say whether the file existed beforehand. - Nit:
scripts/eval/harness/codex.tsdoesrealpathSync(process.env.NODESPACE_HOME ?? ""); an unset variable throws a bare ENOENT.scenario.tsalready enforces it; use the validated home. - Nit:
mainandload()swallow every error silently. Never breaking Codex is right; a debug-gated stderr line would make a dead hook diagnosable. - Nit: the driver uses the user's real
CODEX_HOME(with their other hooks) and setsnetwork_access=true. The rationale (a copy of the credential is a credential) is documented and the widening is narrow (NodeSpace home and.git), so this is acceptable. DeletingNODESPACE_WRITE_JOURNALfrom the child env is correct for the reason stated.
Checked and sound
- Session id is validated against
^[A-Za-z0-9_-]{1,128}$before it forms a path (no traversal); the Rust side appliesis_session_idtoo. - State is written to a temp file in the same directory and renamed, so a reader never sees a partial file.
- Output shapes match the contract:
hookSpecificOutput.hookEventName+additionalContext, PreToolUsepermissionDecision: "deny"with a reason, top-levelsystemMessage.SessionEndexists in the 0.162.1 binary, so registering it is valid. - Restored fields:
lastCheckedAtis epoch ms fromhost.now()and comparable across processes;blockedandopeningare intentionally carried and cleared byprompt(); nothing restored is unsafe beyond the unvalidated-cast point. - Per-call cost: the CLI is spawned only when the watch interval has elapsed; the unconditional state write on every PreToolUse is the main avoidable cost.
Review addressed (87d9334)Addressed
Skipped
Re-Review DecisionDecision: RE-REVIEW NEEDED Rationale: Dropping |
Re-review of 87d9334 (Codex hooks)Verdict: APPROVE. Every earlier finding is addressed or skipped with a sound reason. Two small things are worth a follow-up in this PR or the next; neither blocks. Earlier findings
FindingsImprovement
Notes (no action needed)
3. Old double-quoted handlers (
|
malibio
left a comment
There was a problem hiding this comment.
Re-review of 87d9334, verdict APPROVE. All earlier findings are addressed or soundly skipped. Two non-blocking improvements are in the summary comment: (1) pruneStale can delete the state file of a thread resumed after more than 7 days and the skip-unchanged save then hides the write; (2) isState is shallower than its comment claims. The author's hooks.json still holds the old double-quoted handlers and needs a manual clean before reinstall.
Re-review addressed (d554b03)Addressed
Not changed
Re-Review DecisionDecision: NO RE-REVIEW NEEDED Rationale: Two targeted fixes to what the re-review found, each pinned by a test (one mutation-checked), and one new test. No new behaviour beyond what was approved. The suite passes (281 skill tests; quality:fix clean). |
…er (refs #3748) ## Completed in This Session - [x] Shared session snapshot/restore; Codex hook command, installer registration and uninstall - [x] Installer prints the /hooks trust step; the block stays for untrusted sessions - [x] Codex scenario driver and trace support ## Remaining Work - [ ] Live Codex run: hook payloads, status duty, journal env (needs hook trust) - [ ] Pi and Antigravity drivers and runs; Antigravity hook research; results table Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
… (refs #3748) Codex runs each hook event as a command, so the shared session now snapshots and restores its state, and hook.ts keeps it in a file per thread. The installer registers one handler per event in the hooks file, prints the /hooks trust step, and the instructions block stays for untrusted sessions. The CLI names a write's journal after CODEX_THREAD_ID when the plugin's variable is unset. A Codex scenario driver is added. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…k (refs #3748) - Drop the SessionEnd handler: codex exec fires it per process, which wiped the state a resume continues from. State is pruned after a week. - Skip a state write that changes nothing, so parallel tool calls do not undo each other's note, refusal or fetched skills. - Validate a restored state file; an unusable one starts the session again. - A prompt starts a session that could not reach NodeSpace; a tool call does not, which would cost every call a timeout while the daemon is down. - Single-quote the script path in the hook command. - Hook-process tests over a fake nodespace and git: the deny output shape, PostToolUse additionalContext, the prompt note with the list, resume, the registered shell command. - Document the nested-harness journal caveat both ways; driver reads the scenario home; NODESPACE_HOOK_DEBUG prints a swallowed error. Skipped: handlers left behind when CODEX_HOME moves (they sit in the old home's hooks file, which Codex no longer reads); the needsHookTrust line on a no-op reinstall (trust state is not knowable from here). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…3748) - pruneStale skips the thread being continued: a resume after a week idle read the file, pruned it, then skipped the write because nothing changed, leaving no state. An unchanged save now touches the file instead, so its age is the thread's idle time. - isState checks the members a restored session reads (item id, paths, parts, fields; skill ids; project id), so a stale shape starts the session again. - Tests: aged-file resume (fails without the fix), an item missing its parts, and the registered command in a home with a space and a single quote. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
d554b03 to
f29d78d
Compare
What
Codex gets what the Claude Code and OpenCode plugins deliver: the project, orientation, consent rules and skill list at session start; a note when a skill changes; and a refusal of the next tool call once the watched task is finished elsewhere.
snapshot()and a restore argument, andplugins/codex/hook.tskeeps the state in a file per thread. The session logic is reused, not copied.~/.codex/hooks.json(other entries untouched; uninstall removes only its own), prints the/hookstrust step, and keeps the instructions block for sessions whose hooks are not trusted.CODEX_THREAD_IDwhenNODESPACE_WRITE_JOURNALis unset. The hook'ssession_idis the same id.scripts/eval/harness/codex.tsdrives the scenario;trace.tsreads Codex events.Live result (Codex 0.162.1)
Probe, mid-session standard edit, and task-finished-elsewhere all held; the session's own writes were not read as foreign. Details are in the issue comment.
Not in this PR
Pi and Antigravity drivers and runs, Antigravity hook research, and the status duty (Codex has no status line; only a
systemMessageis emitted). These stay open on #3748.Tests
bun run test:changed: scripts, skill, types and node-types checks pass. Onenodespace-agentPTY test (ultra_short_lived_processes_are_pruned) failed under load and passes alone; it is unrelated to this change.🤖 Generated with Claude Code