Skip to content

Codex rollout cwd keys on the thread, not the session container (#459) - #462

Merged
philcunliffe merged 7 commits into
masterfrom
fix/issue-459
Jul 31, 2026
Merged

Codex rollout cwd keys on the thread, not the session container (#459)#462
philcunliffe merged 7 commits into
masterfrom
fix/issue-459

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

The defect

createRolloutCwdResolver locates 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:

?? (codexContext?.session_id ? rolloutCwd?.resolve(codexContext.session_id) : undefined)

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 .hypignore is 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 an ignore directory 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.js constructs the resolver; codex/src/exchange-projector.js is the only consumer, at the single ?? fallback above. It passes the container (codexContext.session_id, from metadata.session_id or the session-id header) while resolveCodexContext has already resolved codexContext.thread_id (from metadata.thread_id or the thread-id header) two lines away, plus parent_thread_id and thread_source. So the turn's own thread id was available the whole time. No other module imports rollout-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.id must 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's payload.id equals 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 with cwd = 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_id is 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-id header and nothing else". That justification was wrong by the time it was read: #467 / LLP 0151 established that session-id is not a name Codex emits nor one this file reads. Corrected in 0ca6948.) It is abandoned the moment the turn announces subagent lineage without naming its own thread: thread_source = subagent, a parent_thread_id, or an x-openai-subagent header (the subagent_signal guard). 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 shape codex-rs cannot produce: on both surfaces Codex emits session_id and thread_id as a pair, so nothing can name a container while withholding the thread that pre-empts the guard. See LLP 0083 #container-fallback-gap for the source citation, plus the DOCUMENTED MIRROR and memory-consolidation tests, which pin the cost and the bound rather than leaving them as prose.

readRolloutMeta: not reused, and no second reader introduced

Checked, and it is not reusable here. readRolloutMeta is a private (unexported) function in ai-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-empty cwd, 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: readRolloutCwd predates readRolloutMeta and 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.js type-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-written Deserialize back-fills session_id from id, 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 editing session_command.js, which #458 rewrites. That fold has since landed on master (LLP 0150): both rollout-cwd.js and session_command.js now read through core's single readRolloutSessionMeta, 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 real createUsagePolicyResolver, and the real createRolloutCwdResolver over 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 own cwd).

test asserts on master
subagent dropped by its own cwd root in /work/clean/root, subagent in /work/ignored/sub (ignored) ⇒ USAGE_POLICY_DROP. The leak direction. fails (recorded)
subagent outside an ignored root is recorded the reverse: root in /work/ignored/root, subagent in /work/clean/sub ⇒ not dropped, and projection.cwd === '/work/clean/sub' fails (dropped)
root thread still resolves its own cwd a root turn (thread id == container) still drops on the root's ignored cwd: the fix does not trade the subagent for the root passes
legacy pair with no session_id neither rollout carries a container; both threads still resolve their own cwd, and the subagent still drops fails (projector half)
body disagrees with filename a rollout named for thread B whose payload.id is A ⇒ resolve(B) is undefined, and one plugin.codex.rollout_cwd_thread_mismatch warn carries both ids fails (returns A's cwd)
lineage stated, thread id not lineage without a thread_id ⇒ not dropped and cwd === undefined: an unknown cwd is recorded as unknown, not as the root's fails (used the root's)

Proven both ways. As first written: 5 of the 6 fail against unmodified master sources 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 the subagent_signal refusal reddens 7, and dropping the body-map thread_id read reddens 9.

Checks

  • npm test: 3055 tests, 8 failures at the current head, exactly the pre-existing test/core/leave-command.test.js baseline (origin/master runs 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.
  • No em dashes in any added line; no semicolons; JSDoc types only.

LLP

llp/0083 amended 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 the merge-tree check is no longer runnable against fix/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 touches codex/src/rollout-cwd.js, codex/src/exchange-projector.js, codex/src/index.js, codex/src/types.d.ts, LLP 0083, and one test file. Only codex/src/types.d.ts and 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 tree 38d147de6f85b1746e121fe01c4755b01dd8e9db, no conflicts. Either order merges. Also checked: none of fix/issue-448, fix/issue-442, fix/issue-452, claude-desktop-consent-and-entrypoint-gate, or integration/bounded-query-execution (#221) touches any file in this diff. Was based on origin/master; the branch now contains a merge of origin/master (7453083) rather than being linear on it.

Fixes #459

neutral-loop and others added 3 commits July 29, 2026 23:11
`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>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

neutral review (head 9dcf733)

Reviewed in a detached worktree off origin/master. The fix is right, the ground-truth gate is real, and the claimed numbers all reproduce. Two findings are material: the guard the PR added to protect the container fallback was dead in every branch against real Codex traffic, and three of the header names it depends on do not exist in Codex's source. Both are fixed and pushed (636cb08, 629fdba). No gh pr merge / gh pr ready; PR body untouched.

The PR asked for the fallback to be judged independently. It took two passes: the first from this repo's own documents, the second from Codex's source, which reverses part of the first answer. Both are below, because the second only makes sense against the first.


1. The judgement call: the container fallback at :242

Pass 1, from this repo: the fallback's guard cannot fire for the client it exists for

Every producer of the two guard inputs, in resolveCodexContext (codex/src/exchange-projector.js:678-761 at 9dcf733):

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-id
  • x-codex-turn-metadata, when has_turn_metadata(), i.e. request_kind.is_some()
  • x-codex-parent-thread-id, gated only on parent_thread_id
  • x-openai-subagent, gated only on subagent_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.js at 9dcf733: 15 pass / 0 fail.
  • Same file against unmodified master sources (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 via ignoringResolver, not a stub) and the real createRolloutCwdResolver over a temp sessions tree. projection.cwd === '/work/clean/sub' pins the stamped value, not just the drop.
  • npm test at 9dcf733: 2861 tests, 8 failures = the test/core/leave-command.test.js baseline exactly, 0 others. npm run typecheck clean. gateway_codex_capture and core_boot_noop smokes 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. readRolloutCwd reads a bounded prefix, splits the first line, parseMaybeJson, then guards isPlainObject + type === 'session_meta' + payload object, and takes payload.id off that raw object (rollout-cwd.js:106-123). payload.session_id is never touched, so the back-fill at protocol.rs:3173-3178 cannot reach this code at all. The presence check is a real stringValue(payload?.id), so absent reads as absent - which is where F4 comes in.
  • sessionIdFromPath is unchanged by hyp session ignore names the session container Codex drops on, not a thread id (#453) #458, so the import at rollout-cwd.js:10 keeps its meaning under either merge order. The comment at :6-9 earns 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>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round: 629fdba9 (neutral reconciler, code-review path; codex not on PATH so no second Codex family reviewer)

Verdict: findings. The fix is correct, the regression gate is real, and I fixed 4 actionable findings on the branch (31b50b3). One design question is left for the author because reversing it would rewrite a decision this PR records in LLP 0083.

The fix itself: sound, and the ground-truth gate holds

I verified the regression gate independently rather than taking the PR body's word. Restoring the three source files from origin/master into a worktree that keeps the new test file:

  • 9 of 20 fail pre-fix, all 20 pass after. The leak-direction case (a subagent turn is .hypignore-dropped by ITS OWN rollout cwd, not the root thread's) is among the pre-fix failures, so the test genuinely exercises the bug in the direction issue 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 says matters (recorded when it should have been dropped), not just the happy path.
  • The tests drive the real projector, the real createUsagePolicyResolver, and the real createRolloutCwdResolver over a temp sessions tree. They are not vacuous.
  • Caller trace confirmed: grep finds exactly one importer of rollout-cwd.js (codex/src/index.js) and one consumer of resolve() (exchange-projector.js:121). No other call site passes the container.
  • subagent_signal does not reach a row: attributes is built field-by-field with setIfString (exchange-projector.js:763-776) and only codexContext.attributes is ever spread (exchange-projector.js:167), so the code comment's claim checks out.
  • The premise the fix rests on is corroborated in-repo: codex/src/backfill.js:538-542 already reads thread_source and parent_thread_id out of a rollout's own session_meta, so subagent threads do get their own rollout carrying their own cwd.
  • All added @ref anchors resolve: llp/0030…#decision (## Decision), llp/0083…#decision (## Decision), llp/0083…#container-fallback-gap (explicit {#container-fallback-gap}), llp/0049…#requirements (## Requirements {#requirements}).

Findings

1. MEDIUM (fixed) - LLP 0083's ## Decision still stated the superseded key, and a code @ref pointed at it.
llp/0083-codex-live-cwd-from-rollout.decision.md:52-53 (pre-fix) read: "falls back to the session rollout's session_meta.cwd, keyed on the session id the adapter already resolves", and llp/0083…:47 still wrote the rollout name as rollout-<ts>-<session_id>.jsonl. That is the exact sentence the PR's own correction section identifies as where the defect came from. Meanwhile the bullet at llp/0083…:63 says "Keyed on the codex thread id", and rollout-cwd.js:44 annotates @ref LLP 0083#decision [implements]: keyed on the thread id, and the located rollout must say so - an annotation aimed at prose that contradicted it. This is the "Keep refs honest" / "Living docs" rule in CLAUDE.md. Fixed: the thesis and the Context filename now say thread, with a pointer to the correction section.

2. LOW (fixed) - the identity guard logs one error_kind for two different diagnoses.
rollout-cwd.js:129 (pre-fix) emitted error_kind: 'thread_id_mismatch' for both a genuine id disagreement and an absent payload.id. Those have different causes and different follow-ups: an absent id is the one rollout shape the backfill still accepts (buildSession falls back to the filename id), which LLP 0083's Consequences record as a deliberate live/backfill divergence; a mismatch means a renamed or copied file. Conflating them defeats the point of the log, which CLAUDE.md's Log-Driven Development section asks to identify the broken step. Fixed: thread_id_absent vs thread_id_mismatch under the same message (so one query still finds both), asserted at test/plugins/codex-rollout-cwd.test.js:406 and :516, and recorded in LLP 0083's Consequences.

3. LOW (fixed) - 27 em dashes left in the six files this PR rewrites.
CLAUDE.md forbids U+2014 "anywhere: code, comments, JSDoc, strings, or docs". The branch added none (verified over every + line), and opportunistically removed a few, but left 9 in rollout-cwd.js, 9 in llp/0083…, 6 in the test file, 2 in exchange-projector.js, 1 in types.d.ts - several inside the very JSDoc blocks the PR was editing (e.g. rollout-cwd.js:36-44, the block whose contract it rewrote). Fixed in all six touched files, with the punctuation each sentence wants. The repo-wide backlog (92 other files) is out of scope.

4. MEDIUM (NOT fixed - needs the author's call) - the lineage refusal is value-blind, and when it fires it converts a near-certainly-correct drop into a fail-open record.
exchange-projector.js:259:

if (codexContext.thread_source === 'subagent' || codexContext.subagent_signal) return undefined

subagent_signal includes any value of x-openai-subagent, which per this PR's own LLP text covers review, compact, collab_spawn, and memory_consolidation. Three of those four are operations on the root session's own workspace, so the root's cwd is the right answer for them. When such a turn arrives with a container and no thread id, master resolved the root rollout and correctly dropped an ignored directory; this branch returns undefined, LLP 0049 fails open, and the turn is recorded. That is the same leak direction #459 is about, newly introduced, and it hits the common case (subagents inherit the parent's cwd) to protect the rare one. The tests pin only collab_spawn and x-codex-parent-thread-id (test/plugins/codex-rollout-cwd.test.js, the header loop), so the lossy values are unasserted.

Reachability is narrow and genuinely uncertain: the branch needs the x-codex-turn-metadata blob to be absent or to omit thread_id, and LLP 0083 itself now says core emits that blob for every ordinary turn. I did not change this, because the LLP records the opposite trade in this same commit ("a wrong cwd is a false statement about where a turn ran; an absent one is true"), round 2 added the guard in response to a prior review, and choosing between the two positions depends on codex-rs behavior I cannot verify hermetically. Flipping it a third time from the reviewer's seat would be churn on a privacy control. Two ways forward, both the author's to pick:

  • Narrow the disjunct to lineage that names a genuinely different thread, and let same-workspace sub-thread kinds keep the container fallback; or
  • Take the durable fix the LLP already names as better: read client_metadata.thread_id from the request body as the rollout lookup key only. The LLP declines it because it "would newly populate thread_id, hence conversation_id, on rows" - but that objection does not apply to lookup-key-only use, which touches no column and no attributes entry. This closes the documented #container-fallback-gap and makes the lossy refusal unnecessary.

Informational (no action)

  • The PR body's test table is stale: it says 6 tests / 5 failing pre-fix, but the head carries 11 new tests and 9 fail pre-fix. The gate is stronger than advertised, not weaker. PR body deliberately not edited.
  • Branch is 2 commits behind origin/master (merge base f9b9667, master at c551d6e). git merge-tree is clean, no conflicts.
  • The 8 test/core/leave-command.test.js failures are the pre-existing baseline: I ran the same suite on a fresh origin/master worktree and got the identical 8.

Checks run on the pushed head

  • npm test: 2857 pass / 8 fail / 1 skipped, the verified leave-command baseline. No new failures.
  • npm run typecheck: clean.
  • npm run smoke -- gateway_codex_capture: ok.
  • No em dashes and no semicolons in any added line.

Pushed

31b50b31bb46356c291cff80e4b1d7cf7336d31d on fix/issue-459. Each fix verified against the pre-fix tree with git diff 629fdba9..HEAD -- <file>: the LLP thesis and filename lines changed, error_kind became conditional with both values asserted in the test file, and the em dash count in every touched file went to 0 (from 2 / 9 / 1 / 6 / 9).

@philcunliffe philcunliffe added the neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human label Jul 30, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

neutral triage (LLP 0017): stuck

What I was doing. Running the triage rung on PR #462 (fix/issue-459, head 31b50b3) after the review fix-loop closed out its 2 rounds with one finding still open (629fdba9 finding 4). Triage classifies every residual review finding all-or-nothing: if every one is non-blocking the PR ships, if any one is a true blocker it cannot merge without a human call. I read both marker-signed review records (9dcf7333, 629fdba9), verified each finding against the actual tree in a detached worktree at 31b50b3, and read codex-rs's own header-building source (client.rs, responses_metadata.rs) that a prior reviewer left in the shared scratchpad, to answer the one open reachability question myself rather than take it on faith.

Why it cannot proceed

One true blocker, in the privacy class this repo treats as archetypal, not preferential.

hypaware-core/plugins-workspace/codex/src/exchange-projector.js:259:

if (codexContext.thread_source === 'subagent' || codexContext.subagent_signal) return undefined

subagent_signal is set from the bare x-openai-subagent header with no value check (exchange-projector.js:730-737). Per this PR's own LLP text and the codex-rs snapshot, that header's real values are review, compact, collab_spawn, memory_consolidation. Three of the four (review, compact, memory_consolidation) are same-workspace sub-threads, so the root's cwd is the correct answer for them. When such a turn arrives with a container id and no thread id, master resolved the root rollout and correctly dropped a turn in an ignored directory; this branch returns undefined, .hypignore fails open (LLP 0049), and the turn is recorded. That is the same leak direction issue #459 exists to close, reintroduced by the guard meant to bound the fallback that fixes it, and it hits the common case (subagents inherit the parent's cwd) to protect the rare one.

Reachability, established from the tree, not assumed:

  • The guard only matters when codexContext.session_id is truthy and codexContext.thread_id is falsy (otherwise line 240 already returns via the thread id, or line 261's fallback has nothing to resolve either way). I read codex-rs's responses_metadata.rs: inside the x-codex-turn-metadata blob, session_id and thread_id are gated by the same has_turn_identity boolean, so on that surface they are always present together or absent together, never one without the other. That narrows the live-fire window to turns where the metadata blob is not sent at all while x-openai-subagent still is, which compatibility_headers() allows structurally (the subagent-header block is gated only on self.subagent_header, independent of whether the turn-metadata blob is sent).
  • That is not hypothetical for this repo's own traffic model: the pre-existing #257 regression (test/plugins/codex-rollout-cwd.test.js:58-71, unmodified by this PR) establishes as the real subscription-route shape "codex-tui does NOT send x-codex-turn-metadata... it does carry a session-id header." Combined with the header independence above, a subscription-route subagent turn carrying a bare session-id header, no thread id, and x-openai-subagent: compact (or review, or memory_consolidation) is consistent with the traffic pattern this codebase already treats as real, not manufactured.
  • This exact shape is already asserted and passing in the current suite: test/plugins/codex-rollout-cwd.test.js:464-495 sends { 'session-id': ROOT_SESSION_ID, 'x-openai-subagent': 'collab_spawn' } (no thread id, no metadata blob) and asserts projection.cwd === undefined. Round 2's own mutation table confirms this reddens if the guard is removed. The guard's code is value-blind (codexContext.subagent_signal truthy, not switched on which value), so the same mechanism the suite proves reachable for collab_spawn fires identically for compact, review, and memory_consolidation since they populate the same field through the same header. Those three values are simply untested for their lossy consequence (round 2's own finding: "the lossy values are unasserted").
  • My verdict: reachable in practice, not merely theoretical. I could not rule it out, and the evidence (an existing, traffic-verified regression pattern plus the value-blind code path this PR's own tests already exercise for a sibling value) points the other way. I did not find a way to prove the guard is dead code; I found reason to believe it is not.

A durable fix is already in flight, but not landed. PR #467 (fix/issue-464, open, not merged) reads session_id/thread_id from the request body's client_metadata map, which per its own root-cause writeup is populated on every request regardless of turn kind. That would retire the whole container-fallback ambiguity, including this finding, by construction. It is not merged, so it does not help PR #462 today. I checked PR #467 for consistency with this PR since both touch x-openai-subagent: #467 declines to read it for is_sidechain classification ("would change what is_sidechain means... a separate decision"), while PR #462 only ever uses it to gate whether the fallback resolves, never mirroring it into attributes or a column (exchange-projector.js:730-736, comment states this explicitly). The two are scoped differently and I found no conflict between them.

The rest of the PR, for full context

The decision needed

This 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:

  1. Narrow the guard by value. Only refuse on lineage that names a genuinely different thread; let compact/review/memory_consolidation (same-workspace kinds) keep the container fallback, since the root cwd is correct for them, and reserve the refusal for values that actually indicate a different workspace.
  2. Accept the trade as LLP 0083 records it. The doc states the position plainly: "a wrong cwd is a false statement about where a turn ran; an absent one is true." Keep the guard value-blind, accept the narrowed-but-nonzero recorded-when-should-drop residual as the cost of never mis-attributing a directory, and let PR Codex lineage reads the durable body client_metadata, not header names Codex never sends (#464) #467 (or a rebase onto it) retire the ambiguity later.
  3. Pull forward the durable fix. Land PR Codex lineage reads the durable body client_metadata, not header names Codex never sends (#464) #467's client_metadata.thread_id read (or the lookup-key-only version round 2 suggested, which touches no recorded column) into this PR, which would make the guard's fail-open scenario unreachable by construction rather than by argument.

How to unstick

Reply 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.

philcunliffe pushed a commit that referenced this pull request Jul 30, 2026
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>
philcunliffe pushed a commit that referenced this pull request Jul 30, 2026
…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>
philcunliffe added a commit that referenced this pull request Jul 30, 2026
…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>
philcunliffe added a commit that referenced this pull request Jul 30, 2026
…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>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

let's give PRs 456 and 462 2 more review rounds each

— relayed from Slack

@philcunliffe philcunliffe removed the neutral:stuck neutral attempted this but cannot complete it autonomously — needs a human label Jul 30, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

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.

test and others added 2 commits July 30, 2026 23:01
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>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

neutral review, round 3 of 4 (head 7453083, the merge commit)

This round runs past the CLI's 2-round review cap because the maintainer explicitly granted two more rounds on this thread ("let's give PRs 456 and 462 2 more review rounds each", relayed from Slack, acknowledged at <!-- neutral-ack -->). This is round 3 of the 4 granted, so one granted round remains after this one. A future orchestrator reading this thread should not treat the cap as exhausted.

Head 7453083 is the merge of 31b50b3 + 73b4618 produced by the conflict-resolution rung, and no review had seen it. Reviewed in a detached worktree at that head, with a pristine origin/master worktree alongside for the baseline. The code-review skill is not installed and codex is not on PATH, so this is a direct audit with no second-family reviewer.

Verdict: findings, all three fixed and pushed (0ca6948). The blocker that stopped triage is no longer reachable from any request shape codex-rs is documented to emit, established by execution, so I am recording it as a bounded-and-now-asserted residual rather than a blocker. The fixture port is legitimate, and most of it turns out to be master's work, not the resolver's.


1. The open blocker: is the subagent_signal leak still reachable? Executed.

I did not settle for reading. I drove the real projector, the real createUsagePolicyResolver and the real createRolloutCwdResolver over a real temp sessions tree holding the subagent-shaped rollout pair (root in /work/ignored/root, subagent in /work/clean/sub, so resolving the container drops and resolving nothing records, the exact leak direction), across 17 request shapes covering every surface that can state an identity, at this head and at origin/master for the differential.

The mechanism is still live in code. It fires for the lossy values.

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:

(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's usableInBandCwd + refused-workspace warn compose cleanly with the branch's resolveRolloutCwd fallback. The ?? still keeps the rollout lookup lazy, and resolveRecordedContext still receives the one resolved cwd. Net diff against master is the one call-site swap, the new resolveRolloutCwd, and subagent_signal. Executed (the in-band fast-path test asserts lookups === 0 and passes).
  • rollout-cwd.js: master's shared readRolloutSessionMeta composes with the branch's identity guard. The resolver's claim that meta.cwd now 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 a cwd that 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_context envelope, blank cwd, relative cwd) all pass at this head.
  • index.js / types.d.ts: log: ctx.log wiring and the RolloutCwdResolver.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 inline import('...') types. All @ref anchors 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.js passes.

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 thread pins the bound: the same header on a turn that also states its thread_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-id header and nothing else" occurs 0 times in the remote exchange-projector.js, and the replacement text is present at :261;
  • "no surface it carries states a thread_id" is present in the remote llp/0083...md:141;
  • DOCUMENTED MIRROR is 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>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

neutral review, round 4 of 4 (head 0ca6948)

This is round 4 of the 4 rounds the maintainer granted, the LAST one, and the grant is now EXHAUSTED. The CLI's 2-round cap was spent before round 3; the extra two came from the maintainer on this thread ("let's give PRs 456 and 462 2 more review rounds each", relayed from Slack, acknowledged at <!-- neutral-ack -->). Round 3 used the first, this is the second. No review rounds remain. The next rung is triage. A future orchestrator reading this thread should treat further review as ungranted unless the maintainer grants it again.

Reviewed in a detached worktree at 0ca6948 with a pristine origin/master worktree alongside, both carrying a symlinked node_modules (no lockfile, so npm ci is unavailable and an unlinked tree under-collects). The code-review skill is not installed and codex is not on PATH, so this is a direct audit with no second-family reviewer.

Verdict: findings. Three, all fixed and pushed as 4dcd246. The headline is that round 3's honesty caveat is now closed, and closing it turned up a real misattribution that three rounds had passed over.


1. The traffic-realism premise: ESTABLISHED. Executed.

Round 3's top-priority residual was that "the subagent_signal leak is unreachable" rested on LLP 0151's audit of codex-rs, which round 3 could not re-verify because the scratchpad snapshot was gone. I verified it directly. codex is not on PATH and nothing is vendored in-repo, but the host has network, so I read the emitting source at the three files LLP 0151 cites (openai/codex, codex-rs/, main; responses_metadata.rs last touched by commit 1def0a8, 2026-07-28, i.e. contemporaneous with the audit).

The premise holds, and is stronger than "documented":

  • CodexResponsesMetadata::client_metadata (responses_metadata.rs:219-253) inserts session_id and thread_id into one map literal, unconditionally, from two non-Option String struct fields (:161-162). No gate.
  • Every /responses request carries that map: client.rs:928, client_metadata: Some(responses_metadata.client_metadata()).
  • Call sites supply real typed ids (session/turn_context.rs:536, session/review.rs:91, via TurnMetadataState::new), so neither can be empty.
  • The turn-metadata blob gates session_id and thread_id on the same has_turn_identity boolean (turn_metadata_payload, :291-302), so it states both or neither.

So the shape that reaches the guard (a container named with no thread_id anywhere) is not producible by codex-rs. Round 3's reachability conclusion is confirmed by execution rather than inherited.

Marked honestly, because this is a claim about a program this repo does not build: it is a snapshot of upstream main, not a pinned release, and it is a reading of emitting code, not of captured traffic. LLP 0141 is right that no hermetic smoke can supply it. I did not capture real Codex traffic and do not claim to have.

2. What verifying it turned up: the safety was attributed to the wrong surface

has_turn_identity is false for exactly one request kind, CodexResponsesRequestKind::Memory. That kind still emits the lineage the refusal keys on (thread_source, parent_thread_id, and an x-openai-subagent: memory_consolidation header via detached_memory_responses_metadata) while its blob carries neither id. So memory consolidation is the closest real Codex shape to the guard's trigger, and what keeps it out is the flat body map alone (which is ungated), not the blob.

Both the JSDoc and LLP 0083 said or implied the blob is what answers such a turn. The conclusion they reach is right; the reason they give is wrong, and it is the reason that a future change would rely on. Executed: with the body-map thread_id read removed, this exact shape falls into the refusal and .hypignore fails open on it.

Findings

F1 (MEDIUM, fixed in 4dcd246) - LLP 0083 states the load-bearing invariant in a form that is false of one of the two surfaces.
llp/0083-codex-live-cwd-from-rollout.decision.md:143-146 (at 0ca6948): "the body map and the turn-metadata blob each state a thread_id whenever they state any identity at all". Executed counter-example: a Memory-kind blob states thread_source, parent_thread_id and subagent_kind (identity, and precisely the lineage this refusal keys on) while stating no thread_id. Replaced with the invariant that actually holds and actually does the work: on both surfaces the two ids are emitted as a pair, never one without the other, so no surface can supply the container the fallback needs while withholding the thread that pre-empts it. The source citation (functions, file, commit, date) is now recorded in the doc so the next reader re-checks it in one step instead of re-deriving it a fifth time, and the standing of the claim (upstream main snapshot, emitting code not captured traffic) is stated rather than implied.

F2 (MEDIUM, fixed in 4dcd246) - the same misattribution in the JSDoc that governs the branch.
hypaware-core/plugins-workspace/codex/src/exchange-projector.js:266 (at 0ca6948): "that blob states thread_id whenever it states any turn identity at all, so a turn stating them has already been answered by the branch above". The inference does not hold for the Memory kind: such a turn is answered by the body map, not the blob. Corrected, and the corrected text names the surface that actually earns the guarantee. Pinned by a new test rather than left as prose: test/plugins/codex-rollout-cwd.test.js:666 drives a memory-consolidation turn whose blob states lineage and neither id while the body map states both, and asserts USAGE_POLICY_DROP.

F3 (MEDIUM, fixed by editing the PR description) - the PR body still asserted the premise round 3 deleted from the code, and misdescribed the gate a human would merge on.
The body is the artifact a human reads at merge, and this PR goes to triage next, so its drift is not cosmetic. It still contained the exact sentence round 3 removed from exchange-projector.js as false ("the common subscription-route shape (a session-id header and nothing else)"), described fixtures via the three header names #467 deleted, reported 2861 tests, claimed a 15-test gate, said "Based on origin/master" when the branch now contains a merge, treated #458 as unlanded (it merged 2026-07-30 and its branch is deleted), and described the two-readers fold as an open follow-up when master has since done it (LLP 0150). Most consequentially, the body never mentioned x-openai-subagent or the value-blind guard at all, so a body reader would not learn that the documented residual which consumed rounds 2 to 4 exists. All corrected, with the original claims retained and marked as the state at the time of writing rather than silently rewritten.

Not fixed, deliberately. Per the brief I did not redesign the subagent_signal guard. I endorse round 3's recommendation for whoever owns the follow-up: retire the guard and the container fallback together, since they are entered by exactly the same shapes and, per section 1, that set is now known to be empty for real Codex traffic. That is a design call, not a review fix.

3. The delta 7453083..0ca6948, audited

  • The two new parameterized loops are not vacuous. Executed, by mutation. Deleting the subagent_signal refusal (exchange-projector.js:296) reddens 7 (all three DOCUMENTED MIRROR cases plus the four pre-existing refusal tests); deleting the thread-id key (:295, i.e. reverting the fix) reddens 8 (all three does NOT cost anything cases plus both Subscription-routed Codex records cwd=NULL: .hypignore fails open for a whole traffic class (live projector should enrich from rollouts) #257 tests, both leak-direction tests and the legacy pair). Round 3's mutation claims reproduce exactly. My new test reddens under its own targeted mutation (dropping the body-map thread_id read: 9 red).
  • Round 3's three doc fixes landed and are correct as far as they go; F1 and F2 above sharpen the one sentence in each that over-claimed.
  • House style at the new head: 0 em dashes in all changed files, no semicolons in added JS, JSDoc types only, no @typedef, no inline import('...') types. test/core/llp-ref-hygiene.test.js passes 9/9, so every @ref anchor including the new LLP 0083#container-fallback-gap [tests] resolves.

Verification that the fixes landed

Verified against the committed remote tree at 4dcd2467976dec4225cf095c1acdea4af5093097 by reading blobs back out of origin/fix/issue-459 after the push, not by a green suite:

  • the false sentence "each state a thread_id whenever they state" occurs 0 times in the remote llp/0083...md; the replacement ("emitted as a pair, never one without the other", the 1def0a8 citation, the CodexResponsesRequestKind::Memory paragraph) is present at :148, :151, :162;
  • the old JSDoc sentence occurs 0 times in the remote exchange-projector.js; the replacement is present at :266 and :269;
  • the new test is present in the remote test blob at :666;
  • em dash count is 0 in all three remote blobs.
  • F3 verified against the live PR body via the API after the edit: all four stale strings return 0 occurrences, the six corrected claims return 1 each, 0 em dashes.

Checks

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.

@philcunliffe

philcunliffe commented Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

neutral triage (LLP 0017), head 4dcd246

Verdict: every residual is non-blocking. This PR can merge safely.

The maintainer's grant of 2 extra review rounds (on top of the CLI's 2-round cap) is now exhausted after round 4 (0ca6948 -> 4dcd246). This is the triage rung, the last gate before hold-or-park.

What I independently re-verified at this head, by execution

  • npm test in a fresh worktree with a symlinked node_modules: 3055 tests, 8 failures. Confirmed byte-identical against a pristine origin/master worktree I ran myself (3037 tests, 8 failures, same 8 leave-command.test.js titles, diff exit 0). 0 new failures.
  • npm run typecheck: clean. test/plugins/codex-rollout-cwd.test.js: 30/30.
  • Independently mutated the guard at exchange-projector.js:301 (dropped the subagent_signal disjunct): reddens the DOCUMENTED MIRROR tests and the lineage-only tests, confirming the guard is live code that matters when it fires, not dead code.
  • Independently fetched openai/codex main over the network (not inherited from the thread) and read codex-rs/core/src/responses_metadata.rs and client.rs myself, landing on the same commit round 4 cited (1def0a8, 2026-07-28). Confirmed directly: CodexResponsesMetadata::client_metadata() unconditionally inserts session_id and thread_id from non-Option String fields into every /responses request's body map (responses_metadata.rs:219-253, client.rs:928); has_turn_identity is false only for the Memory kind (:137-138), matching round 4's misattribution finding; and SessionId/ThreadId are UUID-backed newtypes that can never serialize empty (session_id.rs, thread_id.rs), so the ids inserted are never blank.

The residual that decided it: the value-blind subagent_signal refusal

Judged plainly: non-blocking. The shape that fires it (a turn naming a container while withholding a thread id on every surface) is not producible by any code path in the codex-rs source read - not merely unlikely, but structurally excluded by unconditional, non-Option struct fields with no branch that could omit them. That is stronger evidence than "unreachable in practice" prose, though it is still, honestly, a reading of upstream main emitting code rather than a pinned-release capture of real traffic (LLP 0141 - no hermetic check in this repo can supply that). Weighed against blocking: parking this PR leaves the original, always-reachable #459 leak in production indefinitely, which is a strictly worse and currently-shipping privacy defect, to guard against a narrower, currently-unconstructable one. Shipping is the strict privacy improvement.

Round 4's recommendation to retire the guard and the container fallback together (they are now entered by exactly the same unobserved shape) is the right shape for the follow-up rather than narrowing the guard by value - filed below, not a precondition for merge.

Action taken

Opened #498 (neutral:fix), enumerating the value-blind guard's evidentiary status and round 4's retire-both-together recommendation, plus one minor round-1 nit (N2, the negative-cache re-warn cadence) never separately filed. Round 1's other two deferred findings (F2, F6) are already resolved: filed as #464/#465, merged as #467/#466 respectively - not carried forward.

No gh pr merge / gh pr ready. PR body updated next, appending the triage marker only, per LLP 0017.

@philcunliffe philcunliffe added the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Jul 31, 2026
@philcunliffe
philcunliffe marked this pull request as ready for review July 31, 2026 00:33
@philcunliffe
philcunliffe merged commit 8792388 into master Jul 31, 2026
9 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-459 branch July 31, 2026 02:14
philcunliffe pushed a commit that referenced this pull request Jul 31, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030)

Projects

None yet

1 participant