Codex rollout cwd keys on the thread, not the session container (#459) - #462
Conversation
`createRolloutCwdResolver` located a rollout by the id embedded in its FILENAME, which is the thread (`session_meta.payload.id`), while the projector called it with the session CONTAINER (`metadata.session_id` / the `session-id` header). The two are the same uuid on a root thread, so every hand-check passed; a subagent thread inherits its root's container and mints its own thread id, so a subagent turn on the ChatGPT-subscription route resolved the ROOT thread's cwd. That cwd is what `.hypignore` is evaluated against (LLP 0083 / LLP 0050), so a subagent running in an `ignore` directory whose root was not got RECORDED: a directory-scoped privacy control silently not applying. The same value is stamped on the row, so the row also claimed a directory the turn never ran in. - The resolver's key is now the thread id, and its contract says so. - The located rollout must confirm it: `payload.id`, read off the raw JSONL line (never a deserialized `session_meta`, which Codex back-fills `session_id` from `id` in), must equal the id asked for. A filename/body disagreement is a refusal, logged as `plugin.codex.rollout_cwd_thread_mismatch`, not another thread's cwd deciding this turn. `payload.session_id` is deliberately not read here: the container does not select a rollout, and a legacy rollout carrying none still records a good cwd for its thread. - The projector passes the thread id, falling back to the container only for a turn that states no thread and no subagent lineage (a root thread, where the two ids are one value). A turn that announces lineage without naming its own thread resolves no cwd rather than the root's. LLP 0083 amended in the same commit: the keying bullet was the source of the defect, plus a Correction section and the two-readers note. Fixes #459 Co-Authored-By: Claude <noreply@anthropic.com>
…untested guards Review of PR #462 (head 9dcf733) found the container fallback's safety argument does not hold for the client it exists for, and two of the new rules were unenforced by the suite. - The lineage refusal (`thread_source = subagent` / `parent_thread_id`) is only reachable when the client volunteers its lineage: `thread_source` comes from `x-codex-turn-metadata` alone and `parent_thread_id` from that header or `parent-thread-id`. `codex-tui` sends none of them on the subscription route, which is precisely why the rollout fallback exists. So for that client the refusal cannot fire, the container fallback is the ONLY path, and a `codex-tui` subagent turn still resolves the ROOT thread's cwd: the #459 defect, narrowed to one shape rather than closed. Verified against the real projector and the real usage-policy resolver: a turn with just a `session-id` header records `/work/clean/root` while its own rollout says `/work/ignored/sub`. Asserted as a DOCUMENTED GAP test and named in LLP 0083 (`#container-fallback-gap`) with the open empirical question, rather than left as "not observed in practice". Dropping the fallback is not the answer: it returns every `codex-tui` turn, root threads included, to `cwd = NULL`. - Mutation testing: deleting the `thread_source === 'subagent'` disjunct left the suite green (the existing case states lineage via the `parent-thread-id` header only), and so did treating an absent `payload.id` as a match, though both code and LLP state that rule. One test each; both now redden their own guard. - LLP 0083 records the one live/backfill divergence the identity guard introduces: a `session_meta` with a `cwd` and no `id` is tolerated by `buildSession` (filename fallback) and refused live, and refusing means the turn is recorded, not dropped. Co-Authored-By: Claude <noreply@anthropic.com>
…om Codex's own source Round 1 recorded the container fallback's residual gap as a doc note. Codex's own source (the `codex-rs` snapshot this PR already cites for `protocol.rs:3157-3184`) settles it, and the answer is a code fix. `CodexResponsesMetadata::compatibility_headers` shows what a Codex request actually carries: `x-codex-turn-metadata`, `x-codex-window-id`, `x-codex-parent-thread-id`, `x-openai-subagent`. The bare `thread-id`, `session-id` and `parent-thread-id` names the adapter reads appear nowhere in it. That made the refusal added by this PR dead in every branch: the fields it keyed on (`thread_source`, `parent_thread_id`) travel inside `x-codex-turn-metadata`, which also carries `thread_id`, so the thread-id path had already returned; and its header half read a name Codex does not send. - `resolveCodexContext` gains `subagent_signal`, folding in the two lineage headers Codex emits DIRECTLY, gated on their own value and not on the metadata blob. Those are the only lineage that survives a turn stating no thread id, so they are what make the refusal reachable. Deliberately not mirrored into `attributes` or the `parent_thread_id` column: widening what a row records is a separate change. - The refusal now consults it. One test per header; each reddens its own guard, as does removing the guard's use of the signal. - The remaining accepted case is narrowed to a turn stating a container and no lineage at all, which needs a client withholding its thread id AND every lineage signal on a subagent turn. The DOCUMENTED GAP test is rewritten to assert that, not a client-specific claim. - LLP 0083 records the header-name reading, that its "codex-tui does not send x-codex-turn-metadata" premise is at best version-specific (core emits it for every ordinary turn, `request_kind = Turn`), and that the durable fix is the body's `client_metadata.thread_id` - always present, unread today, and left out because it would newly populate `conversation_id` on rows that record null. Co-Authored-By: Claude <noreply@anthropic.com>
neutral review (head
|
| value | producers | line |
|---|---|---|
thread_id |
metadata.thread_id or the thread-id header |
:691-694 |
session_id |
metadata.session_id or the session-id header |
:695-698 |
thread_source |
metadata.thread_source only. No header fallback. |
:700 |
parent_thread_id |
metadata.parent_thread_id or the parent-thread-id header |
:704-707 |
metadata is the x-codex-turn-metadata header alone (readCodexTurnMetadata, :778-779). Per this repo, codex-tui sends that header on no subscription request (llp/0083:31, llp/0141:25-27, rollout-cwd.js:36, and the pre-existing path-faithful fixture at test/plugins/codex-rollout-cwd.test.js:66-68, a bare session-id header), and llp/0083:77 as amended says the common shape is "a session-id header and nothing else".
Read together that means: :240 never fires, :241 never fires, :242 is the only path, every turn keyed on the container. Reproduced against the real projector and the real createUsagePolicyResolver over the PR's own rollout pair, with only a session-id header on the wire:
root rollout cwd=/work/clean/root (.hypignore ignore: /work/ignored/**)
subagent cwd=/work/ignored/sub
headers { 'session-id': <container> }
=> dropped = false, projection.cwd = '/work/clean/root'
Recorded, judged against a directory it never ran in. So the branch the PR calls defensive is not defensive, and the sentence defending it - "Codex states thread_id in the same metadata as the lineage, so the shape is not observed in practice" - is inverted for a client that sends no metadata: it states neither. That sat one paragraph away from "a session-id header and nothing else" in the same doc.
Pass 2, from Codex's source: the guard was dead, and here is what Codex actually sends
The codex-rs snapshot this PR already cites (protocol.rs:3157-3184, the SessionMetaLine back-fill - same file, same lines) also contains client.rs and responses_metadata.rs, which answer the header question outright. CodexResponsesMetadata::compatibility_headers emits exactly:
x-codex-window-idx-codex-turn-metadata, whenhas_turn_metadata(), i.e.request_kind.is_some()x-codex-parent-thread-id, gated only onparent_thread_idx-openai-subagent, gated only onsubagent_header(review,compact,collab_spawn,memory_consolidation)
and client_metadata() unconditionally inserts the flat keys session_id and thread_id, which ride the request body (ResponsesApiRequest.client_metadata, client.rs:929).
Three consequences, in order of how much they matter:
(a) The refusal at :241 was dead in every branch, not merely unreachable for one client. thread_source and parent_thread_id travel inside x-codex-turn-metadata, and that blob carries thread_id too, so any turn stating them has already been answered by :240. Its only other input, the bare parent-thread-id header, is a name Codex never sends: the real one is x-codex-parent-thread-id, and readHeader (:1012-1025) is an exact case-insensitive match, not a suffix match. So metadata present ⇒ :240 returned first; metadata absent ⇒ nothing left to refuse on. The test covering it passed only because the fixture used a header Codex does not emit.
Fixed in 629fdba. resolveCodexContext gains subagent_signal, folding in the two lineage headers Codex emits directly and independently of the metadata blob, and resolveRolloutCwd refuses on it. That independence is the whole point: it is the only lineage that survives a turn stating no thread id, which is precisely the case the refusal exists for. Strictly safety-increasing - it can only turn an accepted container key into "cwd unknown", never the reverse, and when a thread id is stated :240 still returns first. Not mirrored into attributes or the parent_thread_id column: widening what a row records is a separate change (see F2).
(b) codex-tui sends x-codex-turn-metadata after all, at least in this snapshot. has_turn_metadata() is request_kind.is_some(), and the ordinary turn path sets CodexResponsesRequestKind::Turn from core (sturn.rs:342-345), not from Desktop. has_turn_identity(Turn) is true, so the payload carries session_id, thread_id, turn_id. If that holds for deployed clients, :240 fires for ordinary turns and the fallback is not on the hot path at all - which is better than the PR claims, and means llp/0083:31-32's "that is Codex Desktop behavior" is at best version-specific. Recorded in LLP 0083 rather than silently relied on.
(c) The durable fix is sitting in the request body, unread. client_metadata always carries flat session_id and thread_id, and the adapter reads neither (it looks only at the x-codex-turn-metadata header). Reading client_metadata.thread_id would give every turn its own thread id and retire the container fallback outright. I did not do it: it would newly populate thread_id, hence conversation_id (resolveRecordedContext, :828-830), on rows that record null today. That is a recorded-shape change needing its own decision and migration story, not a review fix. It is the highest-value follow-up here and is named in LLP 0083.
Verdict
The fallback stays, but not on the reasoning given. Deleting it is worse than the gap it leaves: it returns every container-only turn, root threads included, to cwd = NULL, which fails .hypignore open for that whole traffic class - the regression LLP 0083 exists to prevent. A certain loss of coverage for all root traffic beats a conditional one for subagent traffic. So the correct move was to make its guard actually reachable, which is what landed.
After the fix the accepted case is narrowed to a turn stating a container, no thread id, and no lineage of any kind. That needs a client withholding its thread id and every lineage signal on a subagent turn, and Codex withholds neither together. That is a bounded residual rather than the open hole pass 1 found, and it is asserted by a DOCUMENTED GAP: test so the next person finds an assertion instead of rediscovering it.
The one option I considered and did not push, because it is a design call: decide the ambiguity from disk - refuse the container key when another rollout in the tree declares session_id = <container> with a different id, so the container demonstrably holds more than one thread. Decidable locally with no client cooperation, but proving uniqueness means visiting every candidate instead of returning on the first name match, so it gives up the newest-first short-circuit LLP 0049 R6 rests on. Recorded in LLP 0083 as open.
Provenance, stated plainly: the codex-rs files are a snapshot in this container's shared scratchpad, left by an earlier worker; I did not fetch them and cannot pin their version. Weight accordingly - but its protocol.rs:3157-3184 matches the exact range this PR's own body cites, so it is the same artefact the author reasoned from, and the header constants are unambiguous. My fix is additive for that reason: the old names are still read, so nothing regresses if deployed clients differ.
2. The fix and the guard behave as claimed
test/plugins/codex-rollout-cwd.test.jsat9dcf733: 15 pass / 0 fail.- Same file against unmodified
mastersources (git checkout origin/master -- codex/src/): 10 pass / 5 fail, exactly as claimed, and exactly the 5 rows the table marks fails - subagent dropped by its own cwd, subagent outside an ignored root, legacy pair, body/filename mismatch, lineage-without-thread-id. The root-thread test passes both ways, so the fix does not trade the root for the subagent. - Both directions are genuinely exercised through the real projector, the real
createUsagePolicyResolver(injected fs viaignoringResolver, not a stub) and the realcreateRolloutCwdResolverover a temp sessions tree.projection.cwd === '/work/clean/sub'pins the stamped value, not just the drop. npm testat9dcf733: 2861 tests, 8 failures = thetest/core/leave-command.test.jsbaseline exactly, 0 others.npm run typecheckclean.gateway_codex_captureandcore_boot_noopsmokes ok.
Mutation results
Round 1, at 9dcf733:
| mutation | outcome |
|---|---|
drop if (codexContext.thread_id) return ... (:240) |
reddens subagent dropped by its own cwd, subagent outside an ignored root, legacy pair |
drop the whole lineage refusal (:241) |
reddens lineage stated, thread id not |
keep only the thread_source disjunct |
reddens lineage stated, thread id not |
keep only the parent_thread_id disjunct |
survived (F3) |
identity guard always passes (rollout-cwd.js:124) |
reddens body disagrees with filename |
absent payload.id treated as a match |
survived (F4) |
guard also accepts payload.session_id |
survived; acceptable, no reachable shape distinguishes it, the rule is a design note |
drop the mismatch log.warn only |
reddens body disagrees with filename (message and both id fields asserted) |
index.js stops passing log: ctx.log |
survived (N1) |
Round 2, on the guard I added:
| mutation | outcome |
|---|---|
drop x-codex-parent-thread-id from subagent_signal |
reddens lineage only via x-codex-parent-thread-id |
drop x-openai-subagent |
reddens lineage only via x-openai-subagent |
guard stops consulting subagent_signal |
reddens all three lineage tests |
Both round-1 survivors now redden their own named test. Final suite: 20 pass / 0 fail in that file; npm test 2866 tests / 8 failures (baseline, 0 others); typecheck clean; smokes ok.
3. Legacy rollouts and the Deserialize trap: clean
- The legacy pair (neither rollout carrying
session_id) resolves per thread -resolve(ROOT) = /work/clean/root,resolve(SUBAGENT) = /work/ignored/sub- and the subagent still drops. It reddens when the thread-id fix is reverted, so it is load-bearing, not decorative. - No struct-shaped read is introduced.
readRolloutCwdreads a bounded prefix, splits the first line,parseMaybeJson, then guardsisPlainObject+type === 'session_meta'+payloadobject, and takespayload.idoff that raw object (rollout-cwd.js:106-123).payload.session_idis never touched, so the back-fill atprotocol.rs:3173-3178cannot reach this code at all. The presence check is a realstringValue(payload?.id), so absent reads as absent - which is where F4 comes in. sessionIdFromPathis unchanged by hyp session ignore names the session container Codex drops on, not a thread id (#453) #458, so the import atrollout-cwd.js:10keeps its meaning under either merge order. The comment at:6-9earns its place.
4. The two-readers deferral: right to defer, wrong to leave untracked
Read readRolloutMeta on origin/fix/issue-453 (ai-gateway/src/session_command.js:802-841). The PR's characterisation holds in every particular: unexported, different plugin, requires a non-empty cwd, returns the container, its own 64KB bound, exists only on an unlanded branch. Not reusable, and importing it would couple the codex adapter to the gateway CLI across a held branch. Deferring is also right on merge hygiene - unifying means editing the file #458 rewrites, converting a clean merge into a guaranteed conflict for no functional gain.
But this is the drift-prone kind of duplication. Both copies encode the same privacy-relevant invariant (read the raw line, never a deserialized session_meta, because Codex back-fills session_id from id), and in both the failure mode is a silent wrong id. Two of the last three defects here (#453, #459) were exactly that. If one copy is later tidied into a typed helper, the other keeps working and the invariant is lost in one place with nothing failing.
A bullet in LLP 0083's Consequences ("a worthwhile follow-up") is not a work item and nothing will surface it. This needs an issue and it is not filed. Recommending rather than creating - the tracker is the human's.
Findings
F1 (major, fixed in 629fdba). The lineage refusal at :241 was unreachable in every branch against real Codex traffic, leaving the container fallback unguarded, and its header input (parent-thread-id) is a name Codex does not send. Fixed by refusing on x-codex-parent-thread-id / x-openai-subagent, the lineage Codex emits as direct headers independently of the metadata blob. Two tests, one per header; both mutate red. The residual accepted case is now container-only-with-no-lineage, asserted by a DOCUMENTED GAP: test and stated in LLP 0083 #container-fallback-gap.
F2 (medium, not fixed, needs filing). The adapter reads three header names that appear nowhere in codex-rs: thread-id, session-id, parent-thread-id. This is wider than #459 and I deliberately left it: parent_thread_id is a first-class projected column (:196) and also feeds user_type / is_sidechain (:905-908), so correcting the name changes recorded rows. Two things belong in that issue: read the body's client_metadata.thread_id / session_id (always present per client.rs:929), which is what would actually retire the container fallback; and reconcile the header set with a real client capture, since llp/0141:108-111 is explicit that no hermetic smoke can supply it. Named in LLP 0083 so it survives this comment.
F3 (minor, fixed in 636cb08). The thread_source === 'subagent' disjunct had no coverage - the existing case stated lineage via a header only, so the disjunct could be deleted with the suite green. Added a turn whose metadata states thread_source=subagent but no thread id resolves no cwd.
F4 (minor, fixed in 636cb08). "an absent payload.id reads as absent and refuses instead of matching" is asserted in rollout-cwd.js:116-117 and llp/0083:71, and nothing enforced it: treating absent as a match left the suite green. Added a test that also pins rollout_thread_id: null in the warn.
F5 (minor, fixed in 636cb08). The identity guard introduces one live/backfill divergence the PR does not mention: a session_meta with a cwd and no id is tolerated by the backfill (backfill.js:528, buildSession falls back to the filename id) and refused live. Deliberate and correct, but LLP 0083's parity claim ("live rows carry the cwd backfill reads") now has an exception, and the direction deserves stating: refusing means cwd unknown, which LLP 0049 fails open on, so a refusal here records the turn rather than dropping it. Recorded in Consequences.
F6 (minor, not fixed, needs filing). The two-readers unification. See §4.
N1 (nit). No test covers index.js:110 passing log: ctx.log; removing it leaves the suite green. PluginActivationContext.log is PluginLogger (hypaware-plugin-kernel-types.d.ts:429, :545-550) and its warn(message, fields?) matches the declared option subset, so npm run typecheck is a real guard, and the existing codexProjectorViaActivate harness (test/plugins/capture-seam-machine-local-list.test.js:294) passes no log and still activates cleanly, proving the optionality. Left alone.
N2 (nit). A mismatch refusal is cached only for NEGATIVE_CACHE_TTL_MS, but a renamed or copied rollout does not heal, so a persistently-mismatched thread re-scans and re-warns every 5s for its life. Bounded, consistent with the existing negative-cache design, and a should-never-happen. Noted so it is not a surprise in a log.
N3 (nit, pre-existing, not touched). rollout-cwd.js still carries em dashes at :23, :36, :41, :44, :83, :143 against CLAUDE.md, in a file this PR rewrote heavily and where :129 was corrected. The diff itself adds zero - verified over every added line, in both the original diff and mine.
Anchors: every @ref in the changed files resolves - LLP 0083#decision, LLP 0030#decision, LLP 0049#requirements (heading slugs), LLP 0049#prospective-only (inline {#...}, mid-line), LLP 0032#codex-repo-root and #remote-redaction (heading slugs), plus the new LLP 0083#container-fallback-gap. House style holds: no semicolons in added JS, JSDoc types only, no @typedef, no inline import('...') types.
Merge order vs #458
Confirmed at the reviewed head: git merge-tree --write-tree origin/fix/issue-459 origin/fix/issue-453 exits 0 with clean tree 38d147de6f85b1746e121fe01c4755b01dd8e9db, byte-for-byte the hash the PR body states. Still exit 0 and conflict-free after my commits (tree 37652e6d91f178e8bfb1d04d452cd2bdedba7b28 at 629fdba).
codex/src/types.d.ts is the only shared file and the edits are disjoint: #458 rewrites CodexRolloutSession (sessionId becomes the container, adds threadId); this PR edits RolloutCwdResolver / RolloutCwdResolverOptions. No LLP is shared. Checked textually rather than trusted: sessionIdFromPath - the helper rollout-cwd.js imports - is untouched by #458, so this PR's thread-id keying stays correct whichever lands first. Either order merges, no rebase needed. None of fix/issue-448, fix/issue-442, fix/issue-452, fix/issue-457, claude-desktop-consent-and-entrypoint-gate or integration/bounded-query-execution touches any file in this diff, by path intersection against each.
Verdict
Changes requested, satisfied by 636cb08 + 629fdba. The core fix is correct and now genuinely guarded; F3-F5 are closed. What a human still owns, both named in LLP 0083 so neither depends on this comment: file F2 - read client_metadata.thread_id from the body and reconcile the header names against a real client capture, which is what would retire the container fallback rather than bound it - and file F6, the two-readers unification.
New head 629fdba9b049dc1fd86a24fa3b3a6cad58395541: npm test 2866 tests / 8 failures (the leave-command baseline, 0 others), npm run typecheck clean, gateway_codex_capture + core_boot_noop ok, 20/20 in the target file. Worktree removed; no state-mutating hyp verb run and no host config touched.
…d files of em dashes Three review findings on this branch, all in the convention/doc layer; no behavior change to the capture seam. 1. LLP 0083's `## Decision` still stated the superseded key. Its bolded thesis read "keyed on the session id the adapter already resolves" and the Context still wrote the rollout name as `rollout-<ts>-<session_id>.jsonl`, which is exactly the sentence the correction section says the defect came from. The keying bullet below it says "thread id", and the code annotates `@ref LLP 0083#decision [implements]: keyed on the thread id`, so an annotation pointed at prose that contradicted it. Both restated. 2. The identity guard logged one `error_kind` for two different diagnoses. An absent `payload.id` is the one rollout shape the backfill still accepts (LLP 0083 records it as a live/backfill divergence); a mismatching id is a renamed or copied file. They now report `thread_id_absent` and `thread_id_mismatch` under the same message, and both are asserted. 3. CLAUDE.md forbids the em dash anywhere. The branch added none, but left 27 in the files it rewrites, some inside the very JSDoc blocks it edited. Removed from all six touched files, with the punctuation each sentence wants. Checks: npm test 2857 pass / 8 fail, the pre-existing leave-command baseline verified identical on origin/master; npm run typecheck clean; npm run smoke -- gateway_codex_capture ok. The #459 regression gate was re-verified by restoring the three src files from origin/master: 9 of the 20 tests fail pre-fix, including the leak-direction case, and all 20 pass after. Co-Authored-By: Claude <noreply@anthropic.com>
Review round:
|
neutral triage (LLP 0017): stuckWhat I was doing. Running the triage rung on PR #462 ( Why it cannot proceedOne true blocker, in the privacy class this repo treats as archetypal, not preferential.
if (codexContext.thread_source === 'subagent' || codexContext.subagent_signal) return undefined
Reachability, established from the tree, not assumed:
A durable fix is already in flight, but not landed. PR #467 ( The rest of the PR, for full context
The decision neededThis is the author's call, not mine to make from the reviewer's seat a third time (round 2 already declined to churn a privacy control further without this exact decision). Two shapes were already on record from round 2, plus a third from PR #467's approach:
How to unstickReply with a comment on this PR (or push to the branch); neutral monitors this thread and will re-engage with your guidance on its next tick. |
Round-2 review found that LLP 0083's new bullet asserted "the rollout-stated cwd is held to the same rule", which is not true on this branch: rollout-cwd.js returns session_meta.cwd as written and the fallback at the gate applies no predicate, so a refused in-band value falls through to a source that is still unpredicated until #466 lands. Verified by execution (a stub rollout returning `sub` still drives a drop from a .hypignore under the daemon's cwd). The same bullet also read as though the in-band cwd is now predicated in general. On the Codex route the value the predicate sees is usually the workspace key selectCodexWorkspace picked, and that substitutes the first workspace when none matches, so an absolute-but-unrelated directory still reaches the gate. Verified by execution: workspaces {'/work/clean/proj': {}} with request cwd /work/ignored/real RECORDS the exchange and stamps /work/clean/proj, and the mirror case drops a session nothing covers. Tracked as #476, which is where the decision about row content belongs. Also state the one diagnostics gap in usableInBandCwd: a cwd of exactly '' never arrives, because readStringKey and firstString both require a non-empty string, so it is refused upstream with no log. Documentation only, all inside the bullet this PR added and the helper it added, so the reapplication cost against held #467 / #462 is unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
…ssues (#476 review round 2) Round 2 of the #477 review filed each residual as its own issue so it does not live only in this paragraph: #480 (the key preempts the rollout session_meta.cwd, pre-existing), #481 (a newly-recorded row carries an ignored workspace's identity), #479 (the shared matcher never canonicalizes, so a symlinked spelling of an ignored directory escapes its .hypignore). Also corrects the third limit. It was written as a trade this amendment makes; execution against a real on-disk symlink shows it is a property of the shared matcher that predates the amendment, that the amendment swaps which of two symmetric spellings trips it rather than opening a new leak, and that the widest case (a declared symlinked key with no in-band cwd at all) is identical before and after. Notes that the matcher fix must canonicalize the local-only list entries too, since canonicalizing only the incoming cwd un-governs an entry a user marked by its symlink spelling. Doc only: exchange-projector.js stays byte-identical, so the conflict surface against #462 and #474 is unchanged. Co-Authored-By: Claude <noreply@anthropic.com>
…471) (#474) * Codex live projector: an unusable in-band cwd is a miss, not a path (#471) The in-band cwd (`codexContext.cwd` / `reqBody.cwd` / `metadata.cwd` / `metadata.user_id.cwd`, the LLP 0083 fast path) reached `resolver.resolve(cwd)` unpredicated. The matcher's first act is `path.resolve(cwd)`, so a relative value was measured against the DAEMON's process cwd: a confident `.hypignore` verdict for a directory the session never ran in, plus that bogus value stamped on the row as its container. `usableInBandCwd` now requires non-blank and absolute before the value reaches the gate, and logs a `plugin.codex.usage_policy_cwd_unusable` warn with an `error_kind` and a hashed cwd so the refusal is observable rather than silent. This does NOT convert the path to fail-closed: a refused cwd falls through to the rollout fallback, and when that states nothing too the row records `cwd = NULL` and is recorded, the existing precedent (LLP 0049 R1 as extended by LLP 0085). What changes is that an unconfirmable cwd yields an honest NULL instead of a verdict computed for the wrong directory. Co-Authored-By: Claude <noreply@anthropic.com> * Codex live cwd: state the fail-open this refusal narrows, and why the trim stays Review round 1 on #474. Two disclosure gaps in `usableInBandCwd`, both comment-only and both inside the new helper, so no extra conflict surface against the held PRs that also touch this file. - The PR reasoned that refuse-and-NULL strictly dominates accept-and-guess with "identical fail-open exposure for the should-be-ignored case". It does not. When the daemon's own process cwd sits under an ignoring `.hypignore`, the guessed base reached the correct verdict and this change now records where it previously dropped. Neither the daemon's launchd plist nor its systemd unit renders a working directory, so that cwd is `$HOME` for a `--user` unit and whatever shell started a foreground daemon. Refusing is still right (the same base produced false drops for every session that ran elsewhere), but the narrowing is real and belongs next to the code, not only in a PR body. - `cwd.trim().length > 0` gates nothing: a blank string is never absolute on either platform, so `isAbsolute` already refuses it. Dropping that conjunct leaves the whole suite green, which reads as dead code to the next person to simplify the predicate. It is load-bearing only for the `error_kind` split that tells `cwd_blank` from `cwd_not_absolute`. Say so. Co-Authored-By: Claude <noreply@anthropic.com> * Codex live cwd: name the two limits this predicate does not reach Round-2 review found that LLP 0083's new bullet asserted "the rollout-stated cwd is held to the same rule", which is not true on this branch: rollout-cwd.js returns session_meta.cwd as written and the fallback at the gate applies no predicate, so a refused in-band value falls through to a source that is still unpredicated until #466 lands. Verified by execution (a stub rollout returning `sub` still drives a drop from a .hypignore under the daemon's cwd). The same bullet also read as though the in-band cwd is now predicated in general. On the Codex route the value the predicate sees is usually the workspace key selectCodexWorkspace picked, and that substitutes the first workspace when none matches, so an absolute-but-unrelated directory still reaches the gate. Verified by execution: workspaces {'/work/clean/proj': {}} with request cwd /work/ignored/real RECORDS the exchange and stamps /work/clean/proj, and the mirror case drops a session nothing covers. Tracked as #476, which is where the decision about row content belongs. Also state the one diagnostics gap in usableInBandCwd: a cwd of exactly '' never arrives, because readStringKey and firstString both require a non-empty string, so it is refused upstream with no log. Documentation only, all inside the bullet this PR added and the helper it added, so the reapplication cost against held #467 / #462 is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: neutral-reconciler <neutral@example.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: neutral-reconciler <neutral-reconciler@users.noreply.github.com> Co-authored-by: neutral-reconciler <neutral@hyparam.dev>
…e key at the .hypignore gate (#477) * Codex live projector: an explicit cwd outranks a substituted workspace key at the .hypignore gate (#476) `selectCodexWorkspace` falls back to the first `workspaces` turn-metadata key when none matches the request's `cwd`, and `resolveCodexContext` stamped that result as the one resolved `cwd` (LLP 0083) that feeds the `.hypignore` gate. So when the request's real cwd matched no declared workspace, the privacy verdict was computed for an unrelated directory: an opted-out session could be recorded (the leak), and a session no `.hypignore` covered could be dropped. An explicit in-band `cwd` now outranks the workspace key for the gate and the stamp. The key keeps its enrichment role and still supplies the `cwd` on the subscription route, where the request states none and the key is the only in-band source there is. A refused substitution is reported as `plugin.codex.usage_policy_workspace_cwd_refused` with hashed paths. `selectCodexWorkspace` itself is untouched to keep the conflict surface with PRs #462, #467 and #474 as small as possible. Co-Authored-By: Claude <noreply@anthropic.com> * LLP 0083: record the limits of the workspace-key refusal (#476 review) Review of PR #477 verified by execution that the fix closes #476 case (a) and several unreported variants of it, and found three consequences the amendment did not state: - the workspace key still outranks the rollout fallback, so a subscription-route session that declares a `workspaces` map never consults `session_meta.cwd` and a first-key guess can still decide its verdict (true on `master` too); - because the key keeps enriching, a row recorded where it used to drop (clean in-band cwd, ignored declared workspace) carries that ignored workspace's identity; - the gate does not canonicalize, so a symlinked in-band spelling of an ignored directory is now recorded where the key's canonical spelling used to drop it. Doc only: no code change, so the projector's contended lines are untouched. Co-Authored-By: Claude <noreply@anthropic.com> * LLP 0083: the stated limits of the workspace-key refusal cite their issues (#476 review round 2) Round 2 of the #477 review filed each residual as its own issue so it does not live only in this paragraph: #480 (the key preempts the rollout session_meta.cwd, pre-existing), #481 (a newly-recorded row carries an ignored workspace's identity), #479 (the shared matcher never canonicalizes, so a symlinked spelling of an ignored directory escapes its .hypignore). Also corrects the third limit. It was written as a trade this amendment makes; execution against a real on-disk symlink shows it is a property of the shared matcher that predates the amendment, that the amendment swaps which of two symmetric spellings trips it rather than opening a new leak, and that the widest case (a declared symlinked key with no in-band cwd at all) is identical before and after. Notes that the matcher fix must canonicalize the local-only list entries too, since canonicalizing only the incoming cwd un-governs an entry a user marked by its symlink spelling. Doc only: exchange-projector.js stays byte-identical, so the conflict surface against #462 and #474 is unchanged. Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: neutral-reconciler <neutral@example.com> Co-authored-by: Claude <noreply@anthropic.com> Co-authored-by: neutral-reconciler <neutral-reconciler@users.noreply.github.com>
|
let's give PRs 456 and 462 2 more review rounds each — relayed from Slack |
|
Re-engaging with your guidance - taking the two additional review rounds you granted for this PR, resuming at the current head rather than triaging the residual findings. |
A semantic merge, not a textual one: master reworked the same Codex cwd path under this branch's feet (#466 LLP 0150, #467 LLP 0151, #474, #477), so the conflicts had to be resolved on what the combined behaviour means, not on which side's hunk looked cleaner. Four files, and what each side wanted: - exchange-projector.js: master wrapped the in-band cwd in `usableInBandCwd` (#474) and added the refused-workspace warn (#477), both around the very expression this branch replaced. Kept both, with this branch's `resolveRolloutCwd` as the fallback rather than master's inline `rolloutCwd.resolve(session_id)`, which is the container key #459 is about. - rollout-cwd.js: master replaced the local first-line read with core's one `readRolloutSessionMeta` (LLP 0150); this branch added a thread-identity guard on top of that read. Composed rather than chosen: the guard now compares `meta.threadId` from the shared reader. The two fit exactly, because LLP 0150 rule 1 (raw JSONL line, never Codex's `Deserialize`) is the property the guard depends on to see an absent `payload.id` as absent. `meta.cwd` also arrives pre-predicated by `sessionMetaCwd`, so a blank or relative rollout cwd is now refused here too. Cache key stays the thread id. - LLP 0083: took master's Context correction and its unusable-in-band bullet, kept this branch's thread-keying thesis over master's superseded "keyed on the codex session id" bullet, and reconciled the prose that #467 falsified: the thread now comes from the body's `client_metadata`, not from `thread-id` / `session-id` header names Codex never emitted. The Consequences bullet promising a shared-reader follow-up was stale (that fold has landed) and now says so. - test/plugins/codex-rollout-cwd.test.js: git merged this file cleanly and the result was wrong in both directions, which is the part worth reading. Master's #257 fixtures key the fake resolver on the session id while stating a distinct thread id, so thread keying missed; rekeyed onto the thread id, which keeps master's deliberately-distinct pair. More seriously, this branch's #459 fixtures state identity through the bare `session-id` / `thread-id` / `parent-thread-id` headers, which #467 established are names no Codex version emits and removed the reads for. Left alone, four leak-direction tests failed outright and the refusal tests would have passed VACUOUSLY, for want of any id rather than because a refusal fired, silently gutting the gate. Ported the fixtures to the body `client_metadata` surface (LLP 0151), assertions unchanged. Checked, not assumed: - Regression gate still bites: master's two source files under this merged test file fail 11 of 23, including every #459 leak-direction case and all four refusal cases, so the ported fixtures are not vacuous. - `npm test`: 3039 pass / 8 fail, exactly the `leave-command` 8 that fail identically on a pristine `origin/master` worktree (73b4618), by name. - `npm run typecheck`: clean. No em dashes, no semicolons in changed lines. The LLP anchors cited (0150#usable-cwd, 0151#body-is-authority, 0083#container-fallback-gap) all resolve. Not touched, deliberately: the open `subagent_signal` finding at `resolveRolloutCwd`. The refusal is still value-blind and its shape is unchanged, but #467 narrowed its reachability, since a turn now has to carry neither a Codex-owned `client_metadata` map nor a turn-metadata blob to reach the container fallback at all. LLP 0083 records that narrowing without pretending it closes the question. Co-Authored-By: Claude <noreply@anthropic.com>
…ind cost Round 3 review of #462. Three corrections, all established by executing the real projector over every surface combination rather than by reading: - `resolveRolloutCwd`'s JSDoc still justified the container fallback with "the common subscription-route shape (a `session-id` header and nothing else)". Since #467 / LLP 0151 that header name is neither emitted by Codex nor read by this file, so the shape it names resolves nothing. Restated against the body's `client_metadata` map, which is what actually decides reachability now. - LLP 0083 said a turn reaches the fallback "only when it carries neither a Codex-owned `client_metadata` map nor a turn-metadata blob". Executed counter-example: either surface can be present and still state no `thread_id`, and then the fallback IS entered. The accurate condition is that no surface states a `thread_id`. - The value-blind refusal's cost (`review` / `compact` / `memory_consolidation` are same-workspace sub-threads, so refusing records a turn the root's cwd would have dropped) was unasserted. Two parameterized loops now pin both halves: the cost, and the bound on it (a turn stating its thread never reaches the guard). Each mutates red on its own mutation. Co-Authored-By: Claude <noreply@anthropic.com>
neutral review, round 3 of 4 (head
|
| shape (root cwd ignored) | master |
head 7453083 |
|---|---|---|
Codex-owned client_metadata stating session only, x-openai-subagent: compact |
DROP | RECORD |
same, review |
DROP | RECORD |
same, memory_consolidation |
DROP | RECORD |
same, collab_spawn |
DROP | RECORD |
turn-metadata blob stating session only, x-openai-subagent: compact |
DROP | RECORD |
So the stuck report's finding is not stale in the sense of being wrong about the code: the guard is still value-blind, and compact / review / memory_consolidation really do convert a correct drop into a fail-open record. The report's line citations have moved (:259 is now :287, :730-737 is now :797-801) but the shape is unchanged.
What changed is the reachability, and it changed decisively.
Every shape in that table requires the turn to name a container while naming no thread_id on any surface. Since PR #467 landed (LLP 0151), the adapter reads the body's flat client_metadata map, and per this repo's own codex-rs audit that map carries session_id and thread_id on every request, while the turn-metadata blob gates the two on the same has_turn_identity. Executed: every realistic Codex shape I could construct is answered by the thread-id key at exchange-projector.js:286 and never reaches the guard at all:
| realistic shape | master |
head |
|---|---|---|
body map (session + subagent's thread) + x-openai-subagent, any of the four values |
DROP (root's ignored cwd, the #459 bug) | RECORD, cwd = /work/clean/sub (correct) |
body map (session + root's thread) + x-openai-subagent: compact |
DROP | DROP (correct, unchanged) |
turn-metadata blob (session + thread) + x-openai-subagent |
DROP | RECORD, cwd = /work/clean/sub |
corroborated flat pair, no x-codex-* key |
DROP | RECORD, cwd = /work/clean/sub |
x-openai-subagent header only, no identity surface at all |
RECORD, no cwd | RECORD, no cwd (no divergence) |
bare session-id header only (the pre-#467 fixture shape) |
RECORD, no cwd | RECORD, no cwd (resolves nothing on either side) |
Verdict (executed): the leak is unreachable from Codex traffic as codex-rs is documented to emit it. It is entered only by a client that sends a Codex-owned client_metadata map (or a turn-metadata blob) stating a container and withholding the thread id, which the repo's own header/body audit says Codex never does, and which additionally needs a rollout on disk whose thread id equals that container.
Marked honestly: the code-path half is executed. The traffic-realism half is reasoned, and it rests on LLP 0151's codex-rs audit, which I could not re-verify: the codex-rs snapshot a prior reviewer left in the shared scratchpad is gone, and codex is not on PATH. LLP 0141 is explicit that no hermetic smoke can supply this. So the narrowing is as good as that landed audit and no better.
Disposition: not a blocker. It is now the same class as the DOCUMENTED GAP test already on the branch: a bounded residual reachable only by an unobserved shape. Per the brief I did not redesign the guard. What I did instead was make the residual assert itself rather than be rediscovered a fourth time (finding F3 below).
One consequence worth naming for whoever owns the follow-up: the guard and the container fallback are now entered by exactly the same shapes. Neither protects real traffic any more. Retiring both together is a coherent future change, and it is strictly simpler than narrowing the guard by value. That is a design call, not a review fix, so I left it.
2. The merge's test-fixture rewrite: contested, and it holds. Executed.
The conflict resolver flagged this as its weakest act. I checked it three ways and it is not an adjust-the-test-to-match.
(a) The 11/23 claim reproduces exactly. git checkout 73b4618 -- codex/src/ into a worktree keeping the merged test file: 12 pass / 11 fail, and the failures are the right ones (both #257 tests, both leak-direction tests, the legacy pair, the body/filename mismatch, all four refusal tests, the absent-payload.id test). Executed.
(b) The assertions really are unchanged. Extracted every assert.* line from 31b50b3 (branch), 73b4618 (master) and the merge, and diffed the sets:
- every one of the branch's 48 assertions survives byte-identical into the merge, zero dropped;
- every one of master's 28 survives except two, which differ only by the em dash the branch's own round-2 fix replaced with a comma;
- test names: the merge is the exact union, master's 12 plus the branch's 11. Nothing was dropped to make anything pass.
(c) The port is a transport change, and most of it is master's, not the resolver's. This is the part that settles it. 73b4618's own copy of the test file already defines SUBSCRIPTION_THREAD_ID and subscriptionClientMetadata(), and already states identity through the body's client_metadata map with the session-id header removed. #467 did that port when it removed the fictional header names. The only thing master keeps is the fake resolver keyed on SUBSCRIPTION_SESSION_ID, because master's projector still passes the container. So:
- the resolver's own act on master's tests was rekeying two fakes onto the thread id, which is precisely the assertion this PR exists to make.
SUBSCRIPTION_SESSION_ID !== SUBSCRIPTION_THREAD_ID, so after the rekey a container-keyed lookup resolves nothing, and both tests correctly fail on master's sources. - the resolver's own act on the branch's new Codex subscription route resolves .hypignore against the ROOT thread's cwd for a subagent turn: rollout-cwd matches the filename thread id, not the container it is given #459 fixtures was porting them from bare
session-id/thread-id/parent-thread-idheaders ontocodexLineageBody(). That was necessary: Codex lineage reads the durable body client_metadata, not header names Codex never sends (#464) #467 removed those three reads, so left as merged those fixtures state no identity at all and the refusal assertions pass for want of an id rather than because the refusal fired. The resolver's own diagnosis of its weakest act was right, and its remedy was right.
(d) Not vacuous, at this head. Mutation-tested rather than assumed:
| mutation | outcome |
|---|---|
delete the subagent_signal refusal (:287) |
reddens all 4 refusal tests |
delete the thread-id key (:286), i.e. revert the fix |
reddens both #257 tests, both leak-direction tests, the legacy pair |
identity guard always passes (rollout-cwd.js:124) |
reddens the mismatch test and the absent-payload.id test |
Judgement: legitimate. The transport surface changed under the branch while it waited, the fixtures were ported to the surface the code now reads, and the assertions are the branch's own, unchanged.
3. The rest of the merge
exchange-projector.js: master'susableInBandCwd+ refused-workspace warn compose cleanly with the branch'sresolveRolloutCwdfallback. The??still keeps the rollout lookup lazy, andresolveRecordedContextstill receives the one resolved cwd. Net diff against master is the one call-site swap, the newresolveRolloutCwd, andsubagent_signal. Executed (the in-band fast-path test assertslookups === 0and passes).rollout-cwd.js: master's sharedreadRolloutSessionMetacomposes with the branch's identity guard. The resolver's claim thatmeta.cwdnow arrives pre-predicated checks out:sessionMetaCwd(src/core/codex/rollout_session_meta.js:176-178) refuses blank and relative values, so the guard sits behind acwdthat is already an absolute path. I would state it more narrowly than "stricter than either side alone": master already had that predicate, so what the merge adds on top of master is the identity guard, not the cwd predicate. Safe either way, and master's three predicate tests (turn_contextenvelope, blank cwd, relative cwd) all pass at this head.index.js/types.d.ts:log: ctx.logwiring and theRolloutCwdResolver.resolve(threadId)contract rename. Clean.llp/0083: already amended for LLP 0151 by the merge. Accurate except for one sentence, which is F2 below.- House style: 0 em dashes in all six touched files, and 0 on any added line of the diff against master. No semicolons in added JS, JSDoc types only, no
@typedef, no inlineimport('...')types. All@refanchors resolve (LLP 0083#decision,#container-fallback-gap,LLP 0030#decision,LLP 0049#requirements,LLP 0150,#usable-cwd,LLP 0151#body-is-authority,#real-header-names);test/core/llp-ref-hygiene.test.jspasses.
Findings
F1 (MEDIUM, fixed in 0ca6948) - the container fallback's stated justification names a shape that resolves nothing.
hypaware-core/plugins-workspace/codex/src/exchange-projector.js:253-255 (at 7453083) justified keeping the fallback with "that is the common subscription-route shape (a session-id header and nothing else)". Since #467 / LLP 0151 that header name is neither emitted by Codex nor read by this file. Executed: a request carrying only a bare session-id header produces no session_id, so the fallback returns undefined on both master and this head. The comment defends a privacy-control branch with a premise the same merge deleted, which is exactly what CLAUDE.md's "Keep refs honest" is for. Restated against the body's client_metadata map, which is what decides reachability now.
F2 (LOW, fixed in 0ca6948) - LLP 0083 understates the reachable set, in the one sentence a future reader will use to judge it.
llp/0083-codex-live-cwd-from-rollout.decision.md:140-143 (at 7453083) said a turn reaches the container fallback "only when it carries neither a Codex-owned client_metadata map nor a turn-metadata blob, because either one states the thread id". Executed counter-example: either surface can be present and state session_id without thread_id, and the fallback is then entered (the five rows of the first table above all carry a Codex-owned map or a blob). The accurate condition is no surface states a thread_id. Corrected, together with the same over-claim in the JSDoc at :263-265. Also recorded there: that the refusal and the fallback are now entered by identical shapes, and that the narrowing rests on another program's documented source rather than on anything hermetic. Editorial precision on prose this PR itself adds, not a change to a landed decision.
F3 (LOW, fixed in 0ca6948) - the value-blind refusal's cost was still unasserted, three rounds after it was first named.
Round 2 wrote "the lossy values are unasserted"; they still were. test/plugins/codex-rollout-cwd.test.js now carries two parameterized loops over review / compact / memory_consolidation:
DOCUMENTED MIRROR: ...pins the cost: with the root in an ignored directory, the refusal records a turn the container fallback would have dropped;... does NOT cost anything once the turn states its threadpins the bound: the same header on a turn that also states itsthread_id(every ordinary Codex turn) is answered by the thread-id key and drops correctly.
Only the pair says how far the residual reaches, which is why both are there. Both mutate red on their own mutation: deleting the refusal reddens the first loop, deleting the thread-id key reddens the second.
Not fixed, deliberately. I did not narrow the guard by value and did not pull #467's read further forward. The reachability answer removes the urgency, and per the brief the guard's design is triage's or the maintainer's call. My recommendation, for the record: retire the guard and the container fallback together, since they are now entered by exactly the same unobserved shapes, rather than narrowing the guard by value.
Verification
Each fix verified against the committed remote tree at 0ca69488e9aafbb8ec3c30ce4c3e836bbf471705, by reading the blobs back out of origin/fix/issue-459 after the push, not by a green suite:
- the phrase "a
session-idheader and nothing else" occurs 0 times in the remoteexchange-projector.js, and the replacement text is present at:261; - "no surface it carries states a
thread_id" is present in the remotellp/0083...md:141; DOCUMENTED MIRRORis present in the remote test file;- em dash count is 0 in all three remote blobs.
Checks
| check | at 7453083 |
at 0ca6948 |
|---|---|---|
npm test |
3048 tests, 8 fail | 3054 tests, 8 fail |
npm run typecheck |
clean | clean |
npm run smoke -- gateway_codex_capture |
not run | ok |
test/plugins/codex-rollout-cwd.test.js |
23/23 | 29/29 |
The 8 failures are the pre-existing test/core/leave-command.test.js baseline, confirmed by name against a pristine origin/master worktree: master runs 3037 tests with the same 8 not ok lines, byte-identical titles (leave after join removes the seed... through leave self-heals an org attach whose plugin is gone...). 0 new failures in either direction.
Both worktrees carried a symlinked node_modules (no lockfile, so npm ci is not available); without it the suite under-collects and the baseline comparison is meaningless.
git merge-tree --write-tree origin/master origin/fix/issue-459 exits 0, no conflicts. The branch is 0 commits behind master.
gh actions taken
gh pr view 462(metadata, comments), read only.git push origin HEAD:fix/issue-459->7453083..0ca6948.- this one comment.
No gh pr merge, no gh pr ready, no label or state change, PR body untouched, no state-mutating hyp verb run, no host config touched. /work/hypaware was never written to; all three worktrees removed.
…ns it Round 4 review of #462, the last granted round. Rounds 2 and 3 narrowed the value-blind `subagent_signal` refusal to "unreachable from Codex traffic" but could not verify the premise it rests on, because the `codex-rs` snapshot was gone. Verified here by reading the emitting source directly (`openai/codex`, `codex-rs/core/src/responses_metadata.rs`, commit `1def0a8`, 2026-07-28), and the premise holds, but not for the stated reason. - The load-bearing invariant is not "each surface states a `thread_id`". That is false of the turn-metadata blob. It is that both surfaces emit `session_id` and `thread_id` AS A PAIR: `client_metadata` inserts them into one map literal, unconditionally, from two non-`Option` `String` fields, and `turn_metadata_payload` gates both on the same `has_turn_identity`. Neither surface can supply the container the fallback needs while withholding the thread that pre-empts it. - `has_turn_identity` is false for exactly one kind, `Memory`, which still emits the lineage the refusal keys on (`thread_source`, `parent_thread_id`, `x-openai-subagent: memory_consolidation`) with no id pair in the blob. So memory consolidation is the closest real Codex shape to the refusal's trigger, and what keeps it out is the flat body map ALONE, not the blob. The JSDoc and LLP 0083 both said or implied the blob answers such a turn. - Pinned by a test rather than left as prose: a memory-consolidation turn whose blob states lineage and neither id. It reddens if the body-map read for `thread_id` is removed, which is the change that would send this shape into the refusal and fail `.hypignore` open on it. LLP 0083 also now records the citation and the standing of the claim (upstream `main` snapshot, emitting code rather than captured traffic) so the next reader re-checks it in one step instead of re-deriving it a fifth time. Co-Authored-By: Claude <noreply@anthropic.com>
neutral review, round 4 of 4 (head
|
| check | origin/master (baseline) |
head 0ca6948 |
head 4dcd246 |
|---|---|---|---|
npm test |
3037 tests, 8 fail | 3054 tests, 8 fail | 3055 tests, 8 fail |
npm run typecheck |
- | clean | clean |
test/plugins/codex-rollout-cwd.test.js |
- | 29/29 | 30/30 |
test/core/llp-ref-hygiene.test.js |
- | 9/9 | 9/9 |
The 8 failures are the pre-existing test/core/leave-command.test.js baseline, confirmed by name against a pristine origin/master worktree I ran myself: the sorted failure-title sets at head and baseline are byte-identical (diff exit 0), all 8 titled leave .... 0 new failures, 0 incidentally fixed.
To triage: the core fix is correct, guarded, and now tested at the shape that matters. The one thing a human should still weigh before merge is the standing of section 1: the guard's residual is bounded by a reading of another program's source at a moving main, which is evidence rather than proof, and no hermetic check in this repo can hold it. Every prior round found its problems in exactly that gap between what the code does and what its prose claims, so the follow-up worth opening is the guard/fallback retirement above, not another review round.
neutral triage (LLP 0017), head
|
One conflict, in `llp/0083`'s `Related:` header line: #462 added LLP 0066 and LLP 0067, this branch added LLP 0160. Resolved as the union, since the two edits are independent additions to a list. Everything else merged textually clean, including `codex/src/exchange-projector.js`, where the two sides turned out to touch disjoint concerns: #462 rewrote the rollout fallback's lookup key (session container to thread id, the new `resolveRolloutCwd`), while this branch changed `refused_workspace_cwd`'s predicate in `resolveCodexContext` and the warn's comment block above it. Checked rather than assumed: - `isEqualOrDescendant` is byte-identical on master and still exported from the usage-policy index, so the shared predicate this branch reuses (LLP 0069 R8) is the same function it was written against. - The two review-established facts survive. Nearest-governs is intact (nearest `.hypignore` walk byte-identical, deepest list entry via `matchDepth`), so the disclosed non-monotonicity is still real and the pinning test still has something to pin. The narrowed warn still carries no `class` / `declared` / `governed_by`, which is the executed ground the disposition rests on. - `scopeGoverns` and `canonicalSpellings` still sit next door after #484, so the deliberate choice of the lexical predicate on this hot path (LLP 0049 R6) remains a live choice rather than a stale one. - LLP 0160 §corrections-0083 says 0083's closing #476 sentence went stale. That bullet is byte-identical between the merge base and master, so the correction still lands where it says it does. - `pathsEqual` is still used by `selectCodexWorkspace`, so it is not orphaned by the predicate swap. Checks on the merge result: `npm test` 3081 pass / 8 fail, `npm run typecheck` clean. The 8 are `test/core/leave-command.test.js`, pre-existing: a pristine `origin/master` (bd7dd0b) worktree fails the identical 8 by name, diffed as sorted name lists with zero difference, at 3078 pass / 8 fail. The merge adds exactly the 3 new #481 passes and no new failures. Co-Authored-By: Claude <noreply@anthropic.com>
The defect
createRolloutCwdResolverlocates a rollout by the id embedded in its filename, which is the thread (session_meta.payload.id). The projector called it with the session container:The two are the same uuid on a root thread, so every hand-check passed. A subagent thread inherits its root's container and mints its own thread id, so a subagent turn on the ChatGPT-subscription route resolved the root's rollout.
That cwd is what
.hypignoreis evaluated against (LLP 0083 / LLP 0050, the capture-seam drop), and it is also the cwd stamped on the row. So a subagent running in anignoredirectory whose root was not got recorded: a directory-scoped privacy control silently not applying, on a row that also claimed a directory the turn never ran in. (The mirror case, an over-drop, loses data but leaks nothing.)Callers traced
There is exactly one caller.
codex/src/index.jsconstructs the resolver;codex/src/exchange-projector.jsis the only consumer, at the single??fallback above. It passes the container (codexContext.session_id, frommetadata.session_idor thesession-idheader) whileresolveCodexContexthas already resolvedcodexContext.thread_id(frommetadata.thread_idor thethread-idheader) two lines away, plusparent_thread_idandthread_source. So the turn's own thread id was available the whole time. No other module importsrollout-cwd.js.Shape chosen: (a) + (c), with a narrow (b)
(a) Pass the thread id where a thread id is meant. This is the actual fix: the caller had the value.
resolve()'s parameter is renamed and its contract now states that the container is the wrong key and why.(c) Refuse on mismatch, regardless. Agreed with the issue that this is right independent of (a): after locating by name, the body's
payload.idmust equal the id asked for. Note precisely what (c) does and does not do here. It does not fix the reported bug on its own: with the container passed in, the located root rollout'spayload.idequals the container, so the guard passes and the leak stands. What it fixes is the class the filename convention exposes: a renamed, copied, or convention-changed file now yields "cwd unknown" instead of some other thread's cwd deciding this turn and being stamped on the row. That refusal used to be structurally impossible to notice, so it is logged (plugin.codex.rollout_cwd_thread_mismatch, warn) - its only other trace is a row withcwd = NULL, indistinguishable from the ordinary not-yet-written rollout.(b) only as far as it pays for itself. The filename stays the candidate selector, because the newest-first bounded walk is what keeps this off the capture hot path (LLP 0049 R6) and opening every candidate would undo that. The body is then the authority on identity. So the function no longer trusts a convention it does not own, without paying to stop using it.
payload.session_idis deliberately not read here. The container does not select a rollout, and a legacy rollout carrying no container still records a perfectly good cwd for its thread, so requiring one would break cwd resolution for pre-container Codex for no gain.One judgement call worth review: when a turn states no thread id, the container is still the right key for a root thread (there the two ids are one value), so that fallback stays, or the LLP 0083 fix regresses for the traffic class it exists for. (This paragraph originally justified the fallback with "the common subscription-route shape, a
session-idheader and nothing else". That justification was wrong by the time it was read: #467 / LLP 0151 established thatsession-idis not a name Codex emits nor one this file reads. Corrected in0ca6948.) It is abandoned the moment the turn announces subagent lineage without naming its own thread:thread_source = subagent, aparent_thread_id, or anx-openai-subagentheader (thesubagent_signalguard). That turn's rollout is not identifiable from the wire, and an unknown cwd (LLP 0049 fails open, row records NULL) is preferred to confidently enforcing and stamping the root's directory.That guard is value-blind, which is a real and now-documented residual:
x-openai-subagent's values are subagent kinds, and three of the four (review,compact,memory_consolidation) are sub-threads of the root's own workspace, where the root's cwd is the correct answer. Refusing them records a turn the fallback would have dropped, which is this PR's own leak direction. It is accepted because it needs a request shapecodex-rscannot produce: on both surfaces Codex emitssession_idandthread_idas a pair, so nothing can name a container while withholding the thread that pre-empts the guard. See LLP 0083#container-fallback-gapfor the source citation, plus theDOCUMENTED MIRRORand memory-consolidation tests, which pin the cost and the bound rather than leaving them as prose.readRolloutMeta: not reused, and no second reader introducedChecked, and it is not reusable here.
readRolloutMetais a private (unexported) function inai-gateway/src/session_command.js- a different plugin - it only exists on #458's unlanded branch, and it is shaped for the CLI's one-shot scan (requires a non-emptycwd, returns the container, its own byte bound). Importing it would couple the codex adapter to the gateway CLI and would take a dependency on a held branch.Nor is this a second reader appearing:
readRolloutCwdpredatesreadRolloutMetaand already had the right discipline (raw JSONL line,type === 'session_meta'guard) - #458's own comment points at it for exactly that reason ("codex/src/rollout-cwd.jstype-checks the same line for the same reason"). What this PR does is carry the remaining two rules across rather than re-derive them: read the raw line (Codex's hand-writtenDeserializeback-fillssession_idfromid, so a struct-shaped read cannot tell legacy from root -codex-rs/protocol/src/protocol.rs:3157-3184), and treat an id that cannot be confirmed as unresolvable. The two readers now agree on discipline while keeping their different jobs. Folding them into one shared reader was noted in LLP 0083 as a follow-up, deliberately not done here: it would mean editingsession_command.js, which #458 rewrites. That fold has since landed on master (LLP 0150): bothrollout-cwd.jsandsession_command.jsnow read through core's singlereadRolloutSessionMeta, so this section records the state at the time of writing, not the state at the current head. What stays local to this resolver is the thread-identity guard, which is not a read.Ground-truth gate
test/plugins/codex-rollout-cwd.test.js, all exercising the drop decision through the real projector, the realcreateUsagePolicyResolver, and the realcreateRolloutCwdResolverover a temp sessions tree holding a subagent-shaped rollout pair (a root whose id is also the container, and a subagent that inherits the container, mints its own thread id, and records its owncwd).master/work/clean/root, subagent in/work/ignored/sub(ignored) ⇒USAGE_POLICY_DROP. The leak direction./work/ignored/root, subagent in/work/clean/sub⇒ not dropped, andprojection.cwd === '/work/clean/sub'session_idpayload.idis A ⇒resolve(B)isundefined, and oneplugin.codex.rollout_cwd_thread_mismatchwarn carries both idsthread_id⇒ not dropped andcwd === undefined: an unknown cwd is recorded as unknown, not as the root'sProven both ways. As first written: 5 of the 6 fail against unmodified
mastersources with the new test file in place (10 pass / 5 fail), all 15 pass after. At the current head the file carries 30 tests, all passing, after #467 moved identity onto the request body (fixtures ported accordingly) and three review rounds added the refusal, mismatch and residual cases. Re-verified by mutation: deleting the thread-id key reddens 8, deleting thesubagent_signalrefusal reddens 7, and dropping the body-mapthread_idread reddens 9.Checks
npm test: 3055 tests, 8 failures at the current head, exactly the pre-existingtest/core/leave-command.test.jsbaseline (origin/masterruns 3037 with the same 8 failure titles). No new failures. (Originally reported as 2861/8, before the merge with master.)npm run typecheck: clean.npm run smoke -- gateway_codex_capture: ok.LLP
llp/0083amended in the same commit, because its "Keyed on the codex session id" bullet is where the defect came from. The keying bullets are rewritten (thread id, plus the confirm-the-body rule and the root-only container fallback), and a## Correction: the first cut keyed on the container (issue #459)section records how the root/subagent coincidence hid it, why the leak direction is the one that matters, and how it relates to #453 on the other call path. Two consequences added: the mismatch log, and the two-readers note above.Merge order vs #458 (resolved: #458 merged 2026-07-30)
#458 has since merged and its branch is deleted, and this branch has merged
origin/master, so the ordering question below is settled and themerge-treecheck is no longer runnable againstfix/issue-453. Retained as the record of what was checked at the time.No overlap in the changed set: #458 touches
session_command.js,codex/src/backfill.js,codex/src/types.d.ts, the privacy skill, and LLPs 0030/0066/0067. This PR touchescodex/src/rollout-cwd.js,codex/src/exchange-projector.js,codex/src/index.js,codex/src/types.d.ts, LLP 0083, and one test file. Onlycodex/src/types.d.tsand no LLP is shared, and the two edit different interfaces in it (#458:CodexRolloutSession; here:RolloutCwdResolver/RolloutCwdResolverOptions).git merge-tree --write-tree fix/issue-459 origin/fix/issue-453⇒ exit 0, clean tree38d147de6f85b1746e121fe01c4755b01dd8e9db, no conflicts. Either order merges. Also checked: none offix/issue-448,fix/issue-442,fix/issue-452,claude-desktop-consent-and-entrypoint-gate, orintegration/bounded-query-execution(#221) touches any file in this diff. Was based onorigin/master; the branch now contains a merge oforigin/master(7453083) rather than being linear on it.Fixes #459