From 98a417bf951a6f6986c89027605dc9a67feae88b Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 7 Aug 2026 00:57:50 -0700 Subject: [PATCH 1/2] Add failing coverage for cross-provider signature replay A signature tagged as belonging to a different provider than the current request must never be replayed, even when the historical turn's bare model string happens to match; a second account on the same provider must still replay. The adapter has no way to express provider-tagged signatures yet, so these cases fail to even compile. --- tests/unit/codex-responses-adapter.test.ts | 58 ++++++++++++++++++++-- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/tests/unit/codex-responses-adapter.test.ts b/tests/unit/codex-responses-adapter.test.ts index 4ca6ea417..3c25c41d8 100644 --- a/tests/unit/codex-responses-adapter.test.ts +++ b/tests/unit/codex-responses-adapter.test.ts @@ -1,9 +1,12 @@ import { test, expect, describe } from "bun:test"; import { createCodexResponsesAdapter, + tagSignature, CODEX_ACCOUNT_ID_OPTION, CODEX_SESSION_ID_OPTION, + CODEX_RESPONSES_PROVIDER, } from "../../src/provider/codex-responses-adapter.js"; +import { GROK_RESPONSES_PROVIDER } from "../../src/provider/grok-responses-adapter.js"; import { BEARER_CREDENTIAL_SENTINEL } from "@intx/inference"; import type { ConversationTurn, InferenceOptions, LastCycleSource } from "@intx/types/runtime"; @@ -113,7 +116,7 @@ describe("codex-responses buildRequest", () => { model: "gpt-5-codex", timestamp: 0, content: [ - { type: "thinking", thinking: "internal steps...", signature: "ENC_BLOB_123" }, + { type: "thinking", thinking: "internal steps...", signature: tagSignature(CODEX_RESPONSES_PROVIDER, "ENC_BLOB_123") }, { type: "text", text: "The answer is 42." }, ], }, @@ -126,6 +129,28 @@ describe("codex-responses buildRequest", () => { ]); }); + test("a second account on the same provider still replays the signature", () => { + // codex/personal and codex/work are two ChatGPT accounts routed through the + // same Codex backend (same provider, different InferenceSource.id). The + // backend can decrypt a signature issued to either account, so a live + // account switch must not poison reasoning continuity. + const turns: ConversationTurn[] = [ + userTurn("solve the hard problem"), + { + role: "assistant", + model: "gpt-5-codex", + timestamp: 0, + content: [ + { type: "thinking", thinking: "internal steps...", signature: tagSignature(CODEX_RESPONSES_PROVIDER, "ENC_BLOB_123") }, + { type: "text", text: "The answer is 42." }, + ], + }, + ]; + const workAdapter = createCodexResponsesAdapter({ sourceId: "codex/work", provider: "codex-responses", model: "gpt-5-codex" }); + const body = JSON.parse(workAdapter.buildRequest(turns, "gpt-5-codex", baseOptions).body) as Record; + expect(body["input"]).toContainEqual({ type: "reasoning", summary: [], encrypted_content: "ENC_BLOB_123" }); + }); + test("drops a reasoning signature issued for a different model after a provider switch", () => { // The signature was minted by grok-4.5; the request now targets a Codex // model. Replaying it would 400 with an undecryptable-content error, so @@ -137,7 +162,31 @@ describe("codex-responses buildRequest", () => { model: "grok-4.5", timestamp: 0, content: [ - { type: "thinking", thinking: "internal steps...", signature: "FOREIGN_BLOB" }, + { type: "thinking", thinking: "internal steps...", signature: tagSignature(GROK_RESPONSES_PROVIDER, "FOREIGN_BLOB") }, + { type: "text", text: "The answer is 42." }, + ], + }, + ]; + const body = JSON.parse(adapter().buildRequest(turns, "gpt-5-codex", baseOptions).body) as Record; + expect(body["input"]).toEqual([ + { type: "message", role: "user", content: [{ type: "input_text", text: "solve the hard problem" }] }, + { type: "message", role: "assistant", content: [{ type: "output_text", text: "The answer is 42." }] }, + ]); + }); + + test("drops a reasoning signature issued by a different provider even when the model string matches", () => { + // Two distinct backends (e.g. proxy aliases) can declare the identical + // literal model name. Nothing but the tagged provider on the signature + // itself distinguishes them, since InferenceSource.model is arbitrary + // catalog text and turn.model alone cannot tell them apart. + const turns: ConversationTurn[] = [ + userTurn("solve the hard problem"), + { + role: "assistant", + model: "gpt-5-codex", + timestamp: 0, + content: [ + { type: "thinking", thinking: "internal steps...", signature: tagSignature(GROK_RESPONSES_PROVIDER, "FOREIGN_BLOB") }, { type: "text", text: "The answer is 42." }, ], }, @@ -156,7 +205,10 @@ describe("codex-responses buildRequest", () => { role: "assistant", model: "grok-4.5", timestamp: 0, - content: [{ type: "thinking", thinking: "...", signature: "FOREIGN_BLOB" }, { type: "text", text: "ok" }], + content: [ + { type: "thinking", thinking: "...", signature: tagSignature(GROK_RESPONSES_PROVIDER, "FOREIGN_BLOB") }, + { type: "text", text: "ok" }, + ], }, userTurn("turn 2"), ]; From 40e6c0ddbaaf4c837bbf62f600894e54293a57d8 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Fri, 7 Aug 2026 01:01:45 -0700 Subject: [PATCH 2/2] Key reasoning-signature replay on the issuing provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit signatureForModel compared only turn.model against the current request's model, but provider is a fixed adapter tag while model is arbitrary catalog/user-supplied text — two distinct backends can declare the same literal model name (proxy aliases, two OpenAI-compatible endpoints both configured as gpt-4o), and the equality check would treat a foreign signature as safe to replay, reproducing the original decrypt-failure bug. ConversationTurn carries no field recording which provider produced it, so provenance now rides inside the signature string itself: capture tags it provider:ciphertext, and replay only unwraps the ciphertext when both the tagged provider and the model match. Keying on provider rather than the per-account source id means a live account switch on the same backend (two ChatGPT accounts through the same Codex service) still preserves reasoning continuity, since the decrypting backend is shared across accounts. --- src/provider/codex-responses-adapter.ts | 58 ++++++++++++++++------ src/provider/grok-responses-adapter.ts | 9 ++-- src/provider/openai-responses-adapter.ts | 9 ++-- tests/unit/codex-responses-adapter.test.ts | 48 +++++++++++++++++- tests/unit/codex-sse-fixtures.test.ts | 3 +- 5 files changed, 102 insertions(+), 25 deletions(-) diff --git a/src/provider/codex-responses-adapter.ts b/src/provider/codex-responses-adapter.ts index 2d3b2ffb3..1ef11a251 100644 --- a/src/provider/codex-responses-adapter.ts +++ b/src/provider/codex-responses-adapter.ts @@ -57,15 +57,44 @@ type ResponsesInputItem = | { type: "reasoning"; summary: never[]; encrypted_content: string }; // A thinking block's `signature` is opaque ciphertext a specific backend -// issued for a specific model; only that backend can decrypt it. `turn.model` -// records which model produced the turn, so comparing it against the model -// this request is being built for is enough provenance to tell whether a -// signature is safe to replay — no separate provenance field is needed. -// Switching models means turns from the old model simply stop qualifying, so -// a poisoned history self-heals on the very next request instead of being -// replayed forever. -export function signatureForModel(turn: ConversationTurn, requestModel: string, signature: string): string | undefined { - return turn.model === requestModel ? signature : undefined; +// issued for a specific model; only that backend can decrypt it. `model` is +// arbitrary catalog/user-supplied text — nothing stops two distinct backends +// (proxy aliases, two OpenAI-compatible endpoints) from declaring the same +// literal model name, so comparing `turn.model` alone treats a foreign +// signature as safe to replay. `ConversationTurn` carries no field for which +// provider produced it, so provenance rides inside the signature string +// itself: capture tags it `:` (see `tagSignature`), +// and replay only unwraps the ciphertext when both the tagged provider and +// the model match the current request. +// +// Provider, not the per-account source id, is the unit of decrypt +// capability — a Codex backend shared across ChatGPT accounts can decrypt a +// signature issued to any of them, so keying on provider (rather than source +// id) is what lets an account switch keep reasoning continuity while a +// genuine cross-provider collision still gets dropped. A poisoned history +// self-heals on the next request instead of being replayed forever. +const SIGNATURE_TAG_SEPARATOR = ":"; + +export function tagSignature(provider: string, encryptedContent: string): string { + return `${provider}${SIGNATURE_TAG_SEPARATOR}${encryptedContent}`; +} + +function untagSignature(tagged: string): { provider: string; encryptedContent: string } | undefined { + const idx = tagged.indexOf(SIGNATURE_TAG_SEPARATOR); + if (idx === -1) return undefined; + return { provider: tagged.slice(0, idx), encryptedContent: tagged.slice(idx + 1) }; +} + +export function signatureForModel( + turn: ConversationTurn, + requestModel: string, + requestProvider: string, + signature: string, +): string | undefined { + if (turn.model !== requestModel) return undefined; + const tagged = untagSignature(signature); + if (tagged === undefined) return undefined; + return tagged.provider === requestProvider ? tagged.encryptedContent : undefined; } // Map one internal turn to zero or more Responses items. Assistant text uses @@ -76,7 +105,7 @@ export function signatureForModel(turn: ConversationTurn, requestModel: string, // (held in a thinking block's signature) AND that backend is the one this // request is going to — replaying it to a different provider gets a 400 it // cannot recover from. -function toResponsesItems(turn: ConversationTurn, requestModel: string): ResponsesInputItem[] { +function toResponsesItems(turn: ConversationTurn, requestModel: string, requestProvider: string): ResponsesInputItem[] { const items: ResponsesInputItem[] = []; const textKind: "input_text" | "output_text" = turn.role === "assistant" ? "output_text" : "input_text"; const textParts: ResponsesContentPart[] = []; @@ -112,7 +141,7 @@ function toResponsesItems(turn: ConversationTurn, requestModel: string): Respons items.push({ type: "function_call_output", call_id: block.callId, output: toolResultText(block) }); } else if (block.type === "thinking" && typeof block.signature === "string" && block.signature.length > 0) { flushText(); - const encryptedContent = signatureForModel(turn, requestModel, block.signature); + const encryptedContent = signatureForModel(turn, requestModel, requestProvider, block.signature); if (encryptedContent !== undefined) { items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent }); } @@ -169,8 +198,9 @@ function buildRequest( messages: ConversationTurn[], model: string, options: InferenceOptions, + requestProvider: string, ): BuiltRequest { - const conversation = messages.flatMap((turn) => toResponsesItems(turn, model)); + const conversation = messages.flatMap((turn) => toResponsesItems(turn, model, requestProvider)); // Corbits Code's prompt cannot live in `instructions` (the backend pins that to // the official Codex prompt), so it leads the input as a developer message. const input = @@ -377,7 +407,7 @@ export function parseResponse( events.push({ type: "inference.thinking.signature", seq, - data: { signature: item["encrypted_content"], index }, + data: { signature: tagSignature(source.provider, item["encrypted_content"] as string), index }, }); } return events; @@ -457,7 +487,7 @@ export function createCodexResponsesAdapter(source: LastCycleSource): ProviderAd items: new Map(), }; return { - buildRequest, + buildRequest: (messages, model, options) => buildRequest(messages, model, options, source.provider), parseResponse: (sseData) => parseResponse(sseData, indexer, source), isStreamTerminal: isResponsesStreamTerminal, }; diff --git a/src/provider/grok-responses-adapter.ts b/src/provider/grok-responses-adapter.ts index c0787925f..f99962526 100644 --- a/src/provider/grok-responses-adapter.ts +++ b/src/provider/grok-responses-adapter.ts @@ -52,7 +52,7 @@ function toolResultText(block: Extract): // Map one internal turn to Responses items. Text-only messages keep the string // shape grok sends; messages with image blocks switch to Responses content parts // so the model receives the actual pixels instead of only a text placeholder. -function toResponsesItems(turn: ConversationTurn, requestModel: string): ResponsesInputItem[] { +function toResponsesItems(turn: ConversationTurn, requestModel: string, requestProvider: string): ResponsesInputItem[] { const items: ResponsesInputItem[] = []; const role = turn.role; const parts: ResponsesInputContentPart[] = []; @@ -95,7 +95,7 @@ function toResponsesItems(turn: ConversationTurn, requestModel: string): Respons items.push({ type: "function_call_output", call_id: block.callId, output: toolResultText(block) }); } else if (block.type === "thinking" && typeof block.signature === "string" && block.signature.length > 0) { flushMessage(); - const encryptedContent = signatureForModel(turn, requestModel, block.signature); + const encryptedContent = signatureForModel(turn, requestModel, requestProvider, block.signature); if (encryptedContent !== undefined) { items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent }); } @@ -137,8 +137,9 @@ function buildRequest( messages: ConversationTurn[], model: string, options: InferenceOptions, + requestProvider: string, ): BuiltRequest { - const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model))); + const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model, requestProvider))); const systemMessage: ResponsesInputItem | undefined = options.systemPrompt !== undefined ? { type: "message", role: "system", content: options.systemPrompt } @@ -177,7 +178,7 @@ function buildRequest( export function createGrokResponsesAdapter(source: LastCycleSource): ProviderAdapter { const indexer = createResponsesBlockIndexer(); return { - buildRequest, + buildRequest: (messages, model, options) => buildRequest(messages, model, options, source.provider), parseResponse: (sseData) => parseResponse(sseData, indexer, source, GROK_RESPONSES_PROVIDER), }; } diff --git a/src/provider/openai-responses-adapter.ts b/src/provider/openai-responses-adapter.ts index 27661cb92..c9d7548d4 100644 --- a/src/provider/openai-responses-adapter.ts +++ b/src/provider/openai-responses-adapter.ts @@ -42,7 +42,7 @@ function toolResultText(block: Extract): return parts.join(""); } -function toResponsesItems(turn: ConversationTurn, requestModel: string): ResponsesInputItem[] { +function toResponsesItems(turn: ConversationTurn, requestModel: string, requestProvider: string): ResponsesInputItem[] { const items: ResponsesInputItem[] = []; const role = turn.role; const parts: ResponsesInputContentPart[] = []; @@ -91,7 +91,7 @@ function toResponsesItems(turn: ConversationTurn, requestModel: string): Respons items.push({ type: "function_call_output", call_id: block.callId, output: toolResultText(block) }); } else if (block.type === "thinking" && typeof block.signature === "string" && block.signature.length > 0) { flushMessage(); - const encryptedContent = signatureForModel(turn, requestModel, block.signature); + const encryptedContent = signatureForModel(turn, requestModel, requestProvider, block.signature); if (encryptedContent !== undefined) { items.push({ type: "reasoning", summary: [], encrypted_content: encryptedContent }); } @@ -128,8 +128,9 @@ function buildRequest( messages: ConversationTurn[], model: string, options: InferenceOptions, + requestProvider: string, ): BuiltRequest { - const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model))); + const conversation = dedupeToolOutputs(messages.flatMap((turn) => toResponsesItems(turn, model, requestProvider))); const systemMessage: ResponsesInputItem | undefined = options.systemPrompt !== undefined ? { type: "message", role: "system", content: options.systemPrompt } @@ -166,7 +167,7 @@ function buildRequest( export function createOpenAIResponsesAdapter(source: LastCycleSource): ProviderAdapter { const indexer = createResponsesBlockIndexer(); return { - buildRequest, + buildRequest: (messages, model, options) => buildRequest(messages, model, options, source.provider), parseResponse: (sseData) => parseResponse(sseData, indexer, source, OPENAI_RESPONSES_PROVIDER), isStreamTerminal: isResponsesStreamTerminal, }; diff --git a/tests/unit/codex-responses-adapter.test.ts b/tests/unit/codex-responses-adapter.test.ts index 3c25c41d8..6f588a596 100644 --- a/tests/unit/codex-responses-adapter.test.ts +++ b/tests/unit/codex-responses-adapter.test.ts @@ -2,6 +2,7 @@ import { test, expect, describe } from "bun:test"; import { createCodexResponsesAdapter, tagSignature, + signatureForModel, CODEX_ACCOUNT_ID_OPTION, CODEX_SESSION_ID_OPTION, CODEX_RESPONSES_PROVIDER, @@ -220,6 +221,43 @@ describe("codex-responses buildRequest", () => { } }); + test("drops a bare, untagged legacy signature instead of misparsing it as ciphertext", () => { + // Signatures captured before this change carry no ":" prefix. + // untagSignature must recognize the absence of a separator and refuse to + // treat any part of the raw string as ciphertext, rather than replaying + // a truncated or garbled blob the backend cannot decrypt. + const turns: ConversationTurn[] = [ + userTurn("solve the hard problem"), + { + role: "assistant", + model: "gpt-5-codex", + timestamp: 0, + content: [ + { type: "thinking", thinking: "internal steps...", signature: "QUJDREVGRzEyMzQ1Njc4OTAtXy8rPQ==" }, + { type: "text", text: "The answer is 42." }, + ], + }, + ]; + const body = JSON.parse(adapter().buildRequest(turns, "gpt-5-codex", baseOptions).body) as Record; + const input = body["input"] as Array>; + expect(input.some((item) => item["type"] === "reasoning")).toBe(false); + }); + + test("signatureForModel returns undefined for an untagged signature", () => { + const turn: ConversationTurn = { role: "assistant", model: "gpt-5-codex", timestamp: 0, content: [] }; + expect(signatureForModel(turn, "gpt-5-codex", CODEX_RESPONSES_PROVIDER, "QUJDREVGRzEyMzQ1Njc4OTAtXy8rPQ==")).toBeUndefined(); + }); + + test("tagSignature/signatureForModel round-trips ciphertext containing embedded colons byte-exact", () => { + // untagSignature splits on the FIRST colon (indexOf, not split(":")), + // so ciphertext that itself contains colons must survive intact. A + // naive split(":")[1] would truncate this to "part2". + const ciphertext = "part1:part2:part3=="; + const turn: ConversationTurn = { role: "assistant", model: "gpt-5-codex", timestamp: 0, content: [] }; + const tagged = tagSignature(CODEX_RESPONSES_PROVIDER, ciphertext); + expect(signatureForModel(turn, "gpt-5-codex", CODEX_RESPONSES_PROVIDER, tagged)).toBe(ciphertext); + }); + test("omits the account-id header when no account id is supplied", () => { const req = adapter().buildRequest([userTurn("x")], "gpt-5-codex", { providerOptions: { [CODEX_SESSION_ID_OPTION]: "s" } }); expect(req.headers["chatgpt-account-id"]).toBeUndefined(); @@ -285,7 +323,10 @@ describe("codex-responses parseResponse", () => { { type: "response.output_item.done", item: { type: "reasoning", id: "rs_1", encrypted_content: "ENC_BLOB" } }, ]); expect(out[0]).toMatchObject({ type: "inference.thinking.delta", data: { index: 0 } }); - expect(out[1]).toMatchObject({ type: "inference.thinking.signature", data: { signature: "ENC_BLOB", index: 0 } }); + expect(out[1]).toMatchObject({ + type: "inference.thinking.signature", + data: { signature: tagSignature(CODEX_RESPONSES_PROVIDER, "ENC_BLOB"), index: 0 }, + }); }); test("emits empty thinking delta + signature when done provides encrypted_content with no prior delta (pure-encrypted reasoning)", () => { @@ -297,7 +338,10 @@ describe("codex-responses parseResponse", () => { ]); expect(out).toHaveLength(2); expect(out[0]).toMatchObject({ type: "inference.thinking.delta", data: { token: "", index: 0 } }); - expect(out[1]).toMatchObject({ type: "inference.thinking.signature", data: { signature: "ENC", index: 0 } }); + expect(out[1]).toMatchObject({ + type: "inference.thinking.signature", + data: { signature: tagSignature(CODEX_RESPONSES_PROVIDER, "ENC"), index: 0 }, + }); }); test("keys blocks by item_id so interleaved reasoning and tool calls keep distinct indices", () => { diff --git a/tests/unit/codex-sse-fixtures.test.ts b/tests/unit/codex-sse-fixtures.test.ts index fb1624cfd..b675caffa 100644 --- a/tests/unit/codex-sse-fixtures.test.ts +++ b/tests/unit/codex-sse-fixtures.test.ts @@ -10,6 +10,7 @@ import { join } from "node:path"; import { createCodexResponsesAdapter, isResponsesStreamTerminal, + tagSignature, } from "../../src/provider/codex-responses-adapter.js"; import type { InferenceEvent, LastCycleSource } from "@intx/types/runtime"; import { ProtocolMismatchError } from "@intx/inference"; @@ -95,7 +96,7 @@ describe("codex-sse fixtures (golden parse)", () => { }); expect(out[3]).toMatchObject({ type: "inference.thinking.signature", - data: { signature: "ENC_FIXTURE_BLOB_NOT_REAL", index: 0 }, + data: { signature: tagSignature(SOURCE.provider, "ENC_FIXTURE_BLOB_NOT_REAL"), index: 0 }, }); expect(out[4]).toMatchObject({ type: "inference.text.delta",