diff --git a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js index 8fa783e8..fb68873e 100644 --- a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js +++ b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js @@ -253,9 +253,12 @@ export function createCodexExchangeProjector(opts = {}) { * When the client states no thread id the container is still the right key for a * ROOT thread (there the two are one uuid). That was once the common * subscription-route shape, a bare `session-id` header; it is not any more, and - * that header name is not one Codex emits or this file reads - * (@ref LLP 0151#real-header-names). Since the adapter began reading the body's - * flat `client_metadata` map, which Codex fills with BOTH ids on every request + * that header name is not one this file reads on any path + * (@ref LLP 0151#real-header-names). The name is real on Codex's compaction and + * websocket paths, so leaving it unread is a decision rather than an oversight: + * those requests state the same ids in the turn-metadata blob + * (@ref LLP 0165#header-audit-correction). Since the adapter began reading the + * body's flat `client_metadata` map, which Codex fills with BOTH ids on every request * (@ref LLP 0151#body-is-authority), an ordinary Codex turn states its thread and * is answered by the branch above. What is left for the two lines below is a turn * that names a container on a Codex-owned surface while naming no `thread_id` on @@ -752,15 +755,18 @@ const X_CODEX_PARENT_THREAD_ID = 'x-codex-parent-thread-id' function resolveCodexContext(input, provider, path, reqBody) { // @ref LLP 0151#body-is-a-codex-signal [implements]: a Codex-owned body map // identifies the exchange on its own, so the API-key route's generic - // `/v1/responses` resolves with no Codex header at all. The transport signal is - // resolved first because it is also what corroborates the body's ambiguous - // flat identity pair (see `readCodexClientMetadata`). + // `/v1/responses` resolves with no Codex header at all. const transportIsCodex = hasCodexTransportSignal(input, provider, path) + // @ref LLP 0165#flat-pair-corroboration [implements]: naming the client and + // trusting its unnamespaced identity pair are two different questions, and the + // user-agent may only answer the first. Passing the narrow predicate here is + // the whole of that split. + const flatPairIsCorroborated = hasCodexNamespaceSignal(input, provider, path) // @ref LLP 0151#body-is-authority [implements]: the flat body map first, the // turn-metadata blob second. Both are projections of one Codex snapshot, so // they agree whenever both are present; the body is preferred because it is // the only one present for every request kind. - const clientMetadata = readCodexClientMetadata(reqBody, transportIsCodex) + const clientMetadata = readCodexClientMetadata(reqBody, flatPairIsCorroborated) if (!transportIsCodex && clientMetadata === undefined) return undefined const metadata = readCodexTurnMetadata(input, clientMetadata) const userAgent = readHeader(input.request_headers, 'user-agent') @@ -896,24 +902,48 @@ function resolveCodexContext(input, provider, path, reqBody) { } /** - * Whether the transport alone identifies this exchange as Codex, before any part - * of the request body is consulted: the ChatGPT upstream, the Codex route - * namespace, a Codex-namespaced compatibility header, or a Codex user-agent - * product. Every one of these is a name only a Codex client produces. + * The transport signals that spell a name out of Codex's own proprietary + * vocabulary: the ChatGPT upstream, the `/backend-api/codex/` route namespace, + * or an `x-codex-*` compatibility header. Producing any of them takes knowledge + * of a route or header name Codex never published as an interface, which is a + * meaningfully higher bar than copying a product string. * - * Kept separate from the body signal because it is also what corroborates a + * This, and NOT the looser `hasCodexTransportSignal`, is what corroborates a * `client_metadata` map carrying no Codex-owned key of its own. - * @ref LLP 0151#body-is-a-codex-signal [implements] + * @ref LLP 0165#flat-pair-corroboration [implements]: the strict half of the + * split, the only half a partition key is allowed to rest on. * * @param {AiGatewayExchangeInput} input * @param {string} provider * @param {string} path */ -function hasCodexTransportSignal(input, provider, path) { +function hasCodexNamespaceSignal(input, provider, path) { if (provider === 'chatgpt') return true if (isCodexNamespacePath(path)) return true if (readHeader(input.request_headers, X_CODEX_TURN_METADATA)) return true - if (readHeader(input.request_headers, X_CODEX_WINDOW_ID)) return true + return Boolean(readHeader(input.request_headers, X_CODEX_WINDOW_ID)) +} + +/** + * Whether the transport alone identifies this exchange as Codex, before any part + * of the request body is consulted: any namespace signal above, or a Codex + * user-agent product. + * + * Deliberately the loose half of the split. The user-agent is a product-name + * convention, not a namespace: any process on the user's own machine can set + * `codex_cli_rs/...` without knowing anything Codex-specific. That is enough to + * label a row's client, which is a description, and not enough to promote an + * unnamespaced `client_metadata` pair into `conversation_id` and the LLP 0030 + * partition key, which is an identity. + * @ref LLP 0165#flat-pair-corroboration [implements]: the loose half, which may + * name the client and nothing more. + * + * @param {AiGatewayExchangeInput} input + * @param {string} provider + * @param {string} path + */ +function hasCodexTransportSignal(input, provider, path) { + if (hasCodexNamespaceSignal(input, provider, path)) return true const userAgent = readHeader(input.request_headers, 'user-agent') return codexClientFromUserAgent(userAgent).entrypoint !== undefined } @@ -934,17 +964,21 @@ function hasCodexTransportSignal(input, provider, path) { * `/v1/chat/completions`. Honouring the pair on its own would therefore let an * unrelated client be stamped `client_name: 'codex'` and dictate this row's * `conversation_id` and `session_id`, which is the same defect class as the - * fictional `thread-id` header this document removed, only through the body. So - * the pair is trusted only once `corroborated` says the transport already - * identified the exchange as Codex, where it adds lineage detail to a client - * that is already known rather than naming the client. + * bare `thread-id` header read this document removed, only through the body. So + * the pair is trusted only once `corroborated` says a Codex-namespaced transport + * signal already identified the exchange, where it adds lineage detail to a + * client that is already known rather than naming the client. A Codex-shaped + * user-agent is NOT such a signal: see `hasCodexNamespaceSignal`. * @ref LLP 0151#body-is-authority: the always-present lineage surface. * @ref LLP 0151#body-is-a-codex-signal [constrained-by]: which keys of the map * are evidence of Codex, and which only carry detail. + * @ref LLP 0165#flat-pair-corroboration [constrained-by]: which transport signals + * may corroborate the pair. * * @param {unknown} reqBody - * @param {boolean} corroborated Whether the transport (upstream, route, Codex - * header, or Codex user-agent) already identified this exchange as Codex. + * @param {boolean} corroborated Whether a Codex-namespaced transport signal + * (the ChatGPT upstream, the Codex route namespace, or an `x-codex-*` header) + * already identified this exchange as Codex. * @returns {Record | undefined} */ function readCodexClientMetadata(reqBody, corroborated) { @@ -1084,9 +1118,12 @@ function selectCodexWorkspace(metadata, cwd) { function resolveConversationId(reqBody, input, provider, path, codexContext) { // @ref LLP 0151#real-header-names [implements]: the thread comes from the // context's own resolution (body map, then turn-metadata blob) and nowhere - // else. The `thread-id` / `session-id` header names that used to be consulted - // here are names Codex never emits, so they could only ever have let a - // non-Codex hop dictate this row's identity. + // else. No Codex turn states its lineage in the `thread-id` / `session-id` + // header names that used to be consulted here, so they could only ever have + // let a non-Codex hop dictate this row's identity. + // @ref LLP 0165#header-audit-correction [constrained-by]: those two names are + // real on Codex's compaction and websocket paths, and still not read here, + // because the turn-metadata blob states the same ids for that request kind. if (codexContext?.thread_id) return codexContext.thread_id const sessionId = readMetadataSessionId(reqBody) if (sessionId) return sessionId diff --git a/hypaware-core/smoke/flows/gateway_codex_capture.js b/hypaware-core/smoke/flows/gateway_codex_capture.js index a1832098..5225cd0a 100644 --- a/hypaware-core/smoke/flows/gateway_codex_capture.js +++ b/hypaware-core/smoke/flows/gateway_codex_capture.js @@ -122,7 +122,9 @@ export async function run({ harness, expect }) { const codexTurnId = `turn-${harness.devRunId}` // @ref LLP 0151#body-is-authority: Codex states its lineage in the body's flat // `client_metadata` map on every request kind, so the fixture carries it there - // and NOT under the `thread-id` / `session-id` header names Codex never emits. + // and NOT under the bare `thread-id` / `session-id` header names. + // @ref LLP 0165#header-audit-correction: those two names are real on Codex's + // compaction and websocket paths, but no turn states its lineage in them. const responsesBody = JSON.stringify({ model: 'gpt-5-codex', input: [{ role: 'user', content: [{ type: 'input_text', text: 'help refactor' }] }], diff --git a/llp/0083-codex-live-cwd-from-rollout.decision.md b/llp/0083-codex-live-cwd-from-rollout.decision.md index 5eb32879..f2bdda34 100644 --- a/llp/0083-codex-live-cwd-from-rollout.decision.md +++ b/llp/0083-codex-live-cwd-from-rollout.decision.md @@ -94,7 +94,10 @@ has the symmetric fallback. so the container is not what selects a rollout. The live path resolves the thread from the body's `client_metadata.thread_id`, else the turn-metadata blob ([LLP 0151](./0151-codex-lineage-from-body-client-metadata.decision.md#body-is-authority); - it was never a `thread-id` header, a name Codex does not emit). Only a real + it was never a `thread-id` header; that name is real on paths this document + did not reach, and stated no lineage the projector reads on any of them, see + **Extended-by: [LLP 0165](./0165-codex-flat-pair-needs-a-namespace-signal.decision.md#header-audit-correction)**). + Only a real Codex thread has a rollout, so non-codex traffic never scans. The name is a cheap prefilter, not the answer: the located file's `payload.id` is re-checked against the id asked for, and a disagreement is a **refusal** (cwd unknown) @@ -217,6 +220,12 @@ has the symmetric fallback. remains the open acceptance check, and it is [LLP 0141](./0141-codex-desktop-rides-the-codex-adapter.decision.md)'s point that no hermetic smoke can supply it. + (**Extended-by: [LLP 0165](./0165-codex-flat-pair-needs-a-namespace-signal.decision.md#header-audit-correction)** - + `session-id` and `thread-id` are real names `codex-rs` emits from + `build_session_headers`, on the `/responses/compact` and websocket paths this + bullet's reading did not reach; only `parent-thread-id` is fictional. The rule + the bullet states is unaffected, because no Codex turn states its lineage in + those two names on any path the projector reads.) - **First line only, cached per thread id.** The rollout is written at session start, so it exists before the first exchange projects (earlier and more reliably than Claude's sidecar, which has a known session-start race). Reading a diff --git a/llp/0151-codex-lineage-from-body-client-metadata.decision.md b/llp/0151-codex-lineage-from-body-client-metadata.decision.md index a72dd9e6..87558aea 100644 --- a/llp/0151-codex-lineage-from-body-client-metadata.decision.md +++ b/llp/0151-codex-lineage-from-body-client-metadata.decision.md @@ -43,6 +43,9 @@ Two consequences the projector was on the wrong side of: ([LLP 0030](./0030-session-id-partition-key.decision.md)). `parent-thread-id` was simply the wrong spelling of the real `x-codex-parent-thread-id`, so header-route subagent lineage never resolved at all. + (**Extended-by: [LLP 0165](./0165-codex-flat-pair-needs-a-namespace-signal.decision.md#header-audit-correction)** - + two of the three are real names on paths this audit did not reach, and + correctly unread anyway.) The premise that `x-codex-turn-metadata` is a Codex Desktop signal ([LLP 0083](./0083-codex-live-cwd-from-rollout.decision.md#context), @@ -92,6 +95,12 @@ defect class as the fictional `thread-id` header header, and in a capture product a misfiled client is a privacy question and not a cosmetic one. +> **Extended-by: [LLP 0165](./0165-codex-flat-pair-needs-a-namespace-signal.decision.md#flat-pair-corroboration).** +> The user-agent branch is dropped from the corroborating predicate below. It +> is a product-name convention any local process can copy, so it may name the +> client but not promote the flat pair into the partition key. The other three +> signals are unchanged, and so is everything else in this section. + So the pair is honoured only once the transport has already identified the exchange as Codex independently of the body: the `chatgpt` upstream, the `/backend-api/codex/` namespace, an `x-codex-*` compatibility header, or a @@ -124,6 +133,14 @@ read.** The audit of every header this file reads: | `x-oai-request-id` (response) | yes | the service | | ~~`thread-id`~~, ~~`session-id`~~, ~~`parent-thread-id`~~ | **no** | nothing emits them; removed | +> **Extended-by: [LLP 0165](./0165-codex-flat-pair-needs-a-namespace-signal.decision.md#header-audit-correction).** +> The last row overstates: `session-id` and `thread-id` are real header names on +> Codex's `/responses/compact` and websocket-handshake paths, though not on the +> turn-streaming path this document audited, and `parent-thread-id` is fictional +> as stated. The verdict is unaffected (both remain correctly unread, since the +> turn-metadata blob states the same ids for the one request kind that carries +> them), only the reason. + `x-openai-subagent` is real and still **unread**. Adopting it would change what `is_sidechain` means (its values are Codex's subagent *kinds*: `review`, `compact`, `memory_consolidation`, `collab_spawn`), which is a separate diff --git a/llp/0165-codex-flat-pair-needs-a-namespace-signal.decision.md b/llp/0165-codex-flat-pair-needs-a-namespace-signal.decision.md new file mode 100644 index 00000000..8b9245bc --- /dev/null +++ b/llp/0165-codex-flat-pair-needs-a-namespace-signal.decision.md @@ -0,0 +1,132 @@ +# LLP 0165: a Codex user-agent may name the client, not key the row + +**Type:** Decision +**Status:** Active +**Systems:** Plugins, Sources, Gateway +**Author:** Claude +**Date:** 2026-07-31 +**Related:** LLP 0030, LLP 0083, LLP 0141, LLP 0151 + +> Carries the two deferred review findings from +> [LLP 0151](./0151-codex-lineage-from-body-client-metadata.decision.md)'s +> implementing PR (#467, follow-up issue #473). LLP 0151 is Active and +> immutable, so the tightening and the factual correction land here as new +> findings that ref it, not as edits to it. + +## Context + +LLP 0151 moved Codex lineage off header names onto the request body's flat +`client_metadata` map, and gated the map's non-Codex-exclusive `session_id` + +`thread_id` pair on the transport having already identified the exchange as +Codex ([#body-is-a-codex-signal](./0151-codex-lineage-from-body-client-metadata.decision.md#body-is-a-codex-signal)). +The predicate it gated on, `hasCodexTransportSignal`, answered four signals: the +`chatgpt` upstream, the `/backend-api/codex/` route namespace, an `x-codex-*` +compatibility header, and a `codex`-prefixed user-agent product. + +Two findings against that shape, both raised on the PR and deferred as +non-blocking: + +1. **One of the four signals is a naming convention, not a namespace.** A + forged `user-agent: codex_cli_rs/1.0` plus a body `client_metadata` carrying + only the flat pair satisfied the predicate, and the pair then dictated + `conversation_id` and `session_id`, the latter being the partition key + ([LLP 0030](./0030-session-id-partition-key.decision.md)). The other three + signals need Codex's proprietary route or header vocabulary; the user-agent + needs only a product string any process on the machine can copy. This is + residual rather than introduced: the pre-LLP-0151 projector let the same + user-agent reach a header fallback with no body gate at all, so LLP 0151 was + already a net tightening. It is worth closing anyway because the value it + steers is a privacy-adjacent partition key in a traffic-capture product. +2. **The header audit overstated its case.** LLP 0151 + [#real-header-names](./0151-codex-lineage-from-body-client-metadata.decision.md#real-header-names) + records `thread-id`, `session-id` and `parent-thread-id` as names no Codex + version emits. Two of the three are real on paths the audit did not reach. + +## Decision + +**"May be called Codex" and "may have its +flat identity pair trusted" are two predicates, and the user-agent answers only +the first.** The loose predicate keeps all four signals and keeps deciding +`client_name`, `client_version` and whether a codex context resolves at all: a +description of the client, revisable on the next row, costing nothing if wrong. +The strict predicate is the three Codex-namespaced signals only (`chatgpt` +upstream, `/backend-api/codex/` namespace, `x-codex-*` header), and it alone +corroborates a `client_metadata` map that carries no `x-codex-*` key of its own. +An identity is not revisable: it clusters rows on disk (LLP 0030 §Breaking) and +scopes the fallback `message_id`, so it may rest only on a name the client had +to learn from Codex rather than one it could guess from a release note. + +Real Codex loses nothing today. Its map carries `x-codex-installation-id` and +`x-codex-window-id` on every request (LLP 0151 +[#context](./0151-codex-lineage-from-body-client-metadata.decision.md#context)), +which is the self-naming branch and never needed corroboration. The accepted +cost is narrow and deliberate: a hypothetical future Codex build that stops +writing any `x-codex-*` map key, sends no compatibility header, posts to a +generic path, and is therefore reachable only by user-agent, loses lineage until +this file is updated. That build would already be a version drift LLP 0151 exists +to make visible, and it surfaces as an absent `lineage_source` +([#lineage-source](./0151-codex-lineage-from-body-client-metadata.decision.md#lineage-source)) +rather than as silence. + +Rejected: dropping the user-agent from the loose predicate too. It is good +enough to label a client and it is the only signal the API-key route's generic +`/v1/responses` carries when the body map is absent, so removing it would cost +real capture to buy nothing, since labelling was never the exposure. + +**Correction to LLP 0151's header audit: +`session-id` and `thread-id` are real Codex header names on two paths.** +`codex-rs/codex-api/src/requests/headers.rs::build_session_headers` inserts +headers literally named `session-id` and `thread-id`. It has two call sites in +`codex-rs/core/src/client.rs`: `compact_conversation_history` (the +`/responses/compact` endpoint, which HypAware's `isOpenAiResponsesPath` matches) +and `build_websocket_headers` (the `stream_responses_websocket` handshake). So +LLP 0151's "nothing emits them" is wrong for those two paths. It stands for +`parent-thread-id`, which remains a name nothing produces and was simply the +wrong spelling of `x-codex-parent-thread-id`, and it stands for the primary HTTP +turn-streaming path (`build_responses_request` / `stream_responses_api`), which +is the path essentially all ordinary Codex traffic uses. + +**The projector still does not read them, and that stays right.** The +correction is to the justification, not to the decision. The compaction requests +that carry the two headers are `CodexResponsesRequestKind::Compaction`, which +Codex marks `has_turn_identity = true`, so their `x-codex-turn-metadata` blob +independently states the same `session_id` and `thread_id` that +`readCodexTurnMetadata` already reads. `Memory` requests (`summarize_memories`) +state identity on no surface at all by Codex's own design, so there is nothing +for any reader to find. Reading the two bare names would therefore add no id +HypAware does not already have, while reopening exactly what LLP 0151 closed: +they are unnamespaced names any proxy hop or hand-rolled client may set, and a +wrong value there dictates row identity. The audit row's verdict ("removed") +survives its stated reason being corrected. + +The websocket handshake was not investigated further. HypAware's HTTP-proxy +gateway model does not surface it as a request/response exchange, so no +projector path reads its headers either way. + +## Consequences + +- `hasCodexTransportSignal` is now the union of `hasCodexNamespaceSignal` and + the user-agent branch, and only the narrow one is passed to + `readCodexClientMetadata`. That single argument is the whole of the split. +- Row identity does not move for any shape HypAware has recorded from real + Codex traffic, because every such request either carries an `x-codex-*` map + key or arrives on a namespaced route. The pinned-literal identity test in + `test/plugins/codex-exchange-projector.test.js` is unchanged and still passes. +- A user-agent-only exchange whose map states only the flat pair now records the + content-hash fallback identity and no `lineage_source`, and is still stamped + `client_name: 'codex'`. That asymmetry is the decision, so it is asserted as + one test with both halves rather than two tests. +- LLP 0151 gains `Extended-by` forward-refs at the two affected sections. Its + text is otherwise untouched. + +## References + +- Code: `hypaware-core/plugins-workspace/codex/src/exchange-projector.js` + (`hasCodexNamespaceSignal`, `hasCodexTransportSignal`, + `readCodexClientMetadata`, `resolveCodexContext`). +- Tests: `test/plugins/codex-exchange-projector.test.js` (the user-agent + asymmetry, and the namespace-corroborated flat-pair case). +- [LLP 0151](./0151-codex-lineage-from-body-client-metadata.decision.md) - the + decision this extends and corrects. +- [LLP 0030](./0030-session-id-partition-key.decision.md) - why an identity is + not revisable the way a client label is. diff --git a/test/plugins/codex-exchange-projector.test.js b/test/plugins/codex-exchange-projector.test.js index 5fdd551a..0ccb32df 100644 --- a/test/plugins/codex-exchange-projector.test.js +++ b/test/plugins/codex-exchange-projector.test.js @@ -1481,10 +1481,14 @@ test('Codex lineage resolves from the compatibility headers Codex actually sends assert.equal(projection.attributes.codex.lineage_source, 'turn_metadata') }) -// @ref LLP 0151#real-header-names [tests]: `thread-id`, `session-id` and -// `parent-thread-id` are names Codex never emits. Reading them let an -// unrelated proxy hop or a hand-rolled client dictate `conversation_id`, which -// is the partition-adjacent row identity, so they must resolve to nothing. +// @ref LLP 0151#real-header-names [tests]: no Codex turn states its lineage +// under these bare names, so reading them only let an unrelated proxy hop or a +// hand-rolled client dictate `conversation_id`, which is the partition-adjacent +// row identity. They must therefore resolve to nothing. +// @ref LLP 0165#header-audit-correction [tests]: `parent-thread-id` is fictional +// outright, while `thread-id` and `session-id` are real on the compaction and +// websocket-handshake paths and still unread, since the turn-metadata blob +// states the same ids for the one request kind that carries them. test('a bare lineage header name Codex never sends resolves to nothing, not a wrong value', () => { const projector = createCodexExchangeProjector() const projection = /** @type {any} */ (projector.project(exchange({ @@ -1677,9 +1681,64 @@ test('a non-Codex client sending only a flat client_metadata identity pair is no // @ref LLP 0151#body-is-a-codex-signal [tests]: corroboration is what makes the // flat pair readable, not the pair itself, so the guard above must narrow only // WHO may be called Codex and not WHAT a known Codex client's map carries. A -// Codex user-agent is corroboration on its own, so a Codex turn whose map states -// only the flat pair still resolves its lineage from the body. -test('a transport-corroborated Codex request still resolves lineage from a flat-only client_metadata', () => { +// Codex-namespaced header is corroboration on its own, so a Codex turn whose map +// states only the flat pair still resolves its lineage from the body. +test('a namespace-corroborated Codex request still resolves lineage from a flat-only client_metadata', () => { + const projector = createCodexExchangeProjector() + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/v1/responses', + request_headers: JSON.stringify({ 'x-codex-window-id': 'window-corr' }), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'go', + client_metadata: { session_id: 'session-corr', thread_id: 'thread-corr' }, + }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + assert.equal(projection.client_name, 'codex') + assert.equal(projection.conversation_id, 'thread-corr') + assert.equal(projection.session_id, 'session-corr') + assert.equal(projection.attributes.codex.lineage_source, 'body_client_metadata') +}) + +// @ref LLP 0165#flat-pair-corroboration [tests]: the user-agent is a product-name +// convention any local process can copy, so it may name the client but must not +// promote an uncorroborated flat pair into the LLP 0030 partition key. The two +// halves are asserted together: the row is still called Codex (all four signals +// still answer "may be called Codex"), and the flat pair still contributes +// nothing (only the namespace signals answer "may have its flat pair trusted"). +test('a Codex user-agent alone does not let a flat client_metadata pair dictate row identity', () => { + const projector = createCodexExchangeProjector() + /** @param {Record} body */ + const project = (body) => /** @type {any} */ (projector.project(exchange({ + path: '/v1/responses', + request_headers: JSON.stringify({ 'user-agent': 'codex_cli_rs/0.55.0' }), + request_body: JSON.stringify({ model: 'gpt-5-codex', input: 'go', ...body }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + const projection = project({ client_metadata: { session_id: 'session-ua', thread_id: 'thread-ua' } }) + // Loose half: the user-agent still identifies the client. + assert.equal(projection.client_name, 'codex') + assert.equal(projection.client_version, '0.55.0') + // Strict half: nothing of the unproven pair reaches the row. + assert.notEqual(projection.conversation_id, 'thread-ua') + assert.notEqual(projection.session_id, 'session-ua') + assert.equal(projection.attributes.codex.thread_id, undefined) + assert.equal(projection.attributes.codex.session_id, undefined) + assert.equal(projection.attributes.codex.lineage_source, undefined) + // Strongest form: the row is identical to the same request without the map. + const control = project({}) + assert.equal(projection.conversation_id, control.conversation_id) + assert.equal(projection.session_id, control.session_id) +}) + +// @ref LLP 0165#flat-pair-corroboration [tests]: dropping the user-agent branch +// from the strict predicate must not cost a Codex build that still writes an +// `x-codex-*` entry into its map. That key names the client on its own, so a +// user-agent-only transport keeps full lineage whenever the map is self-naming. +test('a Codex user-agent request keeps its lineage when the map carries a Codex-owned key', () => { const projector = createCodexExchangeProjector() const projection = /** @type {any} */ (projector.project(exchange({ path: '/v1/responses', @@ -1687,14 +1746,18 @@ test('a transport-corroborated Codex request still resolves lineage from a flat- request_body: JSON.stringify({ model: 'gpt-5-codex', input: 'go', - client_metadata: { session_id: 'session-ua', thread_id: 'thread-ua' }, + client_metadata: { + 'x-codex-installation-id': 'install-ua', + session_id: 'session-ua-owned', + thread_id: 'thread-ua-owned', + }, }), response_body: JSON.stringify({ output_text: 'done' }), }), context())) assert.equal(projection.client_name, 'codex') - assert.equal(projection.conversation_id, 'thread-ua') - assert.equal(projection.session_id, 'session-ua') + assert.equal(projection.conversation_id, 'thread-ua-owned') + assert.equal(projection.session_id, 'session-ua-owned') assert.equal(projection.attributes.codex.lineage_source, 'body_client_metadata') }) diff --git a/test/plugins/codex-rollout-cwd.test.js b/test/plugins/codex-rollout-cwd.test.js index 2943e0a2..32fa5e3a 100644 --- a/test/plugins/codex-rollout-cwd.test.js +++ b/test/plugins/codex-rollout-cwd.test.js @@ -56,7 +56,7 @@ const SUBSCRIPTION_THREAD_ID = '019e60b5-9999-4aaa-8bbb-ccccddddeeee' * That is the shape these tests need, because the rollout fallback only runs * when the request states an id and no in-band cwd. * @ref LLP 0151#body-is-authority [tests]: keyed on the surface Codex really - * fills, not on a `session-id` header Codex never emits. + * fills, not on a bare `session-id` header no Codex turn sends. */ function subscriptionClientMetadata() { return {