Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/server/responses/core-opaque-recovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
22 changes: 20 additions & 2 deletions src/server/responses/encrypted-payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down Expand Up @@ -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<object>();
Expand Down Expand Up @@ -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;
Expand Down
10 changes: 8 additions & 2 deletions src/server/responses/passthrough-dispatch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
17 changes: 16 additions & 1 deletion src/server/responses/request-prepare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions structure/decisions/ADR-5236-responses-http-sse.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions structure/transports/responses.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 9 additions & 4 deletions tests/responses/responses-compaction-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2316,18 +2316,23 @@ 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,
});
const body = (item: Record<string, unknown>) => ({
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 () => {
Expand Down
77 changes: 75 additions & 2 deletions tests/responses/responses-opaque-blob-recovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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<Record<string, unknown>> = [];
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
outbound.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
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<Record<string, unknown>> = [];
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
Expand Down Expand Up @@ -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<Record<string, unknown>> = [];
globalThis.fetch = (async (_input: RequestInfo | URL, init?: RequestInit) => {
outbound.push(JSON.parse(String(init?.body)) as Record<string, unknown>);
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: {
Expand Down
Loading