diff --git a/src/server/responses/core-opaque-recovery.ts b/src/server/responses/core-opaque-recovery.ts index bc7bbe40a18..03df9766863 100644 --- a/src/server/responses/core-opaque-recovery.ts +++ b/src/server/responses/core-opaque-recovery.ts @@ -88,6 +88,7 @@ export function isEncryptedFunctionOutputRejection(bodyText: string): boolean { try { const payload = JSON.parse(bodyText) as unknown; if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; + if (upstreamErrorMessageFromPayload(payload) === ENCRYPTED_FUNCTION_OUTPUT_REJECTION) return true; const record = payload as { detail?: unknown; message?: unknown; error?: unknown }; if (record.detail === ENCRYPTED_FUNCTION_OUTPUT_REJECTION) return true; if (record.message === ENCRYPTED_FUNCTION_OUTPUT_REJECTION) return true; diff --git a/src/server/responses/encrypted-payload.ts b/src/server/responses/encrypted-payload.ts index 0e9efddf999..9835e851cca 100644 --- a/src/server/responses/encrypted-payload.ts +++ b/src/server/responses/encrypted-payload.ts @@ -98,6 +98,19 @@ import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/cat export function looksLikeBackendCiphertext(payload: string): boolean { + // Unknown replay history has no authenticity proof. Require the key-independent Fernet wire + // structure instead of granting ciphertext authority to any long base64-like model output. + // Proven backend bytes still remain opaque and byte-identical; malformed/plaintext slots are + // lowered by the compatibility path below rather than poisoning every later native replay. + return isStructurallyValidFernetToken(payload); +} + +/** + * Pre-route compatibility keeps an encoded-looking unknown slot opaque until the destination is + * known. A routed destination strips that slot instead of exposing possible truncated ciphertext; + * the canonical backend later applies the stricter structural classifier. + */ +function looksLikeUnknownOpaqueSlot(payload: string): boolean { return payload.length >= 64 && /^[A-Za-z0-9+/=_-]+$/.test(payload); } @@ -480,7 +493,10 @@ function contentWithoutCiphertext(content: unknown[]): unknown[] { -export function sanitizeEncryptedContentInPlace(input: unknown): number { +export function sanitizeEncryptedContentInPlace( + input: unknown, + options: { preserveUnknownOpaqueSlots?: boolean } = {}, +): number { if (!Array.isArray(input)) return 0; let rewritten = 0; const protectedFragments = new WeakSet(); @@ -512,7 +528,9 @@ export function sanitizeEncryptedContentInPlace(input: unknown): number { && typeof (child as { encrypted_content?: unknown }).encrypted_content === "string" ) { const payload = (child as { encrypted_content: string }).encrypted_content; - if (!protectedFragments.has(child) && !looksLikeBackendCiphertext(payload)) { + const preserveUnknown = options.preserveUnknownOpaqueSlots === true + && looksLikeUnknownOpaqueSlot(payload); + if (!protectedFragments.has(child) && !looksLikeBackendCiphertext(payload) && !preserveUnknown) { const parts = encryptedSlotParts(payload); frame.node.splice(frame.index, 1, ...parts); rewritten += 1; diff --git a/src/server/responses/passthrough-dispatch.ts b/src/server/responses/passthrough-dispatch.ts index e4ca80ccb22..6676bbd1d40 100644 --- a/src/server/responses/passthrough-dispatch.ts +++ b/src/server/responses/passthrough-dispatch.ts @@ -143,6 +143,7 @@ import { captureCodexAffinityDiagnostic } from "../../codex/affinity-debug"; import { ACCOUNT_GATED_NATIVE_OPENAI_MODELS } from "../../codex/catalog/native-models"; import { attemptOpaqueBlobRecovery, + isEncryptedFunctionOutputRejection, outboundResponsesBodyCarriesEncryptedFunctionOutput, resetStreamedOpaqueBlobLogContext, consoleGoUploadRejectionBody, @@ -1474,8 +1475,13 @@ export async function preparePassthroughExchange( payload => { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return false; const type = (payload as { type?: unknown }).type; - return (type === "error" || type === "response.failed" || type === "response.incomplete") - && upstreamErrorMessageFromPayload(payload) === ENCRYPTED_FUNCTION_OUTPUT_REJECTION; + const decryptRejection = (type === "error" || type === "response.failed" || type === "response.incomplete") + && isEncryptedFunctionOutputRejection(JSON.stringify(payload)); + // `detail` is a real WebSocket error shape but the generic preflight failure projector + // intentionally understands only Responses error/message fields. Preserve only this + // exact identity so the projected 502 can enter the existing single-shot recovery. + if (decryptRejection) preflightLog.upstreamError = ENCRYPTED_FUNCTION_OUTPUT_REJECTION; + return decryptRejection; }, { allowMissingContentType: !recoveryContentType && parsed.stream, replayReadErrors: true, diff --git a/src/server/responses/request-prepare.ts b/src/server/responses/request-prepare.ts index 3c35bd3a806..4d94d6a71ad 100644 --- a/src/server/responses/request-prepare.ts +++ b/src/server/responses/request-prepare.ts @@ -273,10 +273,14 @@ export async function prepareResponsesRequest( // encrypted_content slots as plaintext. Rewrite them to input_text on the RAW body BEFORE // parsing so every consumer sees the payload: parseRequest (routed/translated providers read // the parsed messages) and the native passthrough (_rawBody is this same object, serialized - // verbatim). Genuine backend ciphertext is left byte-identical (looksLikeBackendCiphertext). + // verbatim). Structurally valid backend ciphertext stays byte-identical; encoded-looking unknown + // slots remain opaque only until final-route handling can preserve or strip them safely. { const rewritten = sanitizeEncryptedContentInPlace( (body as { input?: unknown } | undefined)?.input, + // The final destination is not known yet. Keep ambiguous encoded slots opaque until route + // selection can either strip them for a third party or apply strict native classification. + { preserveUnknownOpaqueSlots: true }, ); if (rewritten > 0) console.warn( @@ -891,6 +895,17 @@ export async function prepareResponsesRequest( if (options.abortSignal?.aborted) return clientCancelledResponse(); + if (inboundWire === "responses" && isCanonicalOpenAiForwardProvider(route.provider)) { + const rewritten = sanitizeEncryptedContentInPlace( + (body as { input?: unknown } | undefined)?.input, + ); + if (rewritten > 0) { + console.warn( + `[opencodex] rewrote ${rewritten} non-Fernet encrypted_content part(s) before canonical native replay`, + ); + } + } + // Encrypted child tasks may reach the canonical native backend or an explicitly trusted // direct Responses route. This runs against the FINAL route so native-only fallback can // rescue an incompatible primary without weakening combo behavior. diff --git a/structure/decisions/ADR-5236-responses-http-sse.md b/structure/decisions/ADR-5236-responses-http-sse.md new file mode 100644 index 00000000000..68bf4243e79 --- /dev/null +++ b/structure/decisions/ADR-5236-responses-http-sse.md @@ -0,0 +1,12 @@ +# ADR-5236 — decision recorded under "Responses HTTP/SSE" + +- Contract owner: [transports/responses.md](../transports/responses.md#responses-httpsse) + +## Decision record + +- 목적과 의도: Agent-generated text must not poison later native replay as trusted ciphertext. +- 기존 구현 및 제약 조건: The proxy cannot authenticate backend Fernet tokens after full-history replay, but must preserve genuine opaque bytes byte-for-byte. +- 검토한 주요 대안: Trust every encoded-looking string; strip every encrypted slot; require canonical Fernet structure and retain bounded rejection recovery. +- 선택한 방식: Use Fernet structure as the unknown-history floor and one guarded recovery for the exact backend rejection. +- 다른 대안 대신 이 방식을 선택한 이유: Loose shape grants authority to plaintext, while unconditional stripping destroys valid backend state. +- 장점, 단점 및 영향: False positives stop before dispatch and poisoned history self-recovers once; structurally valid unauthenticated text may still need the bounded backend rejection path. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 031f1d5417f..09bf0d1ccb7 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -333,6 +333,18 @@ including the compaction turn the proxy itself drives. With `store: false`, requ strips ids from every input item, including compact-wire items, matching codex-rs (`core/src/client.rs:918-925`). Compact-wire items remain exempt from response-side field backfill. +For replayed `encrypted_content` slots whose minting provenance is unavailable after a restart or +full-history resend, the plaintext-compatibility boundary requires canonical key-independent Fernet +structure (version byte, timestamp, IV, block-aligned ciphertext and HMAC layout). A long +base64-like agent message does not gain ciphertext authority from its spelling. Structure is not +authentication: it is only the minimum legacy fallback needed to avoid corrupting genuine opaque +history. If the canonical backend still rejects an encrypted function or agent output, the exact +decrypt/decode identity enters one request-budgeted sanitize-and-rebuild attempt for HTTP and +pre-commit SSE/WebSocket terminal envelopes; the single-shot guard remains armed on the rebuilt +send. + +> Decision record: [ADR-5236](../decisions/ADR-5236-responses-http-sse.md) + Codex pool account changes are a separate portability question from destination serving identity. `src/codex/routing.ts` remembers, in process memory and keyed like thread affinity, which pool account minted a conversation's carried state (`previous_response_id`, encrypted reasoning, and diff --git a/tests/responses/responses-compaction-routing.test.ts b/tests/responses/responses-compaction-routing.test.ts index 45e174abedd..f111aafe71b 100644 --- a/tests/responses/responses-compaction-routing.test.ts +++ b/tests/responses/responses-compaction-routing.test.ts @@ -2316,9 +2316,13 @@ describe("computer screenshot output translation boundary", () => { describe("external task-input envelopes (#3735)", () => { beforeEach(takeSpendHome); - // Synthetic charset/length fixture: short plaintext in this slot is deliberately - // normalized to input_text before parsing, so it cannot exercise opaque rejection. - const opaqueOutput = `g${"A".repeat(127)}`; + const opaqueOutput = `${Buffer.concat([ + Buffer.from([0x80]), + Buffer.alloc(8), + Buffer.alloc(16), + Buffer.alloc(16), + Buffer.alloc(32), + ]).toString("base64url")}==`; const external = (output: unknown = "external task input") => ({ type: "function_call_output", id: "external-fixture", name: "handoff_input", namespace: "task_inbox", output, }); @@ -2326,8 +2330,9 @@ describe("external task-input envelopes (#3735)", () => { model: "gw/model", stream: false, input: [item], }); - test("opaque negative fixtures survive the plaintext-slot classifier", () => { + test("only canonical Fernet structure survives the plaintext-slot classifier", () => { expect(looksLikeBackendCiphertext(opaqueOutput)).toBe(true); + expect(looksLikeBackendCiphertext(`gAAAAA${"A".repeat(189)}`)).toBe(false); }); test("sends a complete envelope as user text without an orphan-tool marker", async () => { diff --git a/tests/responses/responses-opaque-blob-recovery.test.ts b/tests/responses/responses-opaque-blob-recovery.test.ts index 48290dad1ba..1fcf62d7ab4 100644 --- a/tests/responses/responses-opaque-blob-recovery.test.ts +++ b/tests/responses/responses-opaque-blob-recovery.test.ts @@ -21,8 +21,15 @@ import { markBodyNonPersistable, rememberResponseState, previousResponseProvider const originalFetch = globalThis.fetch; const originalOpenCodexHome = process.env.OPENCODEX_HOME; const BLOB = "provider-minted-opaque-state"; -// Synthetic Fernet-shaped data must survive the outbound ciphertext shape gate. -const FUNCTION_OUTPUT_BLOB = `g${"A".repeat(127)}`; +// Canonical key-independent Fernet structure; authenticity is deliberately not needed in tests. +const FUNCTION_OUTPUT_BLOB = `${Buffer.concat([ + Buffer.from([0x80]), + Buffer.alloc(8), + Buffer.alloc(16), + Buffer.alloc(16), + Buffer.alloc(32), +]).toString("base64url")}==`; +const FERNET_SHAPED_PLAINTEXT = `gAAAAA${"A".repeat(189)}`; const FUNCTION_OUTPUT_DECRYPT_MESSAGE = "Encrypted function output content could not be decrypted or decoded."; const OPENAI_BLOB_ERROR = JSON.stringify({ error: { @@ -370,6 +377,13 @@ function streamedFunctionOutputDecryptErrorEvent( ); } +function streamedFunctionOutputDecryptDetailEvent(): Response { + return decryptStreamResponse( + `event: error\ndata: ${JSON.stringify({ type: "error", detail: FUNCTION_OUTPUT_DECRYPT_MESSAGE })}\n\n`, + "text/event-stream", + ); +} + function streamedSuccess(id: string): Response { const completed = { type: "response.completed", @@ -544,6 +558,44 @@ describe("opaque blob recovery trigger", () => { }); describe("opaque blob recovery through /v1/responses", () => { + test("lowers Fernet-shaped agent plaintext before native dispatch", async () => { + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return success("resp-agent-plaintext"); + }) as typeof fetch; + + const request = new Request("http://localhost/v1/responses", { + method: "POST", + headers: { + "content-type": "application/json", + "x-codex-parent-thread-id": "thread-agent-plaintext", + authorization: "Bearer caller-codex-token", + }, + body: JSON.stringify({ + model: "gpt-5.5", + stream: false, + store: false, + input: [{ + type: "agent_message", + author: "/root/child_task", + recipient: "/root", + content: [{ type: "encrypted_content", encrypted_content: FERNET_SHAPED_PLAINTEXT }], + }], + }), + }); + + const response = await handleResponses(request, nativeConfig(), { model: "", provider: "" }); + expect(response.status).toBe(200); + await response.text(); + expect(outbound).toHaveLength(1); + expect(outbound[0]?.input).toEqual([{ + type: "message", + role: "user", + content: [{ type: "input_text", text: FERNET_SHAPED_PLAINTEXT }], + }]); + }); + test("recovers a zero-output streamed function-output decrypt failure before client relay", async () => { const outbound: Array> = []; globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { @@ -789,6 +841,27 @@ describe("opaque blob recovery through /v1/responses", () => { expect(retriedInput?.at(1)).toEqual(recoveredFunctionOutput()); }); + test("recovers a WebSocket-style detail decrypt error before client relay", async () => { + const outbound: Array> = []; + globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => { + outbound.push(JSON.parse(String(init?.body)) as Record); + return outbound.length === 1 + ? streamedFunctionOutputDecryptDetailEvent() + : streamedSuccess("resp-stream-detail-recovered"); + }) as typeof fetch; + + const logCtx: RequestLogContext = { model: "", provider: "" }; + const response = await handleResponses(functionOutputRequest(true), config(), logCtx); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(body).toContain("response.completed"); + expect(body).not.toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); + expect(outbound).toHaveLength(2); + expect(JSON.stringify(outbound[1])).not.toContain(FUNCTION_OUTPUT_BLOB); + expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["opaque-blob-rejection"]); + }); + for (const streamMode of ["legacy-tee", "eager-relay"] as const) { test(`preserves non-decrypt failed SSE with encrypted history (${streamMode})`, async () => { const failed = { type: "response.failed", response: {