Skip to content

Codex lineage reads the durable body client_metadata, not header names Codex never sends (#464) - #467

Merged
philcunliffe merged 5 commits into
masterfrom
fix/issue-464
Jul 30, 2026
Merged

Codex lineage reads the durable body client_metadata, not header names Codex never sends (#464)#467
philcunliffe merged 5 commits into
masterfrom
fix/issue-464

Conversation

@philcunliffe

@philcunliffe philcunliffe commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Root cause

resolveCodexContext derived a turn's identity and lineage from
x-codex-turn-metadata plus three bare header names. Read against Codex's own
source (codex-rs/core/src/responses_metadata.rs,
codex-rs/core/src/client.rs:929, codex-rs/codex-api/src/common.rs), Codex
projects one CodexResponsesMetadata snapshot onto three surfaces per HTTP
request:

surface carries when
body client_metadata (flat map, top-level field of ResponsesApiRequest) x-codex-installation-id, session_id, thread_id, x-codex-window-id always; turn_id, x-codex-parent-thread-id, x-openai-subagent when set; and the whole turn-metadata blob every request
x-codex-turn-metadata (header + same-named body entry) thread_source, sandbox, workspaces, parent_thread_id, ... only for request kinds that carry turn metadata; its session_id/thread_id are omitted for kinds with no turn identity
compatibility_headers exactly x-codex-window-id, x-codex-turn-metadata, x-codex-parent-thread-id, x-openai-subagent as above

Two defects followed:

  1. The always-present surface went unread. HypAware never looked at the
    request body's client_metadata, so identity depended on a surface Codex may
    legitimately omit.
  2. Three of the header names read were fictional. thread-id, session-id
    and parent-thread-id are names no Codex version emits. They could never
    supply a right value and could supply a wrong one: any hop or hand-rolled
    client that set thread-id dictated conversation_id, the value the row's
    fallback message_id / part_id is scoped on (LLP 0030).
    parent-thread-id was simply the wrong spelling of the real
    x-codex-parent-thread-id, so header-route subagent lineage never resolved.

The fix

  • Lineage (thread_id, session_id, turn_id, parent thread) resolves
    body-map-first, turn-metadata-blob-second, with the real
    x-codex-parent-thread-id header last.
  • A Codex-owned client_metadata map is itself sufficient evidence that an
    exchange is Codex, so the API-key route's generic /v1/responses no longer
    needs a Codex-namespaced header to be recognized. The map is only trusted when
    it carries an x-codex-* key or both session_id and thread_id, so an
    unrelated client's client_metadata cannot masquerade as Codex lineage.
  • The three fictional names are removed; the real ones are named constants with
    the audit written next to them.
  • attributes.codex.lineage_source (body_client_metadata | turn_metadata)
    records which surface stated the identity, so a Codex version that stops
    filling one is a queryable shift rather than a silent conversation_id drift.
  • x-openai-subagent is real and deliberately still unread: adopting it would
    change what is_sidechain means (its values are Codex's subagent kinds),
    which is a separate decision. LLP 0143 says so explicitly.

Precedence, and why

The body map wins because it is the only surface present for every request
kind. It cannot conflict with the blob in real traffic: both are projections of
the same snapshot fields, so when both are present they are equal. The blob
itself is still read header-first, body-entry-second, because that is what
keeps already-recorded rows bit-identical.

Row identity

Nothing re-keys. For every shape HypAware already resolved an identity from, the
new precedence returns the same string (same snapshot field), and the removed
header names never matched real Codex traffic. conversation_id therefore does
not move, so neither do the message_id / part_id scoped on it.
Already-recorded rows are left alone, no backfill; LLP 0143#row-identity
states why (the newly-identified rows were content-hash keyed and never joinable
to a thread; a backfill would re-key rows the partition spec clusters on for no
query anyone can express today, and hyp backfill codex already re-imports that
history from the rollout tree).

Reproducing tests

test/plugins/codex-exchange-projector.test.js, all pinning LLP 0143 anchors:

  • Codex lineage resolves from the durable body client_metadata when no lineage header is sent - a /backend-api/codex/responses turn with request_headers: {} and body client_metadata; asserts conversation_id, session_id,
    parent_thread_id, prompt_id, attributes.codex.*. Failed before
    (conversation_id was a content hash, attributes.codex.thread_id undefined).
  • body client_metadata alone identifies a Codex exchange on a generic responses path - the API-key route with no Codex header at all. Failed before
    (client_name undefined: the exchange was not recognized as Codex).
  • Codex lineage resolves from the compatibility headers Codex actually sends -
    the four real header names. Failed before on parent_thread_id (misspelled
    name).
  • a bare lineage header name Codex never sends resolves to nothing, not a wrong value - thread-id: phantom-thread must NOT become the row identity. Failed
    before: conversation_id === 'phantom-thread'.
  • body client_metadata wins over the turn-metadata blob when the two disagree -
    pins the tie-break. Failed before.
  • part_id and message_id stay byte-identical for the turn-metadata shape already recorded - literals captured from the pre-change projector
    (e1a2ff876074693f#0, 179fd16763044acd#0). Passed before and after: this
    is the row-identity guard, not a reproducer.

Two test/plugins/codex-rollout-cwd.test.js fixtures rested on the same
disproved premise (a session-id header as the subscription route's identity)
and were moved to the shape Codex really sends. That regression (#257: no in-band
cwd falls back to the rollout session_meta.cwd) is unchanged, and the fix
actually makes it reachable for real traffic, since the fallback is keyed on the
Codex session id, which previously had to arrive in a header Codex never sends.

Docs

  • New LLP 0143 (Decision): the three surfaces, the precedence, the full
    header audit, lineage_source, and the no-backfill row-identity decision.
  • LLP 0083 and LLP 0141 carried the disproved premise that
    x-codex-turn-metadata is Codex Desktop behavior, and LLP 0083 additionally
    named a session-id header as the subscription route's session source. Both
    corrected in place with a pointer to LLP 0143. (PR Codex rollout cwd keys on the thread, not the session container (#459) #462's review reached the
    same conclusion for the doc text; that PR is still open, so master still had
    the false premise.)
  • gateway_codex_capture smoke fixture now sends the body client_metadata and
    no phantom headers, so the hermetic smoke is path-faithful.

Verification

  • npm test: 2885 tests, 2876 pass, 8 fail - all 8 are the pre-existing
    test/core/leave-command.test.js failures, confirmed failing identically on a
    clean origin/master checkout (3 pass / 8 fail there too). Nothing else fails.
  • npx tsc -p tsconfig.json --noEmit: clean.
  • npm run smoke -- gateway_codex_capture: ok. npm run smoke -- core_boot_noop: ok.

Fixes #464

neutral-reconciler and others added 2 commits July 29, 2026 23:57
The Codex live projector derived a turn's thread, session and parent thread
from request headers, three of whose names Codex has never emitted, while the
authoritative ids sat unread in the request body.

Read against Codex's source, Codex projects one `CodexResponsesMetadata`
snapshot onto three surfaces per HTTP request. The flat body `client_metadata`
map (a top-level field of `ResponsesApiRequest`) is built unconditionally and
always carries `session_id` and `thread_id`; the `x-codex-turn-metadata` blob
only rides along for request kinds that carry turn metadata; and
`compatibility_headers` emits exactly four names. `thread-id`, `session-id`
and `parent-thread-id`, the three bare names the projector read, are not among
them, so they could never supply a right value and could supply a wrong one:
any hop setting `thread-id` dictated `conversation_id`, the scope of the row's
fallback `message_id`.

- Lineage now resolves body-map-first, turn-metadata-blob-second, with the real
  `x-codex-parent-thread-id` header (previously misspelled) last.
- A Codex-owned `client_metadata` map is itself a sufficient signal that an
  exchange is Codex, so the API-key route's generic `/v1/responses` no longer
  needs a Codex header to be recognized.
- The three fictional header names are gone; the real ones are named constants.
- `attributes.codex.lineage_source` records which surface stated the identity,
  so a future Codex version dropping one is queryable rather than a silent
  `conversation_id` drift.

Nothing re-keys: the blob's `thread_id` and the body map's `thread_id` are the
same field of the same snapshot, and the removed header names never matched
real traffic, so `conversation_id` (and the `message_id` / `part_id` scoped on
it, LLP 0030) is unchanged for every shape already recorded. Already-recorded
rows are left alone; LLP 0143 states why no backfill.

LLP 0143 is the decision doc. LLP 0083 and LLP 0141 carried the disproved
premise that `x-codex-turn-metadata` is Codex Desktop behavior and that the
subscription route states its session in a `session-id` header; both are
corrected, and the two `codex-rollout-cwd` fixtures that rested on that header
now use the shape Codex really sends.

Co-Authored-By: Claude <noreply@anthropic.com>
PR #466 (fix/issue-465) independently minted llp/0143-one-reader-for-codex-session-meta
on its own branch at the same time this branch minted
llp/0143-codex-lineage-from-body-client-metadata. Two documents cannot share a
number, and duplicate LLP numbers are exactly the corpus defect issue #463
tracks, so this branch takes 0144 (deterministic tie-break: the lower PR number
keeps the original).

Purely a renumber: every @ref anchor, cross-link, and Related entry follows the
move, and the document's own content is unchanged. Verified no residual "0143"
reference remains on this branch and all six referenced anchors still resolve in
the renamed document.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

🤖 neutral: renumbered LLP 0143 to 0144 (duplicate number cleared)

This branch and PR #466 (fix/issue-465) were implemented in parallel on the same tick and each independently minted an llp/0143-* document:

Neither branch could see the other, so neither was wrong, but two documents cannot share a number, and duplicate LLP numbers are precisely the corpus defect issue #463 tracks. Rather than let both merge and create the problem on master, this branch takes 0144. Tie-break was deterministic, not a judgement about the documents: the lower PR number keeps the original.

Purely a renumber. The document's content is untouched. The rename carried every reference with it: 8 @ref annotations in exchange-projector.js, 1 in the gateway_codex_capture smoke, 7 in codex-exchange-projector.test.js, 1 in codex-rollout-cwd.test.js, and the cross-links plus Related: entries in LLP 0083 and LLP 0141.

Verified after the rewrite: no residual 0143 reference anywhere on this branch, and all six referenced anchors (#real-header-names, #body-is-authority, #lineage-source, #body-is-a-codex-signal, #row-identity, #context) still resolve in the renamed document. No em dashes added.

Note for whoever reviews: this branch still overlaps #462 on the corrected x-codex-turn-metadata doc text, as the PR body flags. That is a content overlap, separate from this renumber, and will surface as a normal merge conflict on whichever PR lands second.

…eferring one

The body-map-first precedence rests on Codex projecting one metadata snapshot
onto both the flat `client_metadata` map and the `x-codex-turn-metadata` blob,
so that the two are equal whenever both are present. That is a claim about
another program's internals which HypAware cannot verify, and the body-wins
tie-break discarded the counter-evidence without trace: a row whose surfaces
disagreed was indistinguishable from a row whose surfaces agreed.

- `attributes.codex.lineage_conflict` now names the lineage fields the two
  surfaces state differently (`thread_id`, `session_id`, `turn_id`,
  `parent_thread_id`), absent when they agree or only one spoke. The row still
  keys on the body, so this adds a signal and moves no identity.
- `lineage_source` now resolves in the same order as the values it describes
  (`thread_id` before `session_id`, body before blob). It previously answered
  "did the body state anything at all", which mislabelled a turn whose
  `thread_id` came from the blob while only its `session_id` came from the body
  as `body_client_metadata`, though `conversation_id` keys on `thread_id`.
- LLP 0144 gains `#lineage-conflict` and states why the assumption gets a
  continuously checked signal rather than a one-time assertion.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 1 - verdict: findings (2 fixed, 2 left)

Reviewed at head 8a48414. Fixes pushed as 781a0ec. CI was green at the reviewed head; npm test at 781a0ec is 2888 tests / 2879 pass / 8 fail, all 8 the pre-existing test/core/leave-command.test.js failures. npx tsc -p tsconfig.json --noEmit clean. npm run smoke -- gateway_codex_capture ok.

The substance of this PR holds up under adversarial checking. Every load-bearing claim in the description was independently verified rather than taken on trust, and the two that matter most (row identity, byte-identity test non-vacuity) are confirmed by running the old projector, not by reading the diff.


Verified claims

Row identity does not move, and the byte-identity test is NOT vacuous.
I restored the pre-change projector (c551d6e) under the new test file and ran the guard test alone: it passes against the old projector. The literals e1a2ff876074693f / 179fd16763044acd in test/plugins/codex-exchange-projector.test.js:1310-1313 were therefore genuinely captured pre-change and were not regenerated from the new code. Against the same old projector the five reproducer tests fail (38 pass / 5 fail) while the guard passes, which is exactly the split the description claims.

I also ran both projectors over a nine-shape battery and deep-compared the whole projected row, not just the ids. Result:

shape old vs new
header turn-metadata blob (the already-recorded shape) identical but for additive attributes.codex.lineage_source
subagent lineage via blob parent_thread_id identical but for additive lineage_source
no Codex metadata (content-hash fallback) byte-identical
non-Codex /v1/chat/completions byte-identical
reqBody.metadata.session_id fallback byte-identical
originator / user-agent only byte-identical
phantom thread-id header conversation_id phantom to content hash (the intended fix)
body client_metadata only content hash to t8 (new coverage)
blob carried in the body map content hash to t9, permission_mode now resolves (new coverage)

No first-class row field silently changed for any shape HypAware already resolved. The only change to an already-recorded shape is one added attribute, and attributes provably do not feed the identity hash: the guard test yields the same message_id before and after, though lineage_source is present only after.

Privacy: clean. client_metadata is only ever reached through readStringKey for six lineage keys (thread_id, session_id, turn_id, x-codex-parent-thread-id, x-codex-window-id, x-codex-turn-metadata). The map is never spread, mirrored, or serialized into a row; x-codex-installation-id is touched only as a key name in the presence test, never its value. attributes.codex.* gains exactly one new entry, an enum literal. No logging of body content anywhere in the projector: the one ctx.log.info call (exchange-projector.js:151) predates this PR and emits only session_id and exchange_id.

Renumber is complete and honest. Zero occurrences of LLP 0143 anywhere on the branch (full-tree grep, code and docs). llp/0144-codex-lineage-from-body-client-metadata.decision.md exists; no stray llp/0143-*. All six anchors referenced from code (body-is-authority, body-is-a-codex-signal, real-header-names, lineage-source, row-identity, plus the one I added) resolve to real <a id=...> targets, and all in-document (#...) links resolve. LLP 0083 (:8, :36, :68) and LLP 0141 (:8, :29) both cross-link to 0144.

Fixture fallout: the #257 regression is not weakened. Both reshaped codex-rollout-cwd tests keep their original assertions verbatim (assert.equal(projection, USAGE_POLICY_DROP) and assert.equal(projection.cwd, '/work/clean/proj')), the same fakeRolloutCwd keyed on the same SUBSCRIPTION_SESSION_ID, and the same precondition (a stated session id, no in-band cwd). Only the transport of the session id changed. Both fail against the old projector, confirming they now exercise a real path rather than a phantom header. The third test's removal of the session-id header is inert: it still asserts lookups === 0 via the in-band workspaces short-circuit. The smoke fixture is strictly richer than before, sending body client_metadata and the four real headers.

CLAUDE.md compliance: clean. No em dash on any added line in the PR or in my fix. No semicolons. No @typedef, no inline import() types, no new type-import specifiers. JSDoc types only.


Findings

1. Medium - a body/blob disagreement was resolved silently, with nothing recorded. FIXED.
hypaware-core/plugins-workspace/codex/src/exchange-projector.js:714 and :836-841 (at 8a48414).

This was the review's main target. The precedence rests on a claim about another program's internals: that Codex projects one snapshot onto both surfaces, so they are equal whenever both are present. HypAware cannot verify that. The failure mode was silent preference with no detectable conflict: the body won and the blob's contradicting value was discarded without trace, and lineage_source did not help, because it reported body_client_metadata for a disagreeing turn exactly as it would for an agreeing one. The two cases were indistinguishable in the recorded data, so the assumption could break in the field and produce drifted conversation_id values with nothing to query for.

Fixed additively: attributes.codex.lineage_conflict now names the lineage fields the two surfaces state differently (thread_id, session_id, turn_id, parent_thread_id), and is absent when they agree or when only one surface spoke. The row still keys on the body, so the documented tie-break is unchanged and no identity moves - re-running the battery confirms no agreeing shape gains the attribute. This converts the assumption from a one-time assertion in a design doc into something checked on every request: a nonzero lineage_conflict count is now the evidence that would retire it. LLP 0144 gains a #lineage-conflict anchor stating this.

2. Low - lineage_source could name the wrong surface. FIXED.
exchange-projector.js:836-841 (at 8a48414).

lineageSource answered "did the body state anything at all" (clientMetadata.thread_id ?? clientMetadata.session_id), not "where did the identity come from". For a turn whose body map states only session_id while the blob states thread_id, it returned body_client_metadata, but conversation_id keys on thread_id, which came from the blob. The attribute's stated purpose (exchange-projector.js:711 "Which surface stated the identity this row is keyed on") was then false in precisely the mixed case that a partial Codex version drift would produce, which is the case the signal exists to catch. It now walks thread_id before session_id and body before blob, mirroring how the values themselves resolve. Behaviour is unchanged for every shape either test suite already covered.

3. Low - NOT fixed, needs a human: the flat-pair trust branch re-opens a narrow version of the hole this PR closes.
exchange-projector.js:803-805; llp/0144-...decision.md:66.

readCodexClientMetadata accepts a client_metadata map on either of two signals: an x-codex-* prefixed key, or the flat session_id + thread_id pair. The first is genuinely Codex-exclusive. The second is not. A non-Codex client that posts client_metadata: { session_id, thread_id } to /v1/responses is now labelled client_name: 'codex' and dictates conversation_id, which is the same shape of defect the PR body indicts (any hop or hand-rolled client that set thread-id dictated conversation_id), just requiring a body field instead of a header.

I did not change it. It is an explicit recorded decision at LLP 0144#body-is-a-codex-signal, narrowing it to the x-codex-* branch alone would contradict that decision and would break the tie-break test's fixture, and the exposure is genuinely much smaller than what was removed (the gateway only sees traffic the user routes through it, and the consequence is a mislabelled client plus an adopted id, not content leakage). Net posture is clearly better than before this PR. Flagging for a human to decide whether the flat-pair branch earns its keep, given that x-codex-installation-id is present on every request Codex makes and so the strict branch alone may already cover all real traffic.

4. Informational - NOT fixed: matches and isCodexExchange now disagree about whether the body is a signal.
exchange-projector.js:72 versus :778.

isCodexExchange treats a Codex-owned client_metadata as sufficient evidence, but the outer matches gate (:64-73) still keys only on paths and the turn-metadata header. A Codex request on a path outside the matched set with no Codex header would be rejected before the body was ever consulted. Not reachable today: isOpenAiResponsesPath (:248-254) matches /v1/responses, /responses, and any */responses suffix, which covers both real routes. Left alone as speculative hardening rather than widening the match surface on a hypothetical.


Notes, out of scope as briefed

  • x-openai-subagent left deliberately unread, recorded at LLP 0144#real-header-names. Correct call: its values are Codex subagent kinds, so adopting it would redefine is_sidechain. Not treated as a finding.
  • The x-codex-turn-metadata doc-text correction overlaps PR Codex rollout cwd keys on the thread, not the session container (#459) #462. Conflict expected there; not addressed.
  • window_id resolves header-first while lineage resolves body-first. Consistent with the row-stability rationale (window_id was previously header-only), so not a finding.

… of Codex

`readCodexClientMetadata` accepted a body `client_metadata` map on either of two
signals: an `x-codex-*` prefixed key, or the flat `session_id` + `thread_id`
pair. Only the first is Codex-exclusive. The second is a shape any agent
framework may send, and the projector's matched path set includes the fully
generic `/v1/responses` and `/v1/chat/completions`, so an unrelated client that
posted that pair was stamped `client_name: 'codex'` and dictated the row's
`conversation_id` and `session_id` (the partition key, LLP 0030). That is the
same defect class as the fictional `thread-id` header this branch removed,
reached through the body instead of a header, and in a capture product a misfiled
client is a privacy question.

The flat pair is now honoured only when the transport already identified the
exchange as Codex independently of the body (`hasCodexTransportSignal`: the
`chatgpt` upstream, the `/backend-api/codex/` namespace, an `x-codex-*`
compatibility header, or a `codex`-prefixed user-agent product). Real Codex loses
nothing: `client_metadata` carries `x-codex-installation-id` and
`x-codex-window-id` on every request, so the strict branch alone covers all known
Codex traffic, and the corroborated pair still covers a build that stopped
writing them. A non-Codex client's row now comes out byte-identical to the same
request with no `client_metadata` at all.

`isCodexExchange` is replaced by `hasCodexTransportSignal` plus the body check at
the single decision point in `resolveCodexContext`, so the corroboration flag
cannot drift between the two callers.

Also pins the assumption that keeps the outer `match` gate (path and
turn-metadata header only) consistent with the body being a Codex signal: a test
asserts every route Codex posts to passes the gate, so a body-only Codex request
is never dropped before the body is read.

LLP 0144#body-is-a-codex-signal and #body-is-authority are amended in the same
commit.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe

Copy link
Copy Markdown
Contributor Author

Review round 2 of 2 - verdict: findings (1 fixed, 1 informational left, 1 correction to round 1)

This is round 2 of 2, the final review round. After this the PR routes to triage, where residual items are classified rather than fixed, so the round-1 finding that needed pressing was pressed to a fix rather than re-flagged.

Reviewed at head 781a0ec. Fix pushed as fed35da. Checks at fed35da: npm test 2891 tests / 2882 pass / 8 fail, all 8 the pre-existing test/core/leave-command.test.js set (same 8 by name as at 781a0ec); npx tsc -p tsconfig.json --noEmit clean; npm run smoke -- gateway_codex_capture ok. A full local run before and after my change has an identical failure set compared by test name, so nothing regressed.


Round-1 finding 3 (the flat-pair trust branch): REACHABLE, and now FIXED

hypaware-core/plugins-workspace/codex/src/exchange-projector.js:803-805 at 781a0ec. Severity: Medium. Round 1 left this for a human. It should not have been left, so I established reachability by execution and narrowed it.

Reachability verdict: reachable, and wider than round 1 described. I ran the projector at 781a0ec against a non-Codex client. match() admits the request via isOpenAiResponsesPath and via isOpenAiChatPath, so both of the fully generic OpenAI-compatible routes are in scope, not only /v1/responses:

request at 781a0ec match client_name conversation_id session_id
/v1/responses, user-agent: some-agent-framework/2.1, body client_metadata: {session_id, thread_id} true codex evil-thread evil-session
/v1/chat/completions, user-agent: aider/0.60, same body map true codex evil-thread evil-session
same, no client_metadata (control) true undefined content hash content hash

So an unrelated client needed only two ordinary body keys to be filed as Codex and to dictate conversation_id and session_id, which is the partition key (LLP 0030). Nothing about the request was Codex: no route, no header, no user-agent. This is the same defect class the PR body indicts, reached through the body instead of a header, and in a product that captures LLM traffic a misfiled client is a privacy question rather than a labelling nit.

Fix (fed35da). readCodexClientMetadata takes a corroborated flag. An x-codex-* prefixed key is still accepted on its own (it is genuinely Codex-exclusive). The flat session_id + thread_id pair is honoured only when the transport already identified the exchange as Codex independently of the body, factored out as hasCodexTransportSignal: the chatgpt upstream, the /backend-api/codex/ namespace, an x-codex-turn-metadata or x-codex-window-id header, or a codex-prefixed user-agent product. isCodexExchange is gone; the transport signal and the body check now meet at one decision point in resolveCodexContext, so the flag cannot drift between two callers. The transport signal set is exactly the one isCodexExchange already used, so nothing was widened to buy this.

Why the PR's fix stays intact. LLP 0144's own source table says client_metadata carries x-codex-installation-id and x-codex-window-id on every Codex request, so the strict branch alone already covers all known Codex traffic, including the API-key route's header-free /v1/responses that is the whole point of the change. The corroborated flat pair remains as the fallback for a future build that stopped writing those keys, which is precisely the drift this document guards against, so it was narrowed rather than deleted. Round 1 reported that narrowing "would break the tie-break test's fixture": that is true of deleting the branch, not of gating it. Every pre-existing test still passes untouched (46/46 projector, 9/9 rollout-cwd), because each flat-only fixture is on /backend-api/codex/responses with provider: chatgpt, or carries x-codex-installation-id anyway.

Verification.

  • git diff 781a0ec5..HEAD -- hypaware-core/plugins-workspace/codex/src/exchange-projector.js shows exactly the named change (isCodexExchange to hasCodexTransportSignal, readCodexClientMetadata(reqBody, corroborated), hasCodexKey short-circuit before the corroboration gate).
  • New test a non-Codex client sending only a flat client_metadata identity pair is not treated as Codex fails against the pre-fix projector (verified by stashing only the projector file: 48 pass / 1 fail) and passes after. It covers both generic routes and asserts the strongest form: the row is identical to the same request with no client_metadata at all, so the ambiguous map contributes nothing.
  • Re-running the probe after the fix: both non-Codex shapes now yield client_name: undefined and the same content-hash identity as the control, while real Codex is untouched (x-codex-* keyed map resolves; a Codex user-agent with a flat-only map still resolves).
  • Second new test a transport-corroborated Codex request still resolves lineage from a flat-only client_metadata guards the other direction, against over-narrowing into deletion. It passes both before and after by construction; its job is to fail if someone later drops the branch.
  • LLP 0144 amended in the same commit: #body-is-a-codex-signal gains the "the Codex-owned key is what makes the map evidence, the flat identity pair is not" paragraphs with the reasoning and the corroboration set, #body-is-authority no longer claims the flat pair is a Codex-owned key, and the References list names hasCodexTransportSignal. All six @ref anchors still resolve; all in-document (#...) links resolve.

Round-1 finding 4 (matches versus the body signal): unreachability CONFIRMED, and now pinned

exchange-projector.js:72 versus the body check in resolveCodexContext. Severity: Informational. Not a defect today. I verified round 1's claim rather than taking it.

Reachability verdict: unreachable. For the inconsistency to bite, a Codex request would have to arrive on a path outside match()'s set while carrying its Codex evidence only in the body. The complete route set anywhere in this repo is /backend-api/codex/responses, /backend-api/codex/models, /v1/responses, /v1/chat/completions and /v1/models. isCodexNamespacePath covers the first two; isOpenAiResponsesPath covers /v1/responses, /responses, any */responses suffix, and /v1/models; isOpenAiChatPath covers the chat route. Every route Codex posts to is matched, so the body is always reached. The two functions are only accidentally consistent, though, which is the latent-bug shape the brief asks about.

Rather than widen match() to read the body (behaviour change on arbitrary paths for no request Codex is known to make), fed35da pins the covering assumption: a comment at match() names the coupling and says to widen the path set rather than read the body, and a test, every route Codex posts to is matched, so a body-only Codex request is never dropped at the gate, asserts both real routes pass the gate and resolve a Codex context from the body alone. A future narrowing of isOpenAiResponsesPath now fails a test instead of silently dropping the API-key route. This test passes before and after by construction; it is a pin, not a repro.

Round-1's own fixes: verified sound, with one correction

  • lineage_conflict is recorded and leaks nothing. Its value is built only from LINEAGE_SPELLINGS, a module constant of four field names, and never from a value read out of the body. Confirmed by reading lineageConflict and by the recorded literal 'thread_id,session_id'.
  • lineage_source mirrors the resolution order. It walks thread_id then session_id, body then blob, which is the order firstString resolves the values themselves, and conversation_id keys on thread_id.
  • Correction to round 1: the three new tests are not three failing tests. Round 1 said the 3 new tests "genuinely fail without the fix". Mutation testing says otherwise, and the real picture is better stated as: with lineage_conflict neutralized to undefined, exactly 1 of 3 fails (body client_metadata wins over the turn-metadata blob when the two disagree). The other two assert absence and so pass trivially against a missing feature. They are not vacuous, but they guard a different mutant: against a detector that flags a field only one surface states, a lineage field only one surface states is not a conflict and lineage_source names the surface the thread actually came from fail; against an always-flag detector, agreeing lineage surfaces record no lineage_conflict also fails. So the suite pins presence once and false-positive freedom twice. Sound coverage, described imprecisely.
  • Renumber still complete. Zero occurrences of LLP 0143 or 0143- anywhere on the branch, code and docs; llp/ holds 0141, 0142, 0144 and no stray 0143.
  • No em dash (U+2014) on any added line, in this PR's diff or in fed35da. No trailing semicolons on added JS lines. No @typedef, no inline import() types, no new type-import specifiers. JSDoc types only.

Finding left for a human

1. Informational - the corroboration set includes a client-controlled header.
exchange-projector.js hasCodexTransportSignal (the user-agent branch), at fed35da.

Three of the four corroborating signals are gateway-side facts the client cannot set: provider/upstream come from the exchange record the gateway wrote about which upstream it proxied to, and the route is the route. The fourth, a codex-prefixed user-agent, is a request header. A client that forges user-agent: codex_cli_rs/1.0 and sends a flat client_metadata pair is still labelled Codex and still dictates the row identity.

I did not narrow this further, and I do not recommend narrowing it. It is pre-existing (the Codex user-agent was already one of isCodexExchange's signals before this PR, so this is not a hole the change opens), forging a Codex user-agent is an affirmative claim to be Codex rather than the accident the flat pair was, and it is the signal most likely to be the only one left if a future Codex build stops writing x-codex-* body keys on the API-key route.

The decision, if a human wants to take it: drop the user-agent branch from hasCodexTransportSignal's corroboration of the flat pair while keeping it as a Codex labelling signal, which would require splitting the one predicate into "may be called Codex" and "may have its flat pair trusted". Cost: a Codex build that emits neither an x-codex-* body key nor a compatibility header on a generic route would lose its lineage and fall back to the content hash. Benefit: the flat-pair path becomes unforgeable by a client. My recommendation is to keep it as pushed and revisit only if a forged-user-agent case is ever observed, which attributes.codex.lineage_source plus client_name makes queryable.

Notes, out of scope as briefed

  • x-openai-subagent staying unread is a recorded open decision (LLP 0144#real-header-names), not a finding.
  • The x-codex-turn-metadata doc-text overlap with PR Codex rollout cwd keys on the thread, not the session container (#459) #462 is untouched. Whichever lands second gets a normal merge conflict.
  • lineage_source does not distinguish a corroborated flat pair from an x-codex-* keyed map. Both are truthfully body_client_metadata; a third enum value would buy a query nobody has asked for. Not a finding.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Triage rung (LLP 0017) for head fed35dad15cc8923ad826a9c6b1b06070a14da6f. The 2-round review budget was spent by round 2 finding the hasFlatIdentity trust branch reachable on /v1/responses and /v1/chat/completions, and round 2's own fix commits (781a0ec, fed35da) were never independently reviewed - they are the current head, and the ladder will not route this PR to review again. I assessed the whole head for ship-safety, not just the residual from round 2's report.

Verdict: safe to merge. No true blocker. Two non-blocking residuals are filed as #473 (see labels: it also carries neutral:stuck because both findings concern code/prose that exists only on this branch, so a fix attempt before merge would target functions absent from master - the mistake flagged in #470's postmortem).

What I verified, by reading and by execution in a scratch worktree at this head:

  1. Does the new transport-signal gate under-capture real Codex traffic? No. I fetched Codex's current upstream source (codex-rs/core/src/responses_metadata.rs, codex-rs/core/src/client.rs) directly from openai/codex@main, independent of this repo. client_metadata() unconditionally inserts x-codex-installation-id and x-codex-window-id alongside session_id/thread_id into the body map on every Responses-API request (the seeded literal, not a conditional branch) - confirmed against two independent mirrors. That means readCodexClientMetadata's hasCodexKey check (any x-codex--prefixed key) is satisfied by every real Codex request regardless of hasCodexTransportSignal, so the new corroboration requirement never actually gates real traffic. I confirmed this empirically too: executing createCodexExchangeProjector().project(...) against a synthetic worst-case exchange (API-key /v1/responses and subscription /backend-api/codex/responses, zero transport signals, a non-Codex user-agent) still resolves client_name: 'codex' and the correct conversation_id/session_id from the body alone, in both cases. Separately, I confirmed via upstream source that Codex never posts to /v1/chat/completions at all (no such endpoint in the client), so that matched path only ever serves other OpenAI-compatible clients.
  2. Is the x-codex-*-body-key-alone escape sound? Yes. x-codex- is a proprietary Codex prefix; a non-Codex client carrying it in client_metadata requires deliberate mimicry of Codex's vocabulary, the same class of act as forging a codex-prefixed user-agent, which the PR's own reasoning already treats as impersonation rather than an accident.
  3. Test genuinely reproduces the round-2 defect. I ran test/plugins/codex-exchange-projector.test.js against git show 8a48414:.../exchange-projector.js (the commit immediately before round 2's fixes): a non-Codex client sending only a flat client_metadata identity pair is not treated as Codex fails there, confirming it exercises the real defect. Round 1's lineage_conflict claim is also confirmed corrected: stubbing lineageConflict() to always return undefined fails exactly 1 of the 3 related tests (body client_metadata wins over the turn-metadata blob when the two disagree), the other two only assert absence and pass trivially either way.
  4. No body content leaks via lineage_conflict. It emits only field-name constants from LINEAGE_SPELLINGS (thread_id, session_id, turn_id, parent_thread_id), never payload values.
  5. npm test (2940 tests): 2931 pass, the same pre-existing 8 leave-command.test.js ERR_MODULE_NOT_FOUND failures and nothing else. npm run smoke -- gateway_codex_capture and core_boot_noop both ok.

Residual (filed as #473, both non-blocking):

  • The hasCodexTransportSignal user-agent branch is client-settable, so a forged codex_cli_rs/... user-agent plus a flat pair can still dictate the LLP 0030 partition key. This is pre-existing (present in master's isCodexExchange before this PR) and Codex lineage reads the durable body client_metadata, not header names Codex never sends (#464) #467 actually narrows it (now additionally requires a matching body map, where master needed only the two now-removed header names). Requires deliberate impersonation, not an accident. Judged preference, not blocker, given no widening of exposure relative to master.
  • LLP 0144's claim that no Codex version emits thread-id/session-id headers is true for the primary turn-streaming path but false for /responses/compact and the websocket handshake (verified against codex-api/src/requests/headers.rs::build_session_headers upstream). Causes no actual under-capture today because the compaction request kind's turn-metadata blob independently carries the same session_id/thread_id, which the projector already reads. Documentation-accuracy fix only.

@philcunliffe
philcunliffe marked this pull request as ready for review July 30, 2026 01:59
@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 30, 2026
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

Copy link
Copy Markdown
Contributor Author

🤖 neutral: merging this PR as-is adds a duplicate LLP 0144 to master

Read this before merging. Nothing here changes the PR's state: it is still mergeable, green, reviewed, and neutral:approved, and this comment does not move the head so that label stands.

The collision

Different filenames, so git reports no conflict and CI stays green. The collision is invisible to every automated gate this PR passes through. Merging puts two documents at number 0144.

For context, master already carries four duplicate numbers right now:

git ls-tree -r --name-only origin/master llp/ | grep -oE '^llp/[0-9]{4}' | sort | uniq -d
llp/0098
llp/0099
llp/0111
llp/0142      <- new, from #475 colliding with 0142-privacy-surface-and-skill-discoverability

Merging this PR makes it five. The highest free number is 0150 (master now goes to 0149), though #466 is being renumbered to 0150 in the same tick, so 0151 would be next after that.

Why neutral did not just fix it

Two reasons, and the second is the one that actually decided it.

Cost. This PR is at its terminal. Pushing a renumber would move the head, which strips neutral:approved by design (LLP 0030) and re-opens the full ladder: merge-base, CI, two review rounds, triage. Roughly three ticks of work to change a filename, on a PR that is ready for you now. Neutral did renumber PR #466 in this same tick, but only because #466 was already CONFLICTING and had to be pushed regardless, so the renumber rode along for free. That asymmetry, not a different judgement about the numbering, is why the two were treated differently.

Deference. How this repo should handle duplicate LLP numbers is an open question neutral deliberately put to you in issue #469: renumber the later claimant, or adopt a filename-qualified citation form in LLP 0001. That issue is neutral:stuck waiting on your answer. Unilaterally renumbering a held, approved PR would be neutral picking one side of a decision it explicitly declined to make. The #466 renumber was mechanical collision-avoidance on a branch already being rewritten, and its commit message says so; doing the same here would be harder to read as anything but a ruling.

Your options

  1. Merge as-is and accept a fifth duplicate, resolving all of them together under whatever convention you settle in LLP 0098, 0099 and 0111 each have two claimants; the renumber-vs-qualified-citation choice is unowned and needs a decision #469. Cheapest now, and consistent with the fact that you just created the 0142 duplicate yourself.
  2. Tell neutral to renumber it (reply here, or drop neutral:approved). It becomes 0151 and rides the ladder back to held. Costs about three ticks.
  3. Renumber it yourself before merging. One git mv plus the @ref and cross-link updates, and it keeps the approval intact since you are the one pushing.

The durable fix, already written and sitting in your queue

PR #468 (held, neutral:approved) adds test/core/llp-ref-hygiene.test.js with a duplicate-number test that is currently skipped, precisely because 0098/0099/0111 already collide. Resolve those and un-skip it, and this entire class becomes a red CI check on any PR, including your own. It would have caught #475's three collisions before they landed.

PR #475 merged llp/0144-shadow-provider-per-api-shape.decision.md to master as
part of the OpenClaw 0142-0149 block, so this branch's
llp/0144-codex-lineage-from-body-client-metadata.decision.md would have put two
documents at 0144. Because the filenames differ, git reported no conflict and CI
stayed green, so nothing on the rung ladder would have caught it before merge.

0151 verified free across master and every remote branch. 0150 is held by
fix/issue-465, renumbered there from 0143 for the same reason.

Purely a renumber: the document's content is unchanged, and the rename carries
every reference with it (12 @ref annotations in exchange-projector.js, 1 in the
gateway_codex_capture smoke, 14 in codex-exchange-projector.test.js, 1 in
codex-rollout-cwd.test.js, plus the cross-links and Related entries in LLP 0083
and LLP 0141, and the heading). No residual 0144 reference remains on this
branch and all six referenced anchors resolve in the renamed document.

The human directed this renumber explicitly, accepting that moving the head
strips neutral:approved and re-opens the review ladder. It resolves this one
collision and sets no precedent for issue #469, where the general
renumber-versus-qualified-citation convention is still open.

Co-Authored-By: Claude <noreply@anthropic.com>
@philcunliffe philcunliffe removed the neutral:approved neutral reviewed this and holds it for a maintainer merge (own or adopted PR; LLP 0025/0030) label Jul 30, 2026
@philcunliffe

Copy link
Copy Markdown
Contributor Author

🤖 neutral: renumbered LLP 0144 to 0151 as you asked

Done, and the trade you accepted has now been taken: the head moved to 3255242e, so neutral:approved is removed and the review ladder is re-open. This PR will ride back to held over the next few ticks without further input.

Verified before touching anything. 0151 is free across master and every remote branch. 0150 is held by fix/issue-465 (PR #466), renumbered there from 0143 during this tick's conflict resolve for the same reason, so #466 needs no further action, which matches what you were told.

Purely a renumber. The document's content is unchanged. The rename carried every reference with it:

  • 12 @ref annotations in exchange-projector.js
  • 1 in the gateway_codex_capture smoke
  • 14 in codex-exchange-projector.test.js, 1 in codex-rollout-cwd.test.js
  • cross-links and Related: entries in LLP 0083 and LLP 0141
  • the document's own heading

Confirmed afterwards: no residual 0144 reference anywhere on this branch, all six referenced anchors (#body-is-a-codex-signal, #body-is-authority, #lineage-conflict, #lineage-source, #real-header-names, #row-identity) resolve in the renamed document, and no em dash was introduced. master keeps llp/0144-shadow-provider-per-api-shape.decision.md untouched, so merging this PR no longer creates a duplicate.

The commit message states this resolves one collision and sets no precedent for issue #469, where the general renumber-versus-qualified-citation convention is still open and still waiting on you.

Two things worth deciding while this is fresh

master still carries four duplicate numbers: 0098, 0099, 0111, and 0142 (the last from #475 colliding with 0142-privacy-surface-and-skill-discoverability). Renumbering the two branch collisions did not touch those.

And the enforcement half is already merged. PR #468 landed test/core/llp-ref-hygiene.test.js, which contains:

test('no LLP number is claimed by two documents', { skip: 'issue #463 item 1: renumber vs qualified-citation is an open corpus decision' }, ...)

Clear those four duplicates and delete that skip, and this whole class becomes a red CI check on every future PR, including yours. It would have caught the three collisions #475 introduced before they merged. Given you have just chosen "renumber" twice, that skip is the remaining gap between the decision and it being enforced.

@philcunliffe

Copy link
Copy Markdown
Contributor Author

Triage rung (LLP 0017) for head 3255242e, the only commit landed since this PR was approved at fed35dad: a pure renumber of the PR's own LLP document, LLP 0144 to LLP 0151, ordered directly by the human because PR #475 landed llp/0144-shadow-provider-per-api-shape.decision.md on master and two documents cannot share a number. Moving the head stripped neutral:approved, and the review budget was already spent, so this renumber commit had never been reviewed before now. I re-verified it independently rather than trusting the orchestrator's commit message.

Renumber commit, re-verified from the tree (own count, not copied from the commit message):

  • llp/0151-codex-lineage-from-body-client-metadata.decision.md exists; no llp/0144-codex-* remains anywhere on the branch. grep -rn 0144 across the whole tree (js/md) returns nothing. Confirmed clean.
  • No residual 0144 reference to this document survives. The human's 0144-shadow-provider-per-api-shape lives only on master and is untouched by this branch (verified by merging current master into a scratch copy: it lands at llp/0144-shadow-provider-per-api-shape.decision.md with zero conflicts, and the two 0144-named documents never coexist on any single ref).
  • All six anchors resolve in the renamed document via explicit <a id="..."> tags: #body-is-a-codex-signal (L75), #body-is-authority (L60), #lineage-conflict (L143), #lineage-source (L133), #real-header-names (L113), #row-identity (L161).
  • Reference count, counted myself with grep, not copied from the commit body: 12 @ref LLP 0151 in exchange-projector.js, 1 in gateway_codex_capture.js, 14 LLP 0151 mentions in codex-exchange-projector.test.js, 1 in codex-rollout-cwd.test.js, plus LLP 0083 (1 Related: entry + 2 inline cross-links) and LLP 0141 (1 Related: entry + 1 inline cross-link), plus the document's own heading. Matches the orchestrator's claim exactly. No reference to the old number was missed anywhere in the tree.
  • Content diff: diff <(git show fed35dad:llp/0144-codex-lineage-from-body-client-metadata.decision.md) llp/0151-...md shows exactly one changed line, the # LLP 0144: -> # LLP 0151: heading. Byte-identical otherwise.
  • No em dash (U+2014) anywhere in the renumber commit's diff (checked with a Unicode-aware grep over git show).

Verdict: clean. This was a correct, faithful, complete renumber with no smuggled content change.

Semantic drift against current master (four PRs since last real review: #475, #458, #461, #468): merged current origin/master into a scratch copy of this branch. The merge is textually conflict-free, matching GitHub's MERGEABLE. #468's test/core/llp-ref-hygiene.test.js (not present on this branch pre-merge) passes clean once merged: 8/9 subtests pass, 1 pre-existing skip (issue #463), 0 broken @refs, 0 em-dash violations. #475's OpenClaw LLP 0142-0149 block is docs-only and never touches the Codex plugin, so no interaction. #458's session_command.js rewrite resolves session id from the rollout tree (LLP 0066/0067) independently of exchange-projector.js's lineage resolution; no shared logic, no drift. Full npm test on the merged tree: 2895 pass, 8 fail, all 8 the pre-existing test/core/leave-command.test.js failures (confirmed identically 3 pass / 8 fail on a pristine origin/master worktree with the same node_modules symlink). Nothing this PR asserts or references breaks against current master.

Residuals, already tracked as #473 (neutral:fix + neutral:stuck), re-checked against current code, not re-filed:

  1. hypaware-core/plugins-workspace/codex/src/exchange-projector.js:797-803 (hasCodexTransportSignal) - the user-agent branch (codexClientFromUserAgent(userAgent).entrypoint !== undefined) is still a plain string match on a client-settable header. Combined with the body-map trust rule at L816-825 (a flat session_id/thread_id pair is honored once transport is "corroborated"), a forged codex_cli_rs/... user-agent plus a flat client_metadata pair still labels traffic as Codex and picks the LLP 0030 partition key. PREFERENCE, unchanged from the prior judgment: pre-existing (not introduced by this PR or the renumber), and requires deliberate impersonation of one's own traffic in a local telemetry tool - there is no third-party victim.
  2. llp/0151-codex-lineage-from-body-client-metadata.decision.md:125 - the audit table's ~~thread-id~~, ~~session-id~~, ~~parent-thread-id~~ | no | nothing emits them row is still inaccurate for /responses/compact and the websocket handshake, which do emit those bare names. PREFERENCE, unchanged: documentation accuracy only. No under-capture results, since the compaction request kind's turn-metadata blob independently supplies session_id/thread_id through the surfaces this code does read.

Both re-judged against the current tree and current master; I agree with the prior classification on both, no new evidence changes them, no new items found to defer.

Outcome: nothing here is a true blocker. Everything is PREFERENCE or already tracked. This PR, including the previously-unreviewed renumber commit, can merge safely.

Residual tracking: #473 (unchanged, not duplicated).

@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 30, 2026
@philcunliffe
philcunliffe merged commit dab0083 into master Jul 30, 2026
8 checks passed
@philcunliffe
philcunliffe deleted the fix/issue-464 branch July 30, 2026 04:03
philcunliffe pushed a commit that referenced this pull request Jul 30, 2026
…nes #466 aged

`llp/0083-codex-live-cwd-from-rollout.decision.md` was the only conflict: this
branch inserts an "an unusable in-band cwd is a miss" bullet into the Decision
list, and master (#467) rewrote the adjacent "keyed on the codex session id"
bullet to name `client_metadata.session_id` and LLP 0151. Both wanted; both kept.

Two claims this branch made stopped being true when #466 landed, so they are
corrected rather than carried across:

- The bullet said the rollout-stated `cwd` is "not yet" held to the same rule
  and that PR #466 would close that half. #466 landed: `rollout-cwd.js` reads
  through `readRolloutSessionMeta`, which applies `sessionMetaCwd`, so a refused
  in-band value now falls through to an already-predicated source. Stated that
  way, and the "two limits" count drops to the one that remains (#476).
- `usableInBandCwd`'s docstring pointed at "LLP 0143 #usable-cwd, PR #466" and
  said the core module "is not on `master` yet; unify them once it lands". That
  LLP is now 0150, and the module has landed. The duplication is kept with its
  real reason: LLP 0150 scopes the in-band path out of its own mandate, so
  borrowing its predicate here would widen 0150's stated scope, and the
  `error_kind` split needs the two conjuncts apart.

The merged text now says explicitly that this bullet is not a consequence of
LLP 0150, so nothing implies the in-band path is covered by 0150's rule.

No code behavior changes in this merge: the projector, its helper, and the tests
merged textually clean and are re-verified against what #466/#467 landed.

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

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

`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>

* Review: name the container fallback's residual gap and cover the two 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>

* Review round 2: make the lineage refusal reachable, and correct it from 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>

* Review: correct LLP 0083's stale Decision thesis and clear the touched 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>

* Correct the container fallback's stale rationale and pin the value-blind 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>

* Attribute the guard's unreachability to the surface that actually earns 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>

---------

Co-authored-by: neutral-loop <neutral-loop@users.noreply.github.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: neutral-reconciler <neutral@example.com>
Co-authored-by: neutral-reconciler <neutral-reconciler@users.noreply.github.com>
Co-authored-by: test <test@test.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

Development

Successfully merging this pull request may close these issues.

Codex lineage is read from headers Codex may not send, while the durable thread_id sits unread in the request body

1 participant