Skip to content

Follow-up: deferred review findings from PR #462 #498

Description

@philcunliffe

What

Deferred findings from four review rounds on PR #462 (fix/issue-459 - "Codex rollout cwd keys on the thread, not the session container"), classified at triage as non-blocking. All are backlinked here rather than gating merge; PR #462 fixes a real, always-reachable privacy leak (#459) and should not be held open for these.

1. The value-blind subagent_signal refusal is a documented, evidence-bounded residual - its own statement

hypaware-core/plugins-workspace/codex/src/exchange-projector.js:301 (at head 4dcd246):

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. That header's real values (per codex-rs) are review, compact, collab_spawn, memory_consolidation. Three of the four are same-workspace sub-threads of the root session, where the root's cwd is the correct answer. When such a turn states a container and no thread id, this guard refuses to resolve a cwd; .hypignore then fails open (LLP 0049) and the turn is recorded rather than dropped - the same leak direction issue #459 exists to close, reintroduced by the guard meant to bound the container fallback that fixes it.

Why it shipped anyway - the reachability chain, and its evidentiary status:

  • Round 2 found the mechanism live in code but declined to redesign it from the reviewer's seat.
  • Round 3 argued, by reasoning from this repo's own codex-rs audit (LLP 0151), that no realistic Codex request shape reaches the guard: client_metadata on the request body carries session_id and thread_id together on every request, and the turn-metadata blob gates both on the same has_turn_identity flag - so nothing can state a container while withholding the thread that pre-empts the guard. Round 3 flagged honestly that this half was reasoned, not executed: the codex-rs snapshot a prior worker had left in the shared scratchpad was gone.
  • Round 4 closed that gap by reading openai/codex main over the network directly: CodexResponsesMetadata::client_metadata() (codex-rs/core/src/responses_metadata.rs:219-253) inserts session_id and thread_id into one map literal, unconditionally, from two non-Option String struct fields, and every /responses request carries that map (client.rs:928). Round 4 bounded the claim precisely: this is upstream main, not the pinned release this repo depends on, and it is a reading of emitting code, not captured traffic - LLP 0141 is explicit that no hermetic smoke in this repo can supply real Codex traffic.
  • Round 4 also found a genuine misattribution three rounds had missed: has_turn_identity is false for exactly one request kind, Memory (memory consolidation), which still emits the lineage the refusal keys on (thread_source, parent_thread_id, the x-openai-subagent: memory_consolidation header) while its turn-metadata blob carries neither id. What keeps that real Codex shape out of the guard is the flat body map alone, not the blob - the JSDoc and LLP 0083 had credited the blob. Fixed and pinned by a test (test/plugins/codex-rollout-cwd.test.js:666).
  • Triage independently re-verified round 4's claim by execution, not by inheriting it: fetched codex-rs/core/src/responses_metadata.rs and client.rs from openai/codex at main (commit 1def0a8, 2026-07-28 - the same commit and date round 4 cited), and confirmed directly: client_metadata() unconditionally inserts both ids (responses_metadata.rs:219-253); every /responses request carries it (client.rs:928); has_turn_identity is false only for Memory (responses_metadata.rs:137-138); SessionId/ThreadId are UUID-backed newtypes (session_id.rs, thread_id.rs) that can never serialize to an empty string, so the ids inserted are never blank. Triage also independently mutated the guard (removed the subagent_signal disjunct) and confirmed it reddens the DOCUMENTED MIRROR tests plus the two lineage-only tests - the mechanism is live code, not dead, and matters when it fires.

Net: the guard is real, live, value-blind, and fail-open when it fires - but per every read of the emitting source available (upstream main, not the pinned release, and not captured traffic), no Codex client can currently construct the input shape that fires it. That is a materially stronger position than "unreachable in practice" prose; it rests on unconditional, non-Option struct fields with no branch that could omit them. It is not proof against a future upstream change or an undiscovered client that behaves differently from documented codex-rs.

Round 4's recommendation, carried forward

The guard and the container fallback it bounds are now entered by exactly the same (currently unobserved) shape: a turn naming a container and no thread id anywhere. Round 3 and round 4 both recommend, as the coherent eventual fix, retiring the guard and the container fallback together rather than narrowing the guard by value - since neither protects real traffic any more, per the same audit. This is a design call for whoever picks it up, not a mechanical fix:

  • Retiring the fallback alone (without also removing the guard) 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), so the two changes are coupled.
  • The durable alternative floated across rounds 1-3 - reading client_metadata.thread_id from the request body as the rollout lookup key, which the code already reads for the thread-id-present path - would make both the fallback and the guard's fail-open scenario unreachable by construction rather than by argument, and is likely the simplest version of "retire together."
  • Whoever files the follow-up PR should re-verify the codex-rs audit against a pinned release version if one is vendored/pinned by then, not just main, since that is the gap every round from 3 onward has flagged honestly and left open.

2. Minor nit carried forward from round 1 (N2, not filed at the time)

rollout-cwd.js's identity-mismatch negative cache: 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 and consistent with the existing negative-cache design, and a should-never-happen in practice - round 1 judged it worth noting so it is not a surprise in a log, not worth blocking on. No action required unless it shows up as log noise in practice.

Already resolved, not carried forward

Round 1's F2 (fictional header names / client_metadata.thread_id unread) and F6 (two-readers duplication between rollout-cwd.js and session_command.js) were filed as #464 and #465 respectively and have both since been merged (#467 and #466). Not residual.


Backlink: #462

Metadata

Metadata

Assignees

No one assigned

    Labels

    neutral:fixDelegate this issue to neutral for an autonomous fix attempt (reproduce -> fix -> PR)neutral:stuckneutral attempted this but cannot complete it autonomously — needs a human

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions