diff --git a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js index 496e77b9..bdc3b4fe 100644 --- a/hypaware-core/plugins-workspace/codex/src/exchange-projector.js +++ b/hypaware-core/plugins-workspace/codex/src/exchange-projector.js @@ -64,10 +64,20 @@ export function createCodexExchangeProjector(opts = {}) { if (isOpenAiChatPath(path)) return true if (isOpenAiResponsesPath(path)) return true if (isCodexNamespacePath(path)) return true - // Codex Desktop tags requests with a `x-codex-turn-metadata` - // header even when the path looks generic, so accept the header - // as a sufficient match signal. - if (readHeader(input.request_headers, 'x-codex-turn-metadata')) return true + // Any Codex client tags a turn-metadata-carrying request with + // `x-codex-turn-metadata`, even when the path looks generic, so accept the + // header as a sufficient match signal. It is NOT a Desktop-only signal. + // @ref LLP 0151#real-header-names [constrained-by]: every Codex client + // emits it, so the match is client-independent. + if (readHeader(input.request_headers, X_CODEX_TURN_METADATA)) return true + // NOTE this gate deliberately does not consult the body, while + // `resolveCodexContext` treats a Codex-owned `client_metadata` as a + // sufficient Codex signal. The two only stay consistent because the path + // set above covers every route Codex posts to, so a body-only Codex + // request is always matched here first. A test pins that: see + // `test/plugins/codex-exchange-projector.test.js` ("every route Codex + // posts to is matched..."). Widen the path set, do not start reading the + // body here, if Codex adds a route. return false }, @@ -636,14 +646,39 @@ function firstPlainObject(...values) { // Codex header + workspace metadata // --------------------------------------------------------------------- +// The Codex-owned request headers this file may read. `compatibility_headers` in +// `codex-rs/core/src/responses_metadata.rs` builds exactly four names +// (`x-codex-window-id`, `x-codex-turn-metadata`, `x-codex-parent-thread-id`, +// `x-openai-subagent`), and `readHeader` matches a full name, so any other +// spelling can never match. The other names read in this file (`originator`, +// `user-agent`, `x-client-request-id`, and the response's `x-oai-request-id`) +// are real too, from the shared default client and `codex-api`. +// @ref LLP 0151#real-header-names [constrained-by]: named constants so a +// fictional header name cannot be reintroduced by a typo. +const X_CODEX_TURN_METADATA = 'x-codex-turn-metadata' +const X_CODEX_WINDOW_ID = 'x-codex-window-id' +const X_CODEX_PARENT_THREAD_ID = 'x-codex-parent-thread-id' + /** * @param {AiGatewayExchangeInput} input * @param {string} provider * @param {string} path + * @param {Record} reqBody */ function resolveCodexContext(input, provider, path, reqBody) { - if (!isCodexExchange(input, provider, path)) return undefined - const metadata = readCodexTurnMetadata(input) + // @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`). + const transportIsCodex = hasCodexTransportSignal(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) + if (!transportIsCodex && clientMetadata === undefined) return undefined + const metadata = readCodexTurnMetadata(input, clientMetadata) const userAgent = readHeader(input.request_headers, 'user-agent') const client = codexClientFromUserAgent(userAgent) const workspace = selectCodexWorkspace( @@ -655,21 +690,26 @@ function resolveCodexContext(input, provider, path, reqBody) { ? workspaceInfo.associated_remote_urls : undefined const thread_id = firstString( + readStringKey(clientMetadata, 'thread_id'), readStringKey(metadata, 'thread_id'), - readHeader(input.request_headers, 'thread-id'), ) const session_id = firstString( + readStringKey(clientMetadata, 'session_id'), readStringKey(metadata, 'session_id'), - readHeader(input.request_headers, 'session-id'), ) - const turn_id = readStringKey(metadata, 'turn_id') + const turn_id = firstString( + readStringKey(clientMetadata, 'turn_id'), + readStringKey(metadata, 'turn_id'), + ) const thread_source = readStringKey(metadata, 'thread_source') - // Subagent lineage: the parent thread that spawned this one. Codex puts - // it in the same turn-metadata blob as thread_id (set for subagent - // turns; absent on the root thread). + // Subagent lineage: the parent thread that spawned this one. Set for subagent + // turns, absent on a root thread. Codex projects it onto all three surfaces + // under two different spellings: `x-codex-parent-thread-id` in the body map + // and as a header, `parent_thread_id` inside the turn-metadata blob. const parent_thread_id = firstString( + readStringKey(clientMetadata, X_CODEX_PARENT_THREAD_ID), readStringKey(metadata, 'parent_thread_id'), - readHeader(input.request_headers, 'parent-thread-id'), + readHeader(input.request_headers, X_CODEX_PARENT_THREAD_ID), ) const originator = firstString( readHeader(input.request_headers, 'originator'), @@ -677,7 +717,19 @@ function resolveCodexContext(input, provider, path, reqBody) { ) const sandbox = readStringKey(metadata, 'sandbox') const turn_started_at_unix_ms = numberValue(readKey(metadata, 'turn_started_at_unix_ms')) - const window_id = readHeader(input.request_headers, 'x-codex-window-id') + const window_id = firstString( + readHeader(input.request_headers, X_CODEX_WINDOW_ID), + readStringKey(clientMetadata, X_CODEX_WINDOW_ID), + ) + // Which surface stated the identity this row is keyed on. Recorded so a + // future Codex version that stops sending one of them is visible in a query + // instead of showing up as a silent drift in `conversation_id`. + // @ref LLP 0151#lineage-source [implements]: make version drift queryable. + const lineage_source = lineageSource(clientMetadata, metadata) + // The precedence above trusts the two surfaces to agree. Nothing here can + // verify that, so when they do not, say so on the row. + // @ref LLP 0151#lineage-conflict [implements]: the tie-break leaves evidence. + const lineage_conflict = lineageConflict(clientMetadata, metadata) // Strip any credential userinfo at ingress, before it reaches the first-class // `git_remote` field or the `attributes.codex.git_origin_url` mirror. // @ref LLP 0032#remote-redaction @@ -697,6 +749,8 @@ function resolveCodexContext(input, provider, path, reqBody) { setIfString(attributes, 'originator', originator) setIfString(attributes, 'window_id', window_id) setIfString(attributes, 'sandbox', sandbox) + setIfString(attributes, 'lineage_source', lineage_source) + setIfString(attributes, 'lineage_conflict', lineage_conflict) if (turn_started_at_unix_ms !== undefined) attributes.turn_started_at_unix_ms = turn_started_at_unix_ms setIfString(attributes, 'workspace', workspace?.path) setIfString(attributes, 'git_origin_url', git_origin_url) @@ -727,26 +781,150 @@ 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. + * + * Kept separate from the body signal because it is also what corroborates a + * `client_metadata` map carrying no Codex-owned key of its own. + * @ref LLP 0151#body-is-a-codex-signal [implements] + * * @param {AiGatewayExchangeInput} input * @param {string} provider * @param {string} path */ -function isCodexExchange(input, provider, path) { +function hasCodexTransportSignal(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 + if (readHeader(input.request_headers, X_CODEX_TURN_METADATA)) return true + if (readHeader(input.request_headers, X_CODEX_WINDOW_ID)) return true const userAgent = readHeader(input.request_headers, 'user-agent') return codexClientFromUserAgent(userAgent).entrypoint !== undefined } -/** @param {AiGatewayExchangeInput} input */ -function readCodexTurnMetadata(input) { - const raw = readHeader(input.request_headers, 'x-codex-turn-metadata') +/** + * The request body's flat `client_metadata` map: the surface Codex fills for + * every Responses request kind, so the one lineage surface always present. + * + * Declines a map carrying no Codex-owned key, so a `client_metadata` an + * unrelated client happens to send cannot masquerade as Codex lineage. An + * `x-codex-` prefixed key is Codex-exclusive and is accepted on its own; Codex + * writes `x-codex-installation-id` and `x-codex-window-id` into this map on + * every request, so that branch alone covers every request real Codex makes. + * + * The flat `session_id` + `thread_id` pair is NOT Codex-exclusive: those are + * ordinary names any agent framework may put in a `client_metadata` map, and the + * matched path set includes the generic `/v1/responses` and + * `/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. + * @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. + * + * @param {unknown} reqBody + * @param {boolean} corroborated Whether the transport (upstream, route, Codex + * header, or Codex user-agent) already identified this exchange as Codex. + * @returns {Record | undefined} + */ +function readCodexClientMetadata(reqBody, corroborated) { + const clientMetadata = readKey(reqBody, 'client_metadata') + if (!isPlainObject(clientMetadata)) return undefined + const hasCodexKey = Object.keys(clientMetadata) + .some((key) => key.toLowerCase().startsWith('x-codex-')) + if (hasCodexKey) return clientMetadata + if (!corroborated) return undefined + const hasFlatIdentity = readStringKey(clientMetadata, 'thread_id') !== undefined && + readStringKey(clientMetadata, 'session_id') !== undefined + return hasFlatIdentity ? clientMetadata : undefined +} + +/** + * The turn-metadata blob. Codex transports it twice per HTTP request: as the + * `x-codex-turn-metadata` header, and as the same-named string entry of the + * body's `client_metadata` map. The header is read first so already-recorded + * rows keep their exact identity (@ref LLP 0151#row-identity); the body entry + * is the fallback for a hop that dropped the header. Absent entirely for + * request kinds Codex marks as carrying no turn metadata, which is why the flat + * body keys, not this blob, are the lineage authority. + * + * @param {AiGatewayExchangeInput} input + * @param {Record | undefined} clientMetadata + */ +function readCodexTurnMetadata(input, clientMetadata) { + const raw = readHeader(input.request_headers, X_CODEX_TURN_METADATA) + ?? readStringKey(clientMetadata, X_CODEX_TURN_METADATA) const parsed = parseMaybeJson(raw) return isPlainObject(parsed) ? parsed : undefined } +// The lineage fields that both surfaces carry, as each surface spells them: the +// flat body-map key first, the turn-metadata blob key second. +const LINEAGE_SPELLINGS = [ + ['thread_id', 'thread_id'], + ['session_id', 'session_id'], + ['turn_id', 'turn_id'], + [X_CODEX_PARENT_THREAD_ID, 'parent_thread_id'], +] + +/** + * Name the surface that stated this row's identity, or `undefined` when no + * surface did (the row then keeps the gateway's content-hash fallback). + * + * The checks walk `thread_id` before `session_id` and body before blob, which is + * the same order the values above resolve in, so the recorded name is the + * surface the identity actually came from. Answering from "did the body state + * anything at all" would mislabel a turn whose `thread_id` (what + * `conversation_id` keys on) came from the blob while only its `session_id` came + * from the body. + * + * @param {Record | undefined} clientMetadata + * @param {Record | undefined} metadata + * @returns {'body_client_metadata' | 'turn_metadata' | undefined} + */ +function lineageSource(clientMetadata, metadata) { + for (const key of ['thread_id', 'session_id']) { + if (readStringKey(clientMetadata, key) !== undefined) return 'body_client_metadata' + if (readStringKey(metadata, key) !== undefined) return 'turn_metadata' + } + return undefined +} + +/** + * Name every lineage field the two surfaces state differently, comma-joined in + * turn-metadata spelling, or `undefined` when they agree or only one spoke. + * + * The precedence rests on Codex projecting one metadata snapshot onto both + * surfaces, so that a body value and a blob value for the same field are always + * equal. That is a claim about another program's internals which this code + * cannot check, and the body-wins tie-break would otherwise discard the + * counter-evidence without trace. Recording it makes a Codex version that began + * filling the two surfaces from different state a queryable fact rather than a + * silent preference. The row still keys on the body, so this adds a signal and + * changes no identity. + * @ref LLP 0151#lineage-conflict [implements]: an unverifiable agreement + * assumption gets a recorded signal. + * + * @param {Record | undefined} clientMetadata + * @param {Record | undefined} metadata + * @returns {string | undefined} + */ +function lineageConflict(clientMetadata, metadata) { + const disagreed = LINEAGE_SPELLINGS + .filter(([bodyKey, blobKey]) => { + const fromBody = readStringKey(clientMetadata, bodyKey) + const fromBlob = readStringKey(metadata, blobKey) + return fromBody !== undefined && fromBlob !== undefined && fromBody !== fromBlob + }) + .map(([, blobKey]) => blobKey) + return disagreed.length > 0 ? disagreed.join(',') : undefined +} + /** * @param {string | undefined} userAgent * @returns {{ entrypoint?: string, version?: string }} @@ -789,14 +967,12 @@ function selectCodexWorkspace(metadata, cwd) { * @param {ReturnType} codexContext */ function resolveConversationId(reqBody, input, provider, path, codexContext) { - if (codexContext) { - const codexConversationId = firstString( - codexContext.thread_id, - readHeader(input.request_headers, 'thread-id'), - readHeader(input.request_headers, 'session-id'), - ) - if (codexConversationId) return codexConversationId - } + // @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. + if (codexContext?.thread_id) return codexContext.thread_id const sessionId = readMetadataSessionId(reqBody) if (sessionId) return sessionId const messages = Array.isArray(reqBody.messages) diff --git a/hypaware-core/smoke/flows/gateway_codex_capture.js b/hypaware-core/smoke/flows/gateway_codex_capture.js index f49e0c57..a1832098 100644 --- a/hypaware-core/smoke/flows/gateway_codex_capture.js +++ b/hypaware-core/smoke/flows/gateway_codex_capture.js @@ -120,18 +120,26 @@ export async function run({ harness, expect }) { const codexThreadId = `thread-${harness.devRunId}` const codexSessionId = `session-${harness.devRunId}` 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. const responsesBody = JSON.stringify({ model: 'gpt-5-codex', input: [{ role: 'user', content: [{ type: 'input_text', text: 'help refactor' }] }], stream: true, + client_metadata: { + 'x-codex-installation-id': `install-${harness.devRunId}`, + session_id: codexSessionId, + thread_id: codexThreadId, + turn_id: codexTurnId, + 'x-codex-window-id': `window-${harness.devRunId}`, + }, }) const responsesResp = await postJson( `${gatewayUrl}/backend-api/codex/responses`, harness.devRunId, responsesBody, { - 'thread-id': codexThreadId, - 'session-id': codexSessionId, 'x-client-request-id': `client-request-${harness.devRunId}`, originator: 'Codex Desktop', 'user-agent': 'Codex Desktop/0.133.0-alpha.1', diff --git a/llp/0083-codex-live-cwd-from-rollout.decision.md b/llp/0083-codex-live-cwd-from-rollout.decision.md index cd5c3143..c9fe1290 100644 --- a/llp/0083-codex-live-cwd-from-rollout.decision.md +++ b/llp/0083-codex-live-cwd-from-rollout.decision.md @@ -5,7 +5,7 @@ **Systems:** Plugins, Gateway, Sources **Author:** Phil / Claude **Date:** 2026-07-07 -**Related:** LLP 0030, LLP 0032, LLP 0049, LLP 0050 +**Related:** LLP 0030, LLP 0032, LLP 0049, LLP 0050, LLP 0151 > The `@hypaware/codex` **live** exchange projector resolves an exchange's `cwd` > from the session's local rollout (`session_meta.cwd`) when the request carries @@ -28,10 +28,15 @@ failing **open**. That was a latent assumption. The **API-key** route (Responses API) happens to carry `cwd` in-band in `metadata`, so no enrichment was ever built. The **ChatGPT-subscription** route (`provider='chatgpt'`, `/backend-api/codex/*`) -has no such field, and `codex-tui` does not send the `x-codex-turn-metadata` -header on it (that is Codex Desktop behavior). So "cwd is always available at -projection time" was really "cwd is available when the client volunteers it" — -and for an entire first-class traffic class, it never did: +has no such field, and a turn whose request kind carries no turn metadata sends +no `x-codex-turn-metadata` and therefore no `workspaces`. (This paragraph +previously said `codex-tui` never sends that header and that it is Codex Desktop +behavior. That is false: Codex's `compatibility_headers` emits it for every turn +regardless of client. See +[LLP 0151](./0151-codex-lineage-from-body-client-metadata.decision.md#context).) +So "cwd is always available at projection time" was really "cwd is available when +the client volunteers it" - and for an entire first-class traffic class, it +often did not: - `.hypignore` was a silent **no-op** for subscription-mode Codex — the same gap class as raw-proxy/OTEL ([LLP 0049 §non-goals](./0049-hypignore-usage-policy.spec.md#non-goals)), @@ -58,10 +63,13 @@ Codex now has the symmetric fallback. - **In-band stays the fast path.** A fresh in-band `cwd` short-circuits before any filesystem work; the rollout is consulted **only** on a miss. -- **Keyed on the codex session id.** The live path already resolves it - (`session-id` header / turn metadata); the rollout filename embeds it, matched - via the `sessionIdFromPath` helper shared with the backfill. Only a real Codex - session has a rollout, so non-codex traffic never scans. +- **Keyed on the codex session id.** The live path already resolves it: the + body's `client_metadata.session_id`, else the turn-metadata blob + ([LLP 0151](./0151-codex-lineage-from-body-client-metadata.decision.md#body-is-authority); + it was never a `session-id` header, a name Codex does not emit). The rollout + filename embeds it, matched via the `sessionIdFromPath` helper shared with the + backfill. Only a real Codex session has a rollout, so non-codex traffic never + scans. - **First line only, cached per session 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/0141-codex-desktop-rides-the-codex-adapter.decision.md b/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md index 30884e11..58c178f4 100644 --- a/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md +++ b/llp/0141-codex-desktop-rides-the-codex-adapter.decision.md @@ -5,7 +5,7 @@ **Systems:** Plugins, Sources, Onboarding **Author:** Kenny / Claude **Date:** 2026-07-28 -**Related:** LLP 0012, LLP 0083, LLP 0115, LLP 0130, LLP 0133 +**Related:** LLP 0012, LLP 0083, LLP 0115, LLP 0130, LLP 0133, LLP 0151 > Names a coverage fact that was already true in code and nowhere in the > product surface. Nothing about capture changes here; the picker copy, the @@ -21,10 +21,12 @@ routes, neither of which was named anywhere a user looks: setting and a `[model_providers.hypaware]` table into `$CODEX_HOME/config.toml` (default `~/.codex/config.toml`). The Codex CLI and Codex Desktop read that same file, so one attach routes both through - the local gateway. The live exchange projector already treats the - `x-codex-turn-metadata` header as a sufficient match signal precisely - because it is Desktop that sends it - ([LLP 0083](./0083-codex-live-cwd-from-rollout.decision.md)). + the local gateway. The live exchange projector treats the + `x-codex-turn-metadata` header as a sufficient match signal because any + Codex client sends it on a turn that carries turn metadata - Desktop + included. (This bullet previously said Desktop is what sends it; that is + false, and nothing in the coverage claim depends on it. See + [LLP 0151](./0151-codex-lineage-from-body-client-metadata.decision.md#real-header-names).) 2. **Backfill.** The rollout tree under `$CODEX_HOME/sessions/**` is written by both surfaces too. That half rests on the provider's long-standing assumption (`codex/src/backfill.js`) and on smoke fixtures that synthesize diff --git a/llp/0151-codex-lineage-from-body-client-metadata.decision.md b/llp/0151-codex-lineage-from-body-client-metadata.decision.md new file mode 100644 index 00000000..a72dd9e6 --- /dev/null +++ b/llp/0151-codex-lineage-from-body-client-metadata.decision.md @@ -0,0 +1,215 @@ +# LLP 0151: Codex lineage reads the body's `client_metadata`, not header names + +**Type:** Decision +**Status:** Active +**Systems:** Plugins, Sources, Gateway +**Author:** Claude +**Date:** 2026-07-29 +**Related:** LLP 0030, LLP 0049, LLP 0050, LLP 0066, LLP 0083, LLP 0141 + +> 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. This names the precedence +> (body map, then turn-metadata blob, then the real compatibility headers), the +> header-name audit, and what happens to rows already recorded. + +## Context + +`resolveCodexContext` in +[`exchange-projector.js`](../hypaware-core/plugins-workspace/codex/src/exchange-projector.js) +resolved identity from the `x-codex-turn-metadata` header plus a set of bare +header names. Read against Codex's own source +(`codex-rs/core/src/responses_metadata.rs`, `codex-rs/core/src/client.rs`, +`codex-rs/codex-api/src/common.rs`), Codex projects one snapshot, +`CodexResponsesMetadata`, onto **three** surfaces per HTTP request: + +| surface | what it carries | when | +| --- | --- | --- | +| body `client_metadata` (flat `string -> string` map, a 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`, `parent_turn_id`, `x-openai-subagent` when set; and the whole turn-metadata blob under `x-codex-turn-metadata` | **every** request | +| `x-codex-turn-metadata` (header, and the same-named body entry) | the nested blob: `thread_source`, `sandbox`, `workspaces`, `turn_started_at_unix_ms`, `parent_thread_id`, `forked_from_thread_id` | only when the request kind carries turn metadata; its `session_id`/`thread_id` are omitted for the kinds Codex marks as having 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 consequences the projector was on the wrong side of: + +1. **The flat body map is the only surface present for every request.** Codex + builds it unconditionally in `client_metadata()`. HypAware never read the + body at all, 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 not names any Codex version emits; + `compatibility_headers` never produces them and the projector matches a full + header name. They could never supply a right value, and could supply a wrong + one: any hop or hand-rolled client that happened to set `thread-id` dictated + `conversation_id`, the value the row's fallback `message_id` is scoped on + ([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. + +The premise that `x-codex-turn-metadata` is a Codex Desktop signal +([LLP 0083](./0083-codex-live-cwd-from-rollout.decision.md#context), +[LLP 0141](./0141-codex-desktop-rides-the-codex-adapter.decision.md)) is also +false: `compatibility_headers` emits it for every turn regardless of client. +Both docs are corrected alongside this one. + +This is the same defect class as the `.hypignore` cwd gap +([LLP 0083](./0083-codex-live-cwd-from-rollout.decision.md)) one layer up: +"identity is available at projection time" was really "identity is available +when the client volunteers it on a version-specific surface". + +## Decision + +**The body's flat `client_metadata` map is the +lineage authority; the turn-metadata blob is the fallback.** `thread_id`, +`session_id`, `turn_id` and the parent thread resolve body-map-first, +blob-second. The two are projections of one snapshot, so they cannot disagree in +real traffic; the body wins because it is the surface that is always there. The +map is only trusted when it carries a Codex-owned `x-codex-*` entry, or when the +transport has already identified the client as Codex, so a `client_metadata` from +an unrelated client cannot masquerade as Codex lineage +([#body-is-a-codex-signal](#body-is-a-codex-signal)). + +The turn-metadata **blob** is still read from the header first and from the body +map second. Both spellings of the blob are byte-equal but for Code Mode tool +names, and header-first is what keeps already-recorded rows bit-identical +([#row-identity](#row-identity)). + +**A Codex-owned `client_metadata` map is +itself sufficient evidence that an exchange is Codex.** The API-key route posts +a generic `/v1/responses` and can carry no Codex-namespaced header, so gating +codex-context resolution on headers would have left the body unread exactly +where it matters most. + +**The Codex-owned key is what makes the map evidence. The flat identity pair is +not.** Only an `x-codex-*` prefixed entry names the client. `session_id` and +`thread_id` are ordinary names any agent framework may write into a +`client_metadata` map, and the projector's matched path set includes the fully +generic `/v1/responses` and `/v1/chat/completions`, which every +OpenAI-compatible client posts to. Accepting the bare pair as evidence would +therefore stamp an unrelated client `client_name: 'codex'` and let it dictate +this row's `conversation_id` and `session_id`, the latter being the partition key +([LLP 0030](./0030-session-id-partition-key.decision.md)). That is the same +defect class as the fictional `thread-id` header +([#real-header-names](#real-header-names)), reached through the body instead of a +header, and in a capture product a misfiled client is a privacy question and not +a cosmetic one. + +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 +`codex`-prefixed user-agent product (`hasCodexTransportSignal`). Real Codex +loses nothing, because `client_metadata` carries `x-codex-installation-id` and +`x-codex-window-id` on every request (the table in [#context](#context)), so the +strict branch alone already covers every request Codex is known to make. The +corroborated pair is the fallback for a build that stopped writing them, which is +exactly the version drift this document is guarding against, so it is kept rather +than deleted. + +The outer `match` gate deliberately still reads only the path and the +`x-codex-turn-metadata` header, never the body, so it and the body signal agree +only because the matched path set covers every route Codex posts to. That +covering assumption is pinned by a test rather than removed by teaching `match` +to read the body, which would widen the projector's claim over arbitrary paths +for no request Codex is known to make. + +**Only header names Codex actually emits are +read.** The audit of every header this file reads: + +| name read | real? | source | +| --- | --- | --- | +| `x-codex-turn-metadata` | yes | `compatibility_headers` | +| `x-codex-window-id` | yes | `compatibility_headers` | +| `x-codex-parent-thread-id` | yes | `compatibility_headers` (was misspelled `parent-thread-id`) | +| `originator` | yes | `add_originator_header` | +| `user-agent` | yes | the shared default client | +| `x-client-request-id` | yes | `codex-api`'s responses endpoint | +| `x-oai-request-id` (response) | yes | the service | +| ~~`thread-id`~~, ~~`session-id`~~, ~~`parent-thread-id`~~ | **no** | nothing emits them; removed | + +`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 +decision from where lineage is read. `forked_from_thread_id`, likewise present +in the blob and unread, would need a column. + +**The surface that stated the identity is recorded** +as `attributes.codex.lineage_source` (`body_client_metadata` | +`turn_metadata`, absent when nothing stated one). A future Codex version that +stops filling a surface then shows up as a queryable shift rather than as a +silent drift in `conversation_id`. It names the surface the identity actually +came from, resolved in the same order as the values themselves (`thread_id` +before `session_id`, body before blob), so a turn whose `thread_id` came from +the blob is not labelled `body_client_metadata` merely because its `session_id` +came from the body. + +**A disagreement between the two surfaces is +recorded, not just resolved.** [#body-is-authority](#body-is-authority) rests on +Codex projecting one snapshot onto both surfaces, so equal values whenever both +are present. HypAware cannot verify a claim about another program's internals, +and the body-wins tie-break would otherwise discard the counter-evidence without +trace: the row would look exactly like a row whose surfaces agreed. So when a +lineage field (`thread_id`, `session_id`, `turn_id`, `parent_thread_id`) is +stated differently by the body map and the turn-metadata blob, the disagreeing +field names are recorded as `attributes.codex.lineage_conflict`. + +The row still keys on the body, so this is a signal and not a second +precedence: nothing about row identity depends on it, and the attribute is +absent for every agreeing turn, which is all of them in traffic HypAware has +seen. Its value is diagnostic. A nonzero count for +`attributes.codex.lineage_conflict` is the evidence that would retire the +agreement assumption, and until one appears the assumption is being checked +continuously rather than asserted once in this document. + +**Already-recorded rows are left alone. No backfill.** +Nothing re-keys, because for every shape HypAware already resolved an identity +from, the new precedence returns the same string: + +- The blob's `thread_id` and the body map's `thread_id` are the same field of + the same snapshot, so a row that resolved a thread from the blob resolves the + identical thread from the body. +- The three removed header names never matched real Codex traffic, so no + recorded Codex row was keyed on them. +- `conversation_id` therefore does not move for existing shapes, and neither do + the `message_id` / `part_id` values scoped on it + ([LLP 0030](./0030-session-id-partition-key.decision.md)). A test pins the + literal ids captured from the pre-change projector. + +What **does** change is coverage: turns that stated identity only in the body +were previously keyed on the gateway's content hash, and are now keyed on the +real thread. Those rows are not rewritten. They were never joinable to a thread +in the first place, so leaving them costs nothing a backfill would recover, and +a backfill would have to re-key rows the partition spec clusters on +(LLP 0030 §Breaking) for no query anyone can express today. If that history is +wanted, `hyp backfill codex` already re-imports the same conversations from the +rollout tree, keyed on the rollout's session id. + +## Consequences + +- `.hypignore` coverage improves for real traffic: the rollout-cwd fallback + ([LLP 0083](./0083-codex-live-cwd-from-rollout.decision.md)) is keyed on the + Codex session id, which on the subscription route previously had to come from + a header Codex never sends. It now comes from the body. +- Session opt-out ([LLP 0066](./0066-session-opt-out.spec.md)) keys on the same + stamped `session_id`, so it gains the same coverage. +- `parent_thread_id` and `is_sidechain` stop depending on the blob alone for the + parent id; `is_sidechain` still derives only from the blob's `thread_source`, + so a turn with no blob records a parent with `is_sidechain` unset. Tightening + that needs the `x-openai-subagent` decision above. +- The Codex-source facts here are a snapshot of an upstream HypAware does not + control. The mitigation is `lineage_source` and `lineage_conflict` + ([#lineage-conflict](#lineage-conflict)) plus the acceptance check in + [`docs/ACCEPTANCE.md`](../docs/ACCEPTANCE.md), not a pinned literal. + +## References + +- Code: `hypaware-core/plugins-workspace/codex/src/exchange-projector.js` + (`readCodexClientMetadata`, `readCodexTurnMetadata`, `resolveCodexContext`, + `resolveConversationId`, `hasCodexTransportSignal`, `lineageSource`, + `lineageConflict`). +- Tests: `test/plugins/codex-exchange-projector.test.js` (lineage surfaces), + `test/plugins/codex-rollout-cwd.test.js` (subscription-route fixtures). +- Fixture: `hypaware-core/smoke/flows/gateway_codex_capture.js`. +- [LLP 0030](./0030-session-id-partition-key.decision.md) - `session_id` is the + partition key; `conversation_id ?? session_id` is the fallback-hash scope. +- [LLP 0083](./0083-codex-live-cwd-from-rollout.decision.md) - the cwd half of + the same "only when the client volunteers it" assumption. +- [LLP 0141](./0141-codex-desktop-rides-the-codex-adapter.decision.md) - one + Codex adapter covers CLI and Desktop; its turn-metadata premise is corrected. diff --git a/test/plugins/codex-exchange-projector.test.js b/test/plugins/codex-exchange-projector.test.js index f08ea6cd..9592772e 100644 --- a/test/plugins/codex-exchange-projector.test.js +++ b/test/plugins/codex-exchange-projector.test.js @@ -1109,6 +1109,405 @@ test('conversation_id falls back to a stable hash when no codex metadata or sess assert.equal(projection.conversation_id, repeat.conversation_id) }) +// --------------------------------------------------------------------- +// Lineage surfaces (LLP 0151) +// --------------------------------------------------------------------- + +// @ref LLP 0151#body-is-authority [tests]: the flat body `client_metadata` map +// is the only lineage surface Codex fills for every request kind, so a turn +// that carries no Codex header at all must still resolve its thread, session, +// turn and parent thread. +test('Codex lineage resolves from the durable body client_metadata when no lineage header is sent', () => { + const projector = createCodexExchangeProjector() + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/backend-api/codex/responses', + provider: 'chatgpt', + request_headers: JSON.stringify({}), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'go', + client_metadata: { + 'x-codex-installation-id': 'install-body', + session_id: 'session-body', + thread_id: 'thread-body', + turn_id: 'turn-body', + 'x-codex-window-id': 'window-body', + 'x-codex-parent-thread-id': 'thread-body-parent', + }, + }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + assert.equal(projection.conversation_id, 'thread-body') + assert.equal(projection.session_id, 'session-body') + assert.equal(projection.parent_thread_id, 'thread-body-parent') + assert.equal(projection.prompt_id, 'turn-body') + assert.equal(projection.attributes.codex.thread_id, 'thread-body') + assert.equal(projection.attributes.codex.session_id, 'session-body') + assert.equal(projection.attributes.codex.window_id, 'window-body') + assert.equal(projection.attributes.codex.lineage_source, 'body_client_metadata') +}) + +// @ref LLP 0151#body-is-a-codex-signal [tests]: the API-key route posts to a +// generic `/v1/responses` with no Codex-namespaced header, so the body map is +// also what identifies the exchange as Codex at all. +test('body client_metadata alone identifies a Codex exchange on a generic responses path', () => { + const projector = createCodexExchangeProjector() + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/v1/responses', + request_headers: JSON.stringify({}), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'go', + client_metadata: { + 'x-codex-installation-id': 'install-api', + session_id: 'session-api', + thread_id: 'thread-api', + 'x-codex-window-id': 'window-api', + 'x-codex-turn-metadata': JSON.stringify({ + session_id: 'session-api', + thread_id: 'thread-api', + thread_source: 'subagent', + parent_thread_id: 'thread-api-parent', + sandbox: 'workspace-write', + workspaces: { '/work/api': {} }, + }), + }, + }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + assert.equal(projection.client_name, 'codex') + assert.equal(projection.conversation_id, 'thread-api') + assert.equal(projection.session_id, 'session-api') + // The turn-metadata blob also rides in the body map, so everything it + // carries (thread_source, sandbox, workspaces) resolves without a header. + assert.equal(projection.user_type, 'subagent') + assert.equal(projection.is_sidechain, true) + assert.equal(projection.parent_thread_id, 'thread-api-parent') + assert.equal(projection.permission_mode, 'workspace-write') + assert.equal(projection.cwd, '/work/api') +}) + +// @ref LLP 0151#real-header-names [tests]: the compatibility headers Codex +// really emits keep working, including `x-codex-parent-thread-id` (the name the +// projector previously got wrong). +test('Codex lineage resolves from the compatibility headers Codex actually sends', () => { + const projector = createCodexExchangeProjector() + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/backend-api/codex/responses', + provider: 'chatgpt', + request_headers: JSON.stringify({ + 'x-codex-window-id': 'window-hdr', + 'x-codex-parent-thread-id': 'thread-hdr-parent', + 'x-openai-subagent': 'collab_spawn', + 'x-codex-turn-metadata': JSON.stringify({ + session_id: 'session-hdr', + thread_id: 'thread-hdr', + turn_id: 'turn-hdr', + thread_source: 'subagent', + workspaces: { '/work/hdr': {} }, + }), + }), + request_body: JSON.stringify({ model: 'gpt-5-codex', input: 'go' }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + assert.equal(projection.conversation_id, 'thread-hdr') + assert.equal(projection.session_id, 'session-hdr') + assert.equal(projection.prompt_id, 'turn-hdr') + assert.equal(projection.is_sidechain, true) + assert.equal(projection.parent_thread_id, 'thread-hdr-parent') + assert.equal(projection.attributes.codex.window_id, 'window-hdr') + 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. +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({ + path: '/backend-api/codex/responses', + provider: 'chatgpt', + request_headers: JSON.stringify({ + 'thread-id': 'phantom-thread', + 'session-id': 'phantom-session', + 'parent-thread-id': 'phantom-parent', + }), + request_body: JSON.stringify({ model: 'gpt-5-codex', input: 'go' }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + assert.equal(projection.attributes.codex.thread_id, undefined) + assert.equal(projection.attributes.codex.session_id, undefined) + assert.equal(projection.parent_thread_id, undefined) + assert.equal(projection.attributes.codex.lineage_source, undefined) + // No lineage was stated, so the row keeps the content-hash fallback identity + // rather than adopting an id nothing in Codex produced. + assert.equal(projection.conversation_id.length, 16) + assert.notEqual(projection.conversation_id, 'phantom-thread') + assert.equal(projection.session_id, projection.conversation_id) +}) + +// @ref LLP 0151#body-is-authority [tests]: body and blob are two projections of +// one Codex snapshot and cannot disagree in real traffic; pin which one wins so +// the tie-break is a decision rather than an accident of argument order. +test('body client_metadata wins over the turn-metadata blob when the two disagree', () => { + const projector = createCodexExchangeProjector() + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/backend-api/codex/responses', + provider: 'chatgpt', + request_headers: JSON.stringify({ + 'x-codex-turn-metadata': JSON.stringify({ + session_id: 'session-blob', + thread_id: 'thread-blob', + workspaces: { '/work/blob': {} }, + }), + }), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'go', + client_metadata: { session_id: 'session-body', thread_id: 'thread-body' }, + }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + assert.equal(projection.conversation_id, 'thread-body') + assert.equal(projection.session_id, 'session-body') + assert.equal(projection.attributes.codex.lineage_source, 'body_client_metadata') + // @ref LLP 0151#lineage-conflict [tests]: the tie-break leaves evidence, so + // the disagreement the body silently won is on the row and countable. + assert.equal(projection.attributes.codex.lineage_conflict, 'thread_id,session_id') +}) + +// @ref LLP 0151#lineage-conflict [tests]: the signal must be absent, not merely +// falsy, for the agreeing traffic that is every turn Codex is known to send, or +// a nonzero conflict count stops being evidence of anything. +test('agreeing lineage surfaces record no lineage_conflict', () => { + const projector = createCodexExchangeProjector() + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/backend-api/codex/responses', + provider: 'chatgpt', + request_headers: JSON.stringify({ + 'x-codex-turn-metadata': JSON.stringify({ + session_id: 'session-agree', + thread_id: 'thread-agree', + turn_id: 'turn-agree', + parent_thread_id: 'parent-agree', + }), + }), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'go', + client_metadata: { + session_id: 'session-agree', + thread_id: 'thread-agree', + turn_id: 'turn-agree', + 'x-codex-parent-thread-id': 'parent-agree', + }, + }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + assert.equal(projection.conversation_id, 'thread-agree') + assert.equal(projection.attributes.codex.lineage_source, 'body_client_metadata') + assert.ok(!('lineage_conflict' in projection.attributes.codex)) +}) + +// @ref LLP 0151#lineage-conflict [tests]: only a real disagreement counts. A +// field one surface omits is the normal per-request-kind shape, not a conflict. +test('a lineage field only one surface states is not a conflict', () => { + const projector = createCodexExchangeProjector() + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/backend-api/codex/responses', + provider: 'chatgpt', + request_headers: JSON.stringify({ + 'x-codex-turn-metadata': JSON.stringify({ thread_source: 'user' }), + }), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'go', + client_metadata: { session_id: 'session-solo', thread_id: 'thread-solo' }, + }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + assert.equal(projection.conversation_id, 'thread-solo') + assert.ok(!('lineage_conflict' in projection.attributes.codex)) +}) + +// @ref LLP 0151#lineage-source [tests]: the recorded name is the surface the +// identity came from. Here the body states only `session_id`, so `thread_id`, +// which is what `conversation_id` keys on, comes from the blob. +test('lineage_source names the surface the thread actually came from', () => { + const projector = createCodexExchangeProjector() + const projection = /** @type {any} */ (projector.project(exchange({ + path: '/backend-api/codex/responses', + provider: 'chatgpt', + request_headers: JSON.stringify({ + 'x-codex-turn-metadata': JSON.stringify({ thread_id: 'thread-from-blob' }), + }), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'go', + client_metadata: { + 'x-codex-installation-id': 'install-mixed', + session_id: 'session-from-body', + }, + }), + response_body: JSON.stringify({ output_text: 'done' }), + }), context())) + + assert.equal(projection.conversation_id, 'thread-from-blob') + assert.equal(projection.attributes.codex.session_id, 'session-from-body') + assert.equal(projection.attributes.codex.lineage_source, 'turn_metadata') + assert.ok(!('lineage_conflict' in projection.attributes.codex)) +}) + +// @ref LLP 0151#body-is-a-codex-signal [tests]: a flat `session_id` + +// `thread_id` pair is not a Codex-exclusive shape, and `/v1/responses` and +// `/v1/chat/completions` are generic matched paths that any OpenAI-compatible +// client posts to. Honouring the pair as evidence of Codex would reopen through +// the body exactly what removing the `thread-id` header closed: an unrelated +// client stamped `client_name: 'codex'` and dictating this row's +// `conversation_id` and `session_id` (the partition key, LLP 0030). So the row +// must come out exactly as if the map had not been sent at all. +test('a non-Codex client sending only a flat client_metadata identity pair is not treated as Codex', () => { + const projector = createCodexExchangeProjector() + const flatPair = { session_id: 'foreign-session', thread_id: 'foreign-thread' } + const shapes = [ + { + path: '/v1/responses', + body: { model: 'gpt-5', input: 'go' }, + response_body: JSON.stringify({ output_text: 'done' }), + }, + { + path: '/v1/chat/completions', + body: { model: 'gpt-4o', messages: [{ role: 'user', content: 'hi' }] }, + response_body: JSON.stringify({ choices: [{ message: { role: 'assistant', content: 'ok' } }] }), + }, + ] + for (const shape of shapes) { + /** @param {Record} body */ + const project = (body) => /** @type {any} */ (projector.project(exchange({ + path: shape.path, + // A user-agent no Codex build produces, and no Codex-namespaced header. + request_headers: JSON.stringify({ 'user-agent': 'some-agent-framework/2.1' }), + request_body: JSON.stringify(body), + response_body: shape.response_body, + }), context())) + + const projection = project({ ...shape.body, client_metadata: flatPair }) + assert.equal(projection.client_name, undefined, `${shape.path}: must not be stamped codex`) + assert.notEqual(projection.conversation_id, 'foreign-thread') + assert.notEqual(projection.session_id, 'foreign-session') + // `identity_source` is stamped for every row; no lineage attribute is. + 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 ambiguous map contributes nothing, so the row is + // byte-identical to the same request without it. + const control = project(shape.body) + assert.equal(projection.conversation_id, control.conversation_id) + assert.equal(projection.session_id, control.session_id) + } +}) + +// @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', () => { + const projector = createCodexExchangeProjector() + const projection = /** @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', + client_metadata: { session_id: 'session-ua', thread_id: 'thread-ua' }, + }), + 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.attributes.codex.lineage_source, 'body_client_metadata') +}) + +// @ref LLP 0151#body-is-a-codex-signal [tests]: `match` gates on the path (plus +// the turn-metadata header) and never reads the body, while codex-context +// resolution accepts a Codex-owned body map on its own. The two only stay +// consistent because the matched path set covers every route Codex posts to: a +// body-only Codex request on an unmatched path would be rejected at the gate +// before the body was read. Pin that covering assumption rather than widen +// `match` on a hypothetical, so a Codex route the set does not cover fails here +// instead of silently going unrecorded. +test('every route Codex posts to is matched, so a body-only Codex request is never dropped at the gate', () => { + const projector = createCodexExchangeProjector() + // The ChatGPT-subscription namespace and the API-key Responses path. + for (const path of ['/backend-api/codex/responses', '/v1/responses']) { + const input = exchange({ + path, + request_headers: JSON.stringify({}), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'go', + client_metadata: { + 'x-codex-installation-id': 'install-gate', + session_id: 'session-gate', + thread_id: 'thread-gate', + }, + }), + response_body: JSON.stringify({ output_text: 'done' }), + }) + assert.equal(projector.match(input), true, `${path} must pass the match gate`) + const projection = /** @type {any} */ (projector.project(input, context())) + assert.equal(projection.client_name, 'codex', `${path} must resolve a codex context`) + assert.equal(projection.conversation_id, 'thread-gate') + } +}) + +// @ref LLP 0151#row-identity [tests]: already-recorded shapes must not re-key. +// These literals were captured from the pre-change projector, so a drift in +// `conversation_id` resolution for a shape HypAware already recorded shows up +// here as a changed `message_id` / `part_id`. +test('part_id and message_id stay byte-identical for the turn-metadata shape already recorded', async () => { + const projector = createCodexExchangeProjector() + const dispatcher = createAiGatewayMessageProjector({ + gatewayId: 'gw-test', + projectors: [{ ...projector, _seq: 0 }], + }) + const rows = /** @type {any[]} */ (await dispatcher.projectExchange(exchange({ + path: '/backend-api/codex/responses', + provider: 'chatgpt', + request_headers: JSON.stringify({ + 'x-codex-window-id': 'window-identity', + 'x-codex-turn-metadata': JSON.stringify({ + session_id: 'session-identity', + thread_id: 'thread-identity', + turn_id: 'turn-identity', + thread_source: 'user', + workspaces: { '/w': {} }, + }), + }), + request_body: JSON.stringify({ model: 'gpt-5-codex', input: 'go' }), + response_body: JSON.stringify({ output_text: 'done' }), + }))) + + assert.deepEqual( + rows.map((r) => ({ role: r.role, session_id: r.session_id, conversation_id: r.conversation_id, message_id: r.message_id, part_id: r.part_id })), + [ + { role: 'user', session_id: 'session-identity', conversation_id: 'thread-identity', message_id: 'e1a2ff876074693f', part_id: 'e1a2ff876074693f#0' }, + { role: 'assistant', session_id: 'session-identity', conversation_id: 'thread-identity', message_id: '179fd16763044acd', part_id: '179fd16763044acd#0' }, + ] + ) +}) + // --------------------------------------------------------------------- // Fixtures // --------------------------------------------------------------------- diff --git a/test/plugins/codex-rollout-cwd.test.js b/test/plugins/codex-rollout-cwd.test.js index bf8cb041..1a510007 100644 --- a/test/plugins/codex-rollout-cwd.test.js +++ b/test/plugins/codex-rollout-cwd.test.js @@ -43,6 +43,23 @@ function fakeRolloutCwd(bySession) { // A realistic subscription-route session id: a UUID the rollout filename embeds. const SUBSCRIPTION_SESSION_ID = '019e60b5-1111-4222-8333-444455556666' +const SUBSCRIPTION_THREAD_ID = '019e60b5-9999-4aaa-8bbb-ccccddddeeee' + +/** + * The body's flat `client_metadata` map as Codex writes it on a turn that states + * its identity but no workspace: session and thread present, no cwd anywhere. + * That is the shape these tests need, because the rollout fallback only runs + * when the request states a session 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. + */ +function subscriptionClientMetadata() { + return { + 'x-codex-installation-id': 'install-sub', + session_id: SUBSCRIPTION_SESSION_ID, + thread_id: SUBSCRIPTION_THREAD_ID, + } +} // --------------------------------------------------------------------- // Regression (#257): the ChatGPT-subscription route carries no in-band cwd, so @@ -59,10 +76,14 @@ test('subscription-route Codex with no in-band cwd is .hypignore-dropped via the const projection = projector.project(exchange({ path: '/backend-api/codex/responses', provider: 'chatgpt', - // codex-tui does NOT send x-codex-turn-metadata on the subscription route; - // it does carry a session-id header, which the adapter already resolves. - request_headers: JSON.stringify({ 'session-id': SUBSCRIPTION_SESSION_ID }), - request_body: JSON.stringify({ model: 'gpt-5-codex', input: 'secret work' }), + // A turn that states its session but no workspace: the flat body + // `client_metadata` map carries the session id, nothing carries a cwd. + request_headers: JSON.stringify({}), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'secret work', + client_metadata: subscriptionClientMetadata(), + }), response_body: JSON.stringify({ output_text: 'ok' }), }), context()) // The rollout cwd (`/work/ignored/proj`) is covered by `/work/ignored/.hypignore`, @@ -78,8 +99,12 @@ test('subscription-route Codex records the rollout cwd on the row (live/backfill const projection = /** @type {any} */ (projector.project(exchange({ path: '/backend-api/codex/responses', provider: 'chatgpt', - request_headers: JSON.stringify({ 'session-id': SUBSCRIPTION_SESSION_ID }), - request_body: JSON.stringify({ model: 'gpt-5-codex', input: 'hello' }), + request_headers: JSON.stringify({}), + request_body: JSON.stringify({ + model: 'gpt-5-codex', + input: 'hello', + client_metadata: subscriptionClientMetadata(), + }), response_body: JSON.stringify({ output_text: 'hi' }), }), context())) assert.ok(projection && projection !== USAGE_POLICY_DROP) @@ -102,7 +127,6 @@ test('an in-band cwd stays the fast path and short-circuits the rollout lookup', path: '/backend-api/codex/responses', provider: 'chatgpt', request_headers: JSON.stringify({ - 'session-id': SUBSCRIPTION_SESSION_ID, 'x-codex-turn-metadata': JSON.stringify({ session_id: SUBSCRIPTION_SESSION_ID, workspaces: { '/work/in-band': {} },