From 6f8d48b1fe172d0330cf01a4aa64187d6f76ddf5 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 5 Oct 2026 11:08:13 +0900 Subject: [PATCH 01/10] fix(responses): sanitize terminal replacement diagnostics --- src/server/responses/adapter-dispatch.ts | 7 +- src/server/responses/non-replayable-error.ts | 53 +++++++ src/server/responses/passthrough-delivery.ts | 6 +- .../responses/terminal-error-redaction.ts | 44 +++++- structure/transports/responses-failover.md | 2 +- structure/transports/responses.md | 2 +- .../responses-translated-reset.test.ts | 143 +++++++++++++++++- 7 files changed, 246 insertions(+), 11 deletions(-) create mode 100644 src/server/responses/non-replayable-error.ts diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index e41c219f3f2..fd9f2cbcea0 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -4,6 +4,7 @@ import { authorizeResendForRecovery } from "../../lib/request-resend-gate"; import { ambiguousResendAllowanceFor, selfContainedResponsesBody } from "./reset-replay"; import { transientSendCapFor } from "./request-send-budget"; import { isNonReplayableResponse } from "../../lib/upstream-retry"; +import { sanitizeNonReplayableUpstreamError } from "./non-replayable-error"; import { isLocalUpstream } from "../../lib/local-upstream"; import type { ResponsesRequestContext, ResponsesAdmissionState } from "./core-options"; import type { PreparedResponsesRequest } from "./request-prepare"; @@ -704,8 +705,10 @@ export async function prepareAdapterExchange( // Preserve the terminal verdict through adapter and combo error formatting. // This also covers a reset reached by a 401/429/413 recovery refetch. if (isNonReplayableResponse(upstreamResponse)) { - cleanupUpstreamAbort(); - return upstreamResponse; + try { + return await sanitizeNonReplayableUpstreamError(upstreamResponse, + transportState.sameTargetRequest?.headers ?? builtInitialRequest.headers, upstream.signal); + } finally { cleanupUpstreamAbort(); } } if ( upstreamResponse.status === 401 diff --git a/src/server/responses/non-replayable-error.ts b/src/server/responses/non-replayable-error.ts new file mode 100644 index 00000000000..3d79c1ca513 --- /dev/null +++ b/src/server/responses/non-replayable-error.ts @@ -0,0 +1,53 @@ +import { redactSecrets, redactSecretString } from "../../lib/redact"; +import { isNonReplayableResponse, isReplayRefusalResponse, markResponseNonReplayable } from "../../lib/upstream-retry"; +import { clientCancelledResponse, readDisplaySafeErrorText } from "./core-errors"; +import { createOutboundCredentialMask } from "./terminal-error-redaction"; + +function maskJson(value: unknown, mask: (text: string) => string, depth = 0): unknown { + if (depth > 64) throw new Error("Upstream diagnostic nesting exceeds the display limit"); + if (typeof value === "string") return mask(value); + if (Array.isArray(value)) return value.map(item => maskJson(item, mask, depth + 1)); + if (value && typeof value === "object") { + return Object.fromEntries(Object.entries(value).map(([key, item]) => [ + mask(key), maskJson(item, mask, depth + 1), + ])); + } + return value; +} + +function diagnosticBody(text: string, mask: (text: string) => string, status: number): string { + const fallback = JSON.stringify({ error: { type: "upstream_error", message: `Provider error ${status}: diagnostic unavailable` } }); + if (!text.trim()) return fallback; + let parsed: unknown; + try { parsed = JSON.parse(text); } + catch { + // Malformed structured diagnostics may contain escaped secrets that cannot be decoded safely. + if (/^[\s]*[\[{\"]/.test(text)) return fallback; + return JSON.stringify({ error: { type: "upstream_error", message: redactSecretString(mask(text)) } }); + } + try { return JSON.stringify(redactSecrets(maskJson(parsed, mask))); } + catch { return fallback; } +} + +/** Client projection never grants another send or turns a real upstream error into our refusal. */ +export async function sanitizeNonReplayableUpstreamError( + response: Response, + outboundHeaders: Record, + signal: AbortSignal, +): Promise { + if (response.ok || !isNonReplayableResponse(response) || isReplayRefusalResponse(response)) return response; + const text = await readDisplaySafeErrorText(response, signal, ""); + if (signal.aborted) { + const cancelled = clientCancelledResponse(); + markResponseNonReplayable(cancelled); + return cancelled; + } + const mask = createOutboundCredentialMask(outboundHeaders, true); + const headers = new Headers({ "content-type": "application/json" }); + if (response.headers.get("x-should-retry") === "false") headers.set("x-should-retry", "false"); + const safe = new Response(response.status === 304 ? null : diagnosticBody(text, mask, response.status), { + status: response.status, headers, + }); + markResponseNonReplayable(safe); + return safe; +} diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index f2d170bfd0f..640edfd27a0 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -18,7 +18,8 @@ import { relayWithAbort, } from "../relay"; import { isUsageDebugEnabled } from "../../usage/debug"; -import { isReplayRefusalResponse } from "../../lib/upstream-retry"; +import { isNonReplayableResponse, isReplayRefusalResponse } from "../../lib/upstream-retry"; +import { sanitizeNonReplayableUpstreamError } from "./non-replayable-error"; import { teeWithBoundedInspection } from "../inspection-tee"; import { codexForwardTerminalOutcomeRecorder, @@ -543,6 +544,9 @@ export async function deliverPassthroughResponse( // through sanitizePassthroughHeaders) so a redirect to a dead host can never // masquerade as a pre-connection failure after the credential was seen. // The numeric outcome above already classified it neutral — no streak. + if (isNonReplayableResponse(upstreamResponse) && !isReplayRefusalResponse(upstreamResponse)) { + return await sanitizeNonReplayableUpstreamError(upstreamResponse, nativeExchange.request.headers, upstream.signal); + } if (upstreamResponse.status >= 300 && upstreamResponse.status < 400) { return new Response(upstreamResponse.body, { status: upstreamResponse.status, diff --git a/src/server/responses/terminal-error-redaction.ts b/src/server/responses/terminal-error-redaction.ts index f8b26d3aedc..fcd8961d704 100644 --- a/src/server/responses/terminal-error-redaction.ts +++ b/src/server/responses/terminal-error-redaction.ts @@ -1,9 +1,44 @@ import { REDACTED_SECRET, SENSITIVE_KEY_PATTERN, redactSecrets } from "../../lib/redact"; +import { foldForMatching } from "../../lib/redact-folding"; import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "../sse-payload-rewrite"; +function maskEncodedCredentials(text: string, secrets: string[]): string { + let view = text; + let offsets = Array.from({ length: text.length + 1 }, (_, index) => index); + for (let depth = 0; depth < 4; depth++) { + const next = foldForMatching(view); + if (next.folded === view) break; + offsets = next.map.map(index => offsets[index]!); + view = next.folded; + } + // Never expose a diagnostic whose encoding exceeds the bounded matching view. + if (foldForMatching(view).folded !== view) return REDACTED_SECRET; + const ranges: Array<{ start: number; end: number }> = []; + for (const secret of secrets) { + let needle = secret; + for (let depth = 0; depth < 4; depth++) needle = foldForMatching(needle).folded; + if (!needle) continue; + for (let at = view.indexOf(needle); at !== -1; at = view.indexOf(needle, at + 1)) { + ranges.push({ start: offsets[at]!, end: offsets[at + needle.length]! }); + } + } + const merged: typeof ranges = []; + for (const range of ranges.sort((a, b) => a.start - b.start)) { + const previous = merged.at(-1); + if (previous && range.start <= previous.end) previous.end = Math.max(previous.end, range.end); + else merged.push(range); + } + for (const { start, end } of merged.reverse()) text = text.slice(0, start) + REDACTED_SECRET + text.slice(end); + return text; +} + /** Mask the selected outbound credential even when upstream echoes only its raw value. */ -export function createOutboundCredentialMask(outboundHeaders: Record): (text: string) => string { - const knownSecrets = Object.entries(outboundHeaders) +export function createOutboundCredentialMask( + outboundHeaders: Record, + encodedDiagnostics = false, +): (text: string) => string { + // Match the normalized values actually sent by Fetch, including trimmed header OWS. + const knownSecrets = [...new Headers(outboundHeaders).entries()] .filter(([name]) => SENSITIVE_KEY_PATTERN.test(name)) .flatMap(([name, value]) => { const credential = /^(?:authorization|proxy-authorization)$/i.test(name) @@ -12,7 +47,10 @@ export function createOutboundCredentialMask(outboundHeaders: Record b.length - a.length); - return (text) => knownSecrets.reduce((safe, secret) => safe.replaceAll(secret, REDACTED_SECRET), text); + return (text) => { + const safe = knownSecrets.reduce((value, secret) => value.replaceAll(secret, REDACTED_SECRET), text); + return encodedDiagnostics ? maskEncodedCredentials(safe, knownSecrets) : safe; + }; } /** Mask upstream diagnostics before either SSE delivery or buffered JSON reconstruction. */ diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index 11e1b0edfbe..2a894f4330c 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -374,7 +374,7 @@ the pre-header row in `fetchWithResetRetry` and the WebSocket row alike: | --- | --- | | 2xx | Returned unchanged. | | 307, 308, 401, 402, 408, 409, 413, 429, or any 5xx | Body released; settles as the refusal. | -| Any other status | Real status and body kept, marked non-replayable. | +| Any other status | Real status kept, marked non-replayable; client delivery applies bounded diagnostic redaction. | The refusal set is everything that would send again: the client retry table (408, 409, 429, every 5xx, which the Codex client retries whatever the headers say), a client following a diff --git a/structure/transports/responses.md b/structure/transports/responses.md index ce5ac093c47..cf57fe84ee5 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -424,7 +424,7 @@ is composed from the following owners in `src/server/responses/`; none is a gene | `request-spend.ts` | This request's entries in the durable spend ledger: one per physical send, settled from the terminal usage. | | `passthrough-execution.ts` | Native host-lease transfer and the enclosing dispatch/delivery `finally`. | | `passthrough-dispatch.ts` | Native request preparation, upstream sends and pre-commit recovery. | -| `passthrough-delivery.ts`, `terminal-error-redaction.ts` | Native HTTP/SSE/JSON delivery, rewrite/inspection, terminal accounting, terminal diagnostic redaction before client delivery, and xAI tool-envelope filtering before continuation storage. | +| `passthrough-delivery.ts`, `terminal-error-redaction.ts`, `non-replayable-error.ts` | Native HTTP/SSE/JSON delivery and terminal accounting. Marked real errors use bounded reads, mask the serving credential in decoded JSON keys/values, drop upstream headers, and retain status plus non-replayability; unsafe diagnostics use a fallback and synthetic refusals stay distinct. SSE diagnostics and xAI tool-envelope filtering precede client delivery/continuation storage. | | `policy-refusal.ts` | Rewrites an allowlisted non-combo HTTP 403 model refusal (`isUpstreamPolicyRefusal` in `src/lib/errors.ts`) from an xAI destination only (`isXaiResponsesDestination`: api.x.ai or the Grok CLI proxy, on either wire) to an HTTP 200 Responses `incomplete` / `content_filter` payload, JSON or SSE, for both `adapter-dispatch.ts` and `passthrough-delivery.ts`. A streamed rewrite takes the turn admission lease and releases it when the body finishes, so the refusal stays inside active-turn accounting. Combo attempts keep the original 403 so failover classifies it as a hop. | | `sidecar-execution.ts` | Image/video versus web-search execution and their shared rotation hook. | | `completion-policy.ts`, `run-turn-execution.ts` | Empty-completion eligibility and adapter-owned event turns. | diff --git a/tests/responses/responses-translated-reset.test.ts b/tests/responses/responses-translated-reset.test.ts index d92b3deb25b..f329b1e62ac 100644 --- a/tests/responses/responses-translated-reset.test.ts +++ b/tests/responses/responses-translated-reset.test.ts @@ -13,6 +13,8 @@ import { handleResponses } from "../../src/server/responses"; import { createRequestExecutionBudget } from "../../src/lib/request-execution-budget"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; +import { isNonReplayableResponse, isReplayRefusalResponse, markResponseNonReplayable, replayRefusalResponse } from "../../src/lib/upstream-retry"; +import { sanitizeNonReplayableUpstreamError } from "../../src/server/responses/non-replayable-error"; async function probe(options: { provider?: Partial; @@ -28,7 +30,7 @@ async function probe(options: { previous?: boolean; prepaid?: boolean; config?: Partial; - answer?: (ordinal: number) => Response; + answer?: (ordinal: number, authorization: string | null) => Response; executor?: typeof fetch; oauth?: boolean; }) { @@ -78,7 +80,7 @@ async function probe(options: { authorizations.push(new Headers(init?.headers).get("authorization")); if (options.executor) return options.executor(_input, init); if (options.abort) abort.abort(); - if (options.answer) return options.answer(bodies.length); + if (options.answer) return options.answer(bodies.length, authorizations.at(-1) ?? null); if (options.downgrade && bodies.length === 1) { if (options.spendOnDowngrade) budget.claimAmbiguousResend(1); return Response.json({ error: { type: "invalid_request_error", param: "reasoning_effort", @@ -107,7 +109,8 @@ async function probe(options: { expect(workflowSends).toBe(bodies.length); } expect(attemptSends).toBe(bodies.length); - return { status: response.status, text, bodies, used: budget.used, workflowSends, attemptSends, + return { status: response.status, text, headers: response.headers, + bodies, used: budget.used, workflowSends, attemptSends, grantSpent: budget.ambiguousResendSpent, reserveSpent: budget.reserveSpent, authorizations }; } finally { @@ -182,6 +185,140 @@ const effortRefusal = (): Response => Response.json({ error: { type: "invalid_re const chatSuccess = (): Response => Response.json({ id: "chat-test", choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }] }); +describe("reset replacement error confidentiality", () => { + const credential = "fixture-active-credential-v1"; + for (const adapter of ["openai-chat", "openai-responses"] as const) { + test.each([ + ["nested JSON escape", "\\u0066ixture-active-credential-v1"], + ["percent escape", "%66ixture-active-credential-v1"], + ["nested percent escape", "%2566ixture-active-credential-v1"], + ["numeric entity", "fixture-active-credential-v1"], + ])(`${adapter} masks encoded diagnostic credentials (%s)`, async (_name, encoded) => { + const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 + ? reset() : Response.json({ error: { message: `invalid request: ${encoded}; path C:\\safe\\u1234.txt` } }, { status: 400 }) }); + expect(result.status).toBe(400); + expect(result.bodies).toHaveLength(2); + const message = JSON.parse(result.text).error.message; + expect(message).toBe("invalid request: [REDACTED]; path C:\\safe\\u1234.txt"); + }); + + test(`${adapter} masks the actual wire credential after header OWS normalization`, async () => { + const result = await probe({ provider: { adapter, apiKey: `${credential} ` }, answer: (ordinal, authorization) => ordinal === 1 + ? reset() : Response.json({ error: { message: `invalid request: ${authorization!.replace(/^Bearer /, "")}` } }, { status: 400 }) }); + expect(result.authorizations).toEqual([`Bearer ${credential}`, `Bearer ${credential}`]); + expect(result.status).toBe(400); + expect(result.bodies).toHaveLength(2); + expect(JSON.parse(result.text).error.message).toBe("invalid request: [REDACTED]"); + }); + + test.each(["plain", "escaped-json"])(`${adapter} redacts replacement diagnostics (%s)`, async shape => { + const diagnostic = `invalid request: ${credential}`; + const text = shape === "plain" ? diagnostic : JSON.stringify({ error: { + message: diagnostic, type: credential, code: credential, + }, [credential]: credential }).replaceAll(credential, credential.replaceAll("f", "\\u0066")); + const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 + ? reset() : new Response(text, { status: 400, headers: { + "content-type": "application/json", "x-upstream-debug": credential, + "set-cookie": `diagnostic=${credential}`, "retry-after": "0", + } }) }); + expect(result.status).toBe(400); + expect(result.text).toContain("invalid request"); + expect(result.text).not.toContain(credential); + expect(result.text).not.toContain("\\u0066ixture-active"); + expect(result.text).toContain("[REDACTED]"); + expect(result.headers.get("x-upstream-debug")).toBeNull(); + expect(result.headers.get("set-cookie")).toBeNull(); + expect(result.headers.get("retry-after")).toBeNull(); + expect(result.text).not.toContain("upstream_reset_replay_refused"); + expect(result.bodies).toHaveLength(2); + }); + } + + test("redacts the serving credential after an OAuth account replacement", async () => { + const result = await probe({ oauth: true, providerName: "xai", provider: { adapter: "openai-chat", + baseUrl: "https://api.x.ai/v1", authMode: "oauth", models: ["test"] }, + body: { model: "xai/test" }, answer: (ordinal, authorization) => ordinal === 1 + ? Response.json({ error: { message: "quota exhausted" } }, { status: 429 }) + : ordinal === 2 ? reset() : Response.json({ error: { message: `invalid request: ${authorization}` } }, { status: 400 }) }); + expect(result.status).toBe(400); + expect(result.authorizations[0]).not.toBe(result.authorizations[2]); + expect(result.text).not.toContain(result.authorizations[2]!.replace(/^Bearer /, "")); + expect(result.text).toContain("[REDACTED]"); + expect(result.bodies).toHaveLength(3); + }); + + test("over-nested diagnostic encoding fails closed within the matching limit", async () => { + const upstream = Response.json({ error: { message: "%2525252566ixture-active-credential-v1" } }, { status: 400 }); + markResponseNonReplayable(upstream); + const safe = await sanitizeNonReplayableUpstreamError(upstream, { authorization: `Bearer ${credential}` }, new AbortController().signal); + expect(safe.status).toBe(400); + expect((await safe.json() as { error: { message: string } }).error.message).toBe("[REDACTED]"); + }); + + test.each(["openai-chat", "openai-responses"] as const)("%s keeps a replacement terminal through combo consumption", async adapter => { + const result = await probe({ provider: { adapter, apiKey: credential, models: ["test", "other"] }, + config: { combos: { pair: { strategy: "failover", targets: [ + { provider: "mock", model: "test" }, { provider: "mock", model: "other" }, + ] } } }, body: { model: "combo/pair" }, answer: ordinal => ordinal === 1 ? reset() + : ordinal === 2 ? Response.json({ error: { code: "context_length_exceeded", message: `context length exceeded: ${credential}` } }, { status: 400 }) + : chatSuccess() }); + expect(result.status).toBe(400); + expect(result.text).not.toContain(credential); + expect(result.text).toContain("[REDACTED]"); + expect(result.bodies).toHaveLength(2); + }); + + test("the client projection preserves marker provenance before lifetime wrapping", async () => { + const upstream = Response.json({ error: { message: `invalid request: ${credential}` } }, { status: 400 }); + markResponseNonReplayable(upstream); + const safe = await sanitizeNonReplayableUpstreamError(upstream, { authorization: `Bearer ${credential}` }, new AbortController().signal); + expect(safe.status).toBe(400); + expect(isNonReplayableResponse(safe)).toBe(true); + expect(isReplayRefusalResponse(safe)).toBe(false); + expect(await safe.text()).not.toContain(credential); + const refusal = replayRefusalResponse(); + expect(await sanitizeNonReplayableUpstreamError(refusal, {}, new AbortController().signal)).toBe(refusal); + expect(isReplayRefusalResponse(refusal)).toBe(true); + await refusal.text(); + }); + + test("an ordinary error keeps its existing delivery owner", async () => { + const upstream = Response.json({ error: { message: "ordinary error" } }, { status: 400 }); + expect(await sanitizeNonReplayableUpstreamError(upstream, {}, new AbortController().signal)).toBe(upstream); + await upstream.text(); + }); + + test("bodyless terminal responses retain their real status", async () => { + const upstream = new Response(null, { status: 304, headers: { "x-upstream-debug": credential } }); + markResponseNonReplayable(upstream); + const safe = await sanitizeNonReplayableUpstreamError(upstream, { "x-api-key": credential }, new AbortController().signal); + expect(safe.status).toBe(304); + expect(safe.body).toBeNull(); + expect(safe.headers.get("x-upstream-debug")).toBeNull(); + expect(isNonReplayableResponse(safe)).toBe(true); + }); + + test("oversized or cancelled diagnostics release their reader without leaking a prefix", async () => { + for (const abortRead of [false, true]) { + const abort = new AbortController(); + let cancelled = false; + const upstream = new Response(new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(credential + (abortRead ? "" : "x".repeat(65_536)))); + if (abortRead) queueMicrotask(() => abort.abort()); + }, + cancel() { cancelled = true; }, + }), { status: 400 }); + markResponseNonReplayable(upstream); + const safe = await sanitizeNonReplayableUpstreamError(upstream, { "x-api-key": credential }, abort.signal); + expect(safe.status).toBe(abortRead ? 499 : 400); + expect(await safe.text()).not.toContain(credential); + expect(cancelled).toBe(true); + expect(isNonReplayableResponse(safe)).toBe(true); + } + }); +}); + describe("translated reset replay boundaries and accounting", () => { test("a valid stored predecessor refuses replay even after local expansion", async () => { const result = await probe({ previous: true }); From 470d355146e7ec4a04218ad023cb8d606ba6ea1d Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Mon, 5 Oct 2026 13:37:26 +0900 Subject: [PATCH 02/10] test(responses): include terminal replacement owner in source inventory --- tests/helpers/responses-core-source.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/helpers/responses-core-source.ts b/tests/helpers/responses-core-source.ts index a49f2dd1c70..eb2c58fee3a 100644 --- a/tests/helpers/responses-core-source.ts +++ b/tests/helpers/responses-core-source.ts @@ -49,6 +49,7 @@ export const RESPONSES_CORE_MODULES = [ "passthrough-delivery.ts", "buffered-sse-json.ts", "terminal-error-redaction.ts", + "non-replayable-error.ts", "sidecar-execution.ts", "completion-policy.ts", "run-turn-execution.ts", From 25307c682a14b15e8a4bd31246f27e71810417a8 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 5 Oct 2026 23:08:44 +0900 Subject: [PATCH 03/10] fix(responses): withhold unresolved entities and mask scalar credentials Terminal reset-replacement diagnostics could still carry the selected credential in two forms the bounded mask missed: an unresolved named HTML reference (which folds to an opaque placeholder of unknown decoded width, e.g. + or fj) and a JSON number/boolean/null whose text equals the credential. Withhold a diagnostic whose fold contains the placeholder, and redact matching scalars while unmatched scalars keep their types. Regression cases cover both adapters in the existing confidentiality block. --- src/lib/redact-folding.ts | 2 +- src/server/responses/non-replayable-error.ts | 6 +++- .../responses/terminal-error-redaction.ts | 5 +++- .../responses-translated-reset.test.ts | 30 +++++++++++++++++++ 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/lib/redact-folding.ts b/src/lib/redact-folding.ts index 9f5c1a70e42..56d2112002e 100644 --- a/src/lib/redact-folding.ts +++ b/src/lib/redact-folding.ts @@ -32,7 +32,7 @@ const INVISIBLE_FORMAT = /[\p{Default_Ignorable_Code_Point}\p{Cf}\p{Mn}\p{Me}]/u * letter may appear. Every named entity is covered, present and future, * without claiming to know what any of them mean. */ -const NAMED_ENTITY_PLACEHOLDER = "\u0001"; +export const NAMED_ENTITY_PLACEHOLDER = "\u0001"; /** * The handful of named references that spell a SEPARATOR rather than a letter. diff --git a/src/server/responses/non-replayable-error.ts b/src/server/responses/non-replayable-error.ts index 3d79c1ca513..3d25875f2a9 100644 --- a/src/server/responses/non-replayable-error.ts +++ b/src/server/responses/non-replayable-error.ts @@ -1,4 +1,4 @@ -import { redactSecrets, redactSecretString } from "../../lib/redact"; +import { REDACTED_SECRET, redactSecrets, redactSecretString } from "../../lib/redact"; import { isNonReplayableResponse, isReplayRefusalResponse, markResponseNonReplayable } from "../../lib/upstream-retry"; import { clientCancelledResponse, readDisplaySafeErrorText } from "./core-errors"; import { createOutboundCredentialMask } from "./terminal-error-redaction"; @@ -6,6 +6,10 @@ import { createOutboundCredentialMask } from "./terminal-error-redaction"; function maskJson(value: unknown, mask: (text: string) => string, depth = 0): unknown { if (depth > 64) throw new Error("Upstream diagnostic nesting exceeds the display limit"); if (typeof value === "string") return mask(value); + if (value === null || typeof value === "number" || typeof value === "boolean") { + const text = String(value); + return mask(text) === text ? value : REDACTED_SECRET; + } if (Array.isArray(value)) return value.map(item => maskJson(item, mask, depth + 1)); if (value && typeof value === "object") { return Object.fromEntries(Object.entries(value).map(([key, item]) => [ diff --git a/src/server/responses/terminal-error-redaction.ts b/src/server/responses/terminal-error-redaction.ts index fcd8961d704..cd866bf1de8 100644 --- a/src/server/responses/terminal-error-redaction.ts +++ b/src/server/responses/terminal-error-redaction.ts @@ -1,5 +1,5 @@ import { REDACTED_SECRET, SENSITIVE_KEY_PATTERN, redactSecrets } from "../../lib/redact"; -import { foldForMatching } from "../../lib/redact-folding"; +import { foldForMatching, NAMED_ENTITY_PLACEHOLDER } from "../../lib/redact-folding"; import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "../sse-payload-rewrite"; function maskEncodedCredentials(text: string, secrets: string[]): string { @@ -7,6 +7,9 @@ function maskEncodedCredentials(text: string, secrets: string[]): string { let offsets = Array.from({ length: text.length + 1 }, (_, index) => index); for (let depth = 0; depth < 4; depth++) { const next = foldForMatching(view); + // An unresolved named reference has unknown decoded width, so an exact + // credential match cannot be proven; withhold the diagnostic. + if (next.folded.includes(NAMED_ENTITY_PLACEHOLDER)) return REDACTED_SECRET; if (next.folded === view) break; offsets = next.map.map(index => offsets[index]!); view = next.folded; diff --git a/tests/responses/responses-translated-reset.test.ts b/tests/responses/responses-translated-reset.test.ts index f329b1e62ac..1e5d3d84fe2 100644 --- a/tests/responses/responses-translated-reset.test.ts +++ b/tests/responses/responses-translated-reset.test.ts @@ -202,6 +202,36 @@ describe("reset replacement error confidentiality", () => { expect(message).toBe("invalid request: [REDACTED]; path C:\\safe\\u1234.txt"); }); + test.each([ + ["named entity", "fixture+credential-v1", "invalid request: fixture+credential-v1"], + ["percent-encoded named entity", "fixture+credential-v1", "invalid request: fixture%26plus%3Bcredential-v1"], + ["multi-code-point named entity", "fj-credential-v1", "bad key fj-credential-v1"], + ])(`${adapter} withholds unresolved named diagnostic references (%s)`, async (_name, apiKey, message) => { + const result = await probe({ provider: { adapter, apiKey }, answer: ordinal => ordinal === 1 + ? reset() : Response.json({ error: { message } }, { status: 400 }) }); + expect(result.status).toBe(400); + expect(result.bodies).toHaveLength(2); + expect(result.used).toBe(2); + expect(result.grantSpent).toBe(true); + expect(JSON.parse(result.text)).toEqual({ error: { message: "[REDACTED]" } }); + }); + + test.each([ + ["number", "867530912345", { message: "invalid request", code: 867530912345, count: 17, flag: false }, + { message: "invalid request", code: "[REDACTED]", count: 17, flag: false }], + ["boolean", "true", { message: "x", code: true }, { message: "x", code: "[REDACTED]" }], + ["null", "null", { message: "x", code: null }, { message: "x", code: "[REDACTED]" }], + ] as const)(`${adapter} masks scalar diagnostic credentials (%s)`, async (_name, apiKey, error, expected) => { + const result = await probe({ provider: { adapter, apiKey }, answer: ordinal => ordinal === 1 + ? reset() : Response.json({ error }, { status: 400 }) }); + expect(result.authorizations).toEqual([`Bearer ${apiKey}`, `Bearer ${apiKey}`]); + expect(result.status).toBe(400); + expect(result.bodies).toHaveLength(2); + expect(result.used).toBe(2); + expect(result.grantSpent).toBe(true); + expect(JSON.parse(result.text)).toEqual({ error: expected }); + }); + test(`${adapter} masks the actual wire credential after header OWS normalization`, async () => { const result = await probe({ provider: { adapter, apiKey: `${credential} ` }, answer: (ordinal, authorization) => ordinal === 1 ? reset() : Response.json({ error: { message: `invalid request: ${authorization!.replace(/^Bearer /, "")}` } }, { status: 400 }) }); From 7afd69b246a9292c1d1d369843d87c7a30209abd Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 5 Oct 2026 23:20:44 +0900 Subject: [PATCH 04/10] fix(responses): withhold diagnostics with undecoded encoding syntax The bounded credential matcher decodes JSON \u escapes, percent escapes, lowercase-x numeric references and semicolon-terminated named references. Other forms (JSON short escapes such as \/, uppercase-X or unterminated numeric references, legacy named references without a semicolon) stayed literal, so a credential spelled through them could not be matched. When such syntax remains in the final matching view, withhold the diagnostic. Unrelated query strings (a=1&b=2) are preserved. --- .../responses/terminal-error-redaction.ts | 4 +++ .../responses-translated-reset.test.ts | 26 +++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/src/server/responses/terminal-error-redaction.ts b/src/server/responses/terminal-error-redaction.ts index cd866bf1de8..58d4fdb8f43 100644 --- a/src/server/responses/terminal-error-redaction.ts +++ b/src/server/responses/terminal-error-redaction.ts @@ -2,6 +2,9 @@ import { REDACTED_SECRET, SENSITIVE_KEY_PATTERN, redactSecrets } from "../../lib import { foldForMatching, NAMED_ENTITY_PLACEHOLDER } from "../../lib/redact-folding"; import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "../sse-payload-rewrite"; +// An exact credential match cannot be proven while undecoded encoding syntax remains. +const UNRESOLVED_ENCODING = /\\["\\/bfnrt]|&#|&[A-Za-z][A-Za-z0-9]{0,31}(?![A-Za-z0-9;=])/; + function maskEncodedCredentials(text: string, secrets: string[]): string { let view = text; let offsets = Array.from({ length: text.length + 1 }, (_, index) => index); @@ -16,6 +19,7 @@ function maskEncodedCredentials(text: string, secrets: string[]): string { } // Never expose a diagnostic whose encoding exceeds the bounded matching view. if (foldForMatching(view).folded !== view) return REDACTED_SECRET; + if (UNRESOLVED_ENCODING.test(view)) return REDACTED_SECRET; const ranges: Array<{ start: number; end: number }> = []; for (const secret of secrets) { let needle = secret; diff --git a/tests/responses/responses-translated-reset.test.ts b/tests/responses/responses-translated-reset.test.ts index 1e5d3d84fe2..d2c494fdc74 100644 --- a/tests/responses/responses-translated-reset.test.ts +++ b/tests/responses/responses-translated-reset.test.ts @@ -216,6 +216,32 @@ describe("reset replacement error confidentiality", () => { expect(JSON.parse(result.text)).toEqual({ error: { message: "[REDACTED]" } }); }); + test.each([ + ["nested JSON short escape", "fixture/credential-v1", "invalid request: fixture\\/credential-v1"], + ["uppercase hex numeric reference", credential, "invalid request: fixture-active-credential-v1"], + ["numeric reference without semicolon", credential, "invalid request: fixture-active-credential-v1"], + ["legacy named reference without semicolon", "fixture&credential-v1", "invalid request: fixture&credential-v1"], + ])(`${adapter} withholds unresolved diagnostic encoding (%s)`, async (_name, apiKey, message) => { + const result = await probe({ provider: { adapter, apiKey }, answer: ordinal => ordinal === 1 + ? reset() : Response.json({ error: { message } }, { status: 400 }) }); + expect(result.status).toBe(400); + expect(result.bodies).toHaveLength(2); + expect(result.used).toBe(2); + expect(result.grantSpent).toBe(true); + expect(JSON.parse(result.text)).toEqual({ error: { message: "[REDACTED]" } }); + }); + + test(`${adapter} preserves diagnostic URL query parameters with an unrelated credential`, async () => { + const message = "see https://x.test/a?b=1&c=2 for details"; + const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 + ? reset() : Response.json({ error: { message } }, { status: 400 }) }); + expect(result.status).toBe(400); + expect(result.bodies).toHaveLength(2); + expect(result.used).toBe(2); + expect(result.grantSpent).toBe(true); + expect(JSON.parse(result.text)).toEqual({ error: { message } }); + }); + test.each([ ["number", "867530912345", { message: "invalid request", code: 867530912345, count: 17, flag: false }, { message: "invalid request", code: "[REDACTED]", count: 17, flag: false }], From 3592c8e8ee4c90125dd826566c76bbf9ee783284 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 5 Oct 2026 23:31:53 +0900 Subject: [PATCH 05/10] fix(responses): withhold any residual reference start in terminal diagnostics Enumerating semicolon-less HTML references left gaps (a legacy & before letters or "=", or a long run). Withhold the terminal diagnostic whenever an '&' followed by a letter or '#', or a JSON short escape, remains in the final matching view. Over-withholding is accepted on this terminal-only path. Adds both-adapter coverage for malformed and over-nested JSON, array bodies, non-2xx SSE, and dropped upstream headers; records the policy in the responses structure doc. --- .../responses/terminal-error-redaction.ts | 5 ++- structure/transports/responses.md | 2 +- .../responses-translated-reset.test.ts | 44 +++++++++++++++++-- 3 files changed, 45 insertions(+), 6 deletions(-) diff --git a/src/server/responses/terminal-error-redaction.ts b/src/server/responses/terminal-error-redaction.ts index 58d4fdb8f43..a04c415228e 100644 --- a/src/server/responses/terminal-error-redaction.ts +++ b/src/server/responses/terminal-error-redaction.ts @@ -2,8 +2,9 @@ import { REDACTED_SECRET, SENSITIVE_KEY_PATTERN, redactSecrets } from "../../lib import { foldForMatching, NAMED_ENTITY_PLACEHOLDER } from "../../lib/redact-folding"; import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "../sse-payload-rewrite"; -// An exact credential match cannot be proven while undecoded encoding syntax remains. -const UNRESOLVED_ENCODING = /\\["\\/bfnrt]|&#|&[A-Za-z][A-Za-z0-9]{0,31}(?![A-Za-z0-9;=])/; +// Any residual reference start or JSON short escape withholds the diagnostic. +// Over-withholding is accepted: this path only projects terminal error diagnostics. +const UNRESOLVED_ENCODING = /\\["\\/bfnrt]|&[A-Za-z#]/; function maskEncodedCredentials(text: string, secrets: string[]): string { let view = text; diff --git a/structure/transports/responses.md b/structure/transports/responses.md index cf57fe84ee5..9b9ac81ba50 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -424,7 +424,7 @@ is composed from the following owners in `src/server/responses/`; none is a gene | `request-spend.ts` | This request's entries in the durable spend ledger: one per physical send, settled from the terminal usage. | | `passthrough-execution.ts` | Native host-lease transfer and the enclosing dispatch/delivery `finally`. | | `passthrough-dispatch.ts` | Native request preparation, upstream sends and pre-commit recovery. | -| `passthrough-delivery.ts`, `terminal-error-redaction.ts`, `non-replayable-error.ts` | Native HTTP/SSE/JSON delivery and terminal accounting. Marked real errors use bounded reads, mask the serving credential in decoded JSON keys/values, drop upstream headers, and retain status plus non-replayability; unsafe diagnostics use a fallback and synthetic refusals stay distinct. SSE diagnostics and xAI tool-envelope filtering precede client delivery/continuation storage. | +| `passthrough-delivery.ts`, `terminal-error-redaction.ts`, `non-replayable-error.ts` | Native HTTP/SSE/JSON delivery and terminal accounting. Marked real errors use bounded reads, mask the serving credential in decoded JSON keys/values/scalars, drop upstream headers, and retain status plus non-replayability; diagnostics left with undecoded reference or JSON-escape syntax are withheld, unsafe ones use a fallback, and synthetic refusals stay distinct. SSE diagnostics and xAI tool-envelope filtering precede client delivery/continuation storage. | | `policy-refusal.ts` | Rewrites an allowlisted non-combo HTTP 403 model refusal (`isUpstreamPolicyRefusal` in `src/lib/errors.ts`) from an xAI destination only (`isXaiResponsesDestination`: api.x.ai or the Grok CLI proxy, on either wire) to an HTTP 200 Responses `incomplete` / `content_filter` payload, JSON or SSE, for both `adapter-dispatch.ts` and `passthrough-delivery.ts`. A streamed rewrite takes the turn admission lease and releases it when the body finishes, so the refusal stays inside active-turn accounting. Combo attempts keep the original 403 so failover classifies it as a hop. | | `sidecar-execution.ts` | Image/video versus web-search execution and their shared rotation hook. | | `completion-policy.ts`, `run-turn-execution.ts` | Empty-completion eligibility and adapter-owned event turns. | diff --git a/tests/responses/responses-translated-reset.test.ts b/tests/responses/responses-translated-reset.test.ts index d2c494fdc74..7f067b5cdf0 100644 --- a/tests/responses/responses-translated-reset.test.ts +++ b/tests/responses/responses-translated-reset.test.ts @@ -221,6 +221,8 @@ describe("reset replacement error confidentiality", () => { ["uppercase hex numeric reference", credential, "invalid request: fixture-active-credential-v1"], ["numeric reference without semicolon", credential, "invalid request: fixture-active-credential-v1"], ["legacy named reference without semicolon", "fixture&credential-v1", "invalid request: fixture&credential-v1"], + ["legacy reference before equals", "fixture&credential", "invalid request: fixture&credential="], + ["legacy reference before long run", "fixture&" + "a".repeat(40), "invalid request: fixture&" + "a".repeat(40)], ])(`${adapter} withholds unresolved diagnostic encoding (%s)`, async (_name, apiKey, message) => { const result = await probe({ provider: { adapter, apiKey }, answer: ordinal => ordinal === 1 ? reset() : Response.json({ error: { message } }, { status: 400 }) }); @@ -231,15 +233,51 @@ describe("reset replacement error confidentiality", () => { expect(JSON.parse(result.text)).toEqual({ error: { message: "[REDACTED]" } }); }); - test(`${adapter} preserves diagnostic URL query parameters with an unrelated credential`, async () => { - const message = "see https://x.test/a?b=1&c=2 for details"; + test.each([ + ["preserves a query without a reference start", "see https://x.test/a?b=1 for details", "see https://x.test/a?b=1 for details"], + ["withholds a query with a reference start", "see https://x.test/a?b=1&c=2", "[REDACTED]"], + ])(`${adapter} %s with an unrelated credential`, async (_name, message, expected) => { const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 ? reset() : Response.json({ error: { message } }, { status: 400 }) }); expect(result.status).toBe(400); expect(result.bodies).toHaveLength(2); expect(result.used).toBe(2); expect(result.grantSpent).toBe(true); - expect(JSON.parse(result.text)).toEqual({ error: { message } }); + expect(JSON.parse(result.text)).toEqual({ error: { message: expected } }); + }); + + test.each([ + ["malformed structured JSON", `{"error":{"message":"invalid request: ${credential}"`, "application/json", + { error: { type: "upstream_error", message: "Provider error 400: diagnostic unavailable" } }], + ["JSON nested beyond the display limit", '{"detail":'.repeat(65) + JSON.stringify(credential) + "}".repeat(65), "application/json", + { error: { type: "upstream_error", message: "Provider error 400: diagnostic unavailable" } }], + ["array diagnostic", JSON.stringify([credential, { message: `invalid request: ${credential}` }, 17]), "application/json", + ["[REDACTED]", { message: "invalid request: [REDACTED]" }, 17]], + ["non-2xx SSE diagnostic", `event: response.failed\ndata: {"type":"response.failed","response":{"error":{"message":"invalid request: ${credential}"}}}\n\n`, "text/event-stream", + { error: { type: "upstream_error", message: 'event: response.failed\ndata: {"type":"response.failed","response":{"error":{"message":"invalid request: [REDACTED]"}}}\n\n' } }], + ] as const)(`${adapter} safely projects %s`, async (_name, text, contentType, expected) => { + const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 + ? reset() : new Response(text, { status: 400, headers: { "content-type": contentType } }) }); + expect(result.status).toBe(400); + expect(result.bodies).toHaveLength(2); + expect(result.used).toBe(2); + expect(result.grantSpent).toBe(true); + expect(JSON.parse(result.text)).toEqual(expected); + }); + + test(`${adapter} drops upstream diagnostic headers and preserves retry refusal`, async () => { + const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 + ? reset() : Response.json({ error: { message: `invalid request: ${credential}` } }, { status: 400, headers: { + "x-debug-token": credential, "set-cookie": `diagnostic=${credential}`, "x-should-retry": "false", + } }) }); + expect(result.status).toBe(400); + expect(result.bodies).toHaveLength(2); + expect(result.used).toBe(2); + expect(result.grantSpent).toBe(true); + expect(JSON.parse(result.text)).toEqual({ error: { message: "invalid request: [REDACTED]" } }); + expect(result.headers.get("x-debug-token")).toBeNull(); + expect(result.headers.get("set-cookie")).toBeNull(); + expect(result.headers.get("x-should-retry")).toBe("false"); }); test.each([ From 74aebe74f3111bd38b9ee7601a2b77ccbb8310f0 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 5 Oct 2026 23:45:15 +0900 Subject: [PATCH 06/10] fix(responses): withhold encoded terminal diagnostics instead of decoding A bounded decoder cannot be proven equivalent to every client decoder; review found a truncated UTF-8 percent sequence that folded differently from decodeURIComponent. On the terminal non-replayable projection, withhold any diagnostic string containing percent, JSON-escape or HTML reference syntax (checked before and after a lookalike-only fold with escape decoding off). Plain and lookalike echoes are still masked in place. Reverts the NAMED_ENTITY_PLACEHOLDER export, which no longer has a consumer. --- src/lib/redact-folding.ts | 2 +- .../responses/terminal-error-redaction.ts | 22 +++++++++---------- structure/transports/responses.md | 2 +- .../responses-translated-reset.test.ts | 16 +++++++++++--- 4 files changed, 25 insertions(+), 17 deletions(-) diff --git a/src/lib/redact-folding.ts b/src/lib/redact-folding.ts index 56d2112002e..9f5c1a70e42 100644 --- a/src/lib/redact-folding.ts +++ b/src/lib/redact-folding.ts @@ -32,7 +32,7 @@ const INVISIBLE_FORMAT = /[\p{Default_Ignorable_Code_Point}\p{Cf}\p{Mn}\p{Me}]/u * letter may appear. Every named entity is covered, present and future, * without claiming to know what any of them mean. */ -export const NAMED_ENTITY_PLACEHOLDER = "\u0001"; +const NAMED_ENTITY_PLACEHOLDER = "\u0001"; /** * The handful of named references that spell a SEPARATOR rather than a letter. diff --git a/src/server/responses/terminal-error-redaction.ts b/src/server/responses/terminal-error-redaction.ts index a04c415228e..affc6c44fca 100644 --- a/src/server/responses/terminal-error-redaction.ts +++ b/src/server/responses/terminal-error-redaction.ts @@ -1,30 +1,28 @@ import { REDACTED_SECRET, SENSITIVE_KEY_PATTERN, redactSecrets } from "../../lib/redact"; -import { foldForMatching, NAMED_ENTITY_PLACEHOLDER } from "../../lib/redact-folding"; +import { foldForMatching } from "../../lib/redact-folding"; import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "../sse-payload-rewrite"; -// Any residual reference start or JSON short escape withholds the diagnostic. -// Over-withholding is accepted: this path only projects terminal error diagnostics. -const UNRESOLVED_ENCODING = /\\["\\/bfnrt]|&[A-Za-z#]/; +// Encoded spellings are withheld rather than decoded: a bounded decoder cannot +// be proven equivalent to every client decoder. Only terminal diagnostics take this path. +const ENCODING_SYNTAX = /%[0-9A-Fa-f]{2}|\\(?:u[0-9A-Fa-f]{4}|["\\/bfnrt])|&[A-Za-z#]/; function maskEncodedCredentials(text: string, secrets: string[]): string { + if (ENCODING_SYNTAX.test(text)) return REDACTED_SECRET; let view = text; let offsets = Array.from({ length: text.length + 1 }, (_, index) => index); for (let depth = 0; depth < 4; depth++) { - const next = foldForMatching(view); - // An unresolved named reference has unknown decoded width, so an exact - // credential match cannot be proven; withhold the diagnostic. - if (next.folded.includes(NAMED_ENTITY_PLACEHOLDER)) return REDACTED_SECRET; + const next = foldForMatching(view, false); if (next.folded === view) break; offsets = next.map.map(index => offsets[index]!); view = next.folded; } - // Never expose a diagnostic whose encoding exceeds the bounded matching view. - if (foldForMatching(view).folded !== view) return REDACTED_SECRET; - if (UNRESOLVED_ENCODING.test(view)) return REDACTED_SECRET; + // Never expose a diagnostic whose folding exceeds the bounded matching view. + if (foldForMatching(view, false).folded !== view) return REDACTED_SECRET; + if (ENCODING_SYNTAX.test(view)) return REDACTED_SECRET; const ranges: Array<{ start: number; end: number }> = []; for (const secret of secrets) { let needle = secret; - for (let depth = 0; depth < 4; depth++) needle = foldForMatching(needle).folded; + for (let depth = 0; depth < 4; depth++) needle = foldForMatching(needle, false).folded; if (!needle) continue; for (let at = view.indexOf(needle); at !== -1; at = view.indexOf(needle, at + 1)) { ranges.push({ start: offsets[at]!, end: offsets[at + needle.length]! }); diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 9b9ac81ba50..847ef14c89f 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -424,7 +424,7 @@ is composed from the following owners in `src/server/responses/`; none is a gene | `request-spend.ts` | This request's entries in the durable spend ledger: one per physical send, settled from the terminal usage. | | `passthrough-execution.ts` | Native host-lease transfer and the enclosing dispatch/delivery `finally`. | | `passthrough-dispatch.ts` | Native request preparation, upstream sends and pre-commit recovery. | -| `passthrough-delivery.ts`, `terminal-error-redaction.ts`, `non-replayable-error.ts` | Native HTTP/SSE/JSON delivery and terminal accounting. Marked real errors use bounded reads, mask the serving credential in decoded JSON keys/values/scalars, drop upstream headers, and retain status plus non-replayability; diagnostics left with undecoded reference or JSON-escape syntax are withheld, unsafe ones use a fallback, and synthetic refusals stay distinct. SSE diagnostics and xAI tool-envelope filtering precede client delivery/continuation storage. | +| `passthrough-delivery.ts`, `terminal-error-redaction.ts`, `non-replayable-error.ts` | Native HTTP/SSE/JSON delivery and terminal accounting. Marked real errors use bounded reads, mask the serving credential in decoded JSON keys/values/scalars, drop upstream headers, and retain status plus non-replayability; diagnostics containing percent, JSON-escape or HTML reference syntax are withheld rather than decoded, unsafe ones use a fallback, and synthetic refusals stay distinct. SSE diagnostics and xAI tool-envelope filtering precede client delivery/continuation storage. | | `policy-refusal.ts` | Rewrites an allowlisted non-combo HTTP 403 model refusal (`isUpstreamPolicyRefusal` in `src/lib/errors.ts`) from an xAI destination only (`isXaiResponsesDestination`: api.x.ai or the Grok CLI proxy, on either wire) to an HTTP 200 Responses `incomplete` / `content_filter` payload, JSON or SSE, for both `adapter-dispatch.ts` and `passthrough-delivery.ts`. A streamed rewrite takes the turn admission lease and releases it when the body finishes, so the refusal stays inside active-turn accounting. Combo attempts keep the original 403 so failover classifies it as a hop. | | `sidecar-execution.ts` | Image/video versus web-search execution and their shared rotation hook. | | `completion-policy.ts`, `run-turn-execution.ts` | Empty-completion eligibility and adapter-owned event turns. | diff --git a/tests/responses/responses-translated-reset.test.ts b/tests/responses/responses-translated-reset.test.ts index 7f067b5cdf0..1d35ac00fc0 100644 --- a/tests/responses/responses-translated-reset.test.ts +++ b/tests/responses/responses-translated-reset.test.ts @@ -193,13 +193,13 @@ describe("reset replacement error confidentiality", () => { ["percent escape", "%66ixture-active-credential-v1"], ["nested percent escape", "%2566ixture-active-credential-v1"], ["numeric entity", "fixture-active-credential-v1"], - ])(`${adapter} masks encoded diagnostic credentials (%s)`, async (_name, encoded) => { + ])(`${adapter} withholds encoded diagnostic credentials (%s)`, async (_name, encoded) => { const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 ? reset() : Response.json({ error: { message: `invalid request: ${encoded}; path C:\\safe\\u1234.txt` } }, { status: 400 }) }); expect(result.status).toBe(400); expect(result.bodies).toHaveLength(2); const message = JSON.parse(result.text).error.message; - expect(message).toBe("invalid request: [REDACTED]; path C:\\safe\\u1234.txt"); + expect(message).toBe("[REDACTED]"); }); test.each([ @@ -217,13 +217,15 @@ describe("reset replacement error confidentiality", () => { }); test.each([ + ["UTF-8 percent sequence", "éabcdeé-active-v1", "invalid request: %C3%A9%61%62%63%64%65%C3%A9-active-v1"], + ["fullwidth percent", credential, "invalid request: %66ixture-active-credential-v1"], ["nested JSON short escape", "fixture/credential-v1", "invalid request: fixture\\/credential-v1"], ["uppercase hex numeric reference", credential, "invalid request: fixture-active-credential-v1"], ["numeric reference without semicolon", credential, "invalid request: fixture-active-credential-v1"], ["legacy named reference without semicolon", "fixture&credential-v1", "invalid request: fixture&credential-v1"], ["legacy reference before equals", "fixture&credential", "invalid request: fixture&credential="], ["legacy reference before long run", "fixture&" + "a".repeat(40), "invalid request: fixture&" + "a".repeat(40)], - ])(`${adapter} withholds unresolved diagnostic encoding (%s)`, async (_name, apiKey, message) => { + ])(`${adapter} withholds diagnostic encoding (%s)`, async (_name, apiKey, message) => { const result = await probe({ provider: { adapter, apiKey }, answer: ordinal => ordinal === 1 ? reset() : Response.json({ error: { message } }, { status: 400 }) }); expect(result.status).toBe(400); @@ -233,6 +235,14 @@ describe("reset replacement error confidentiality", () => { expect(JSON.parse(result.text)).toEqual({ error: { message: "[REDACTED]" } }); }); + test(`${adapter} masks lookalike diagnostic credentials without encoding`, async () => { + const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 + ? reset() : Response.json({ error: { message: "invalid request: fixture-active-credential-v1" } }, { status: 400 }) }); + expect(result.status).toBe(400); + expect(result.bodies).toHaveLength(2); + expect(JSON.parse(result.text).error.message).toBe("invalid request: [REDACTED]"); + }); + test.each([ ["preserves a query without a reference start", "see https://x.test/a?b=1 for details", "see https://x.test/a?b=1 for details"], ["withholds a query with a reference start", "see https://x.test/a?b=1&c=2", "[REDACTED]"], From 243a5bf68f9a991f2347f409333f5c6700cc8295 Mon Sep 17 00:00:00 2001 From: JUN Date: Mon, 5 Oct 2026 23:55:35 +0900 Subject: [PATCH 07/10] fix(responses): withhold non-ASCII terminal diagnostics Review found Unicode lookalikes (uppercase Greek alpha, ligatures, zero-width splits) that the confusable fold maps differently from the credential, leaving a visually recoverable credential. No fold can be proven equivalent to a reader, so the terminal projection now withholds any diagnostic string containing encoding syntax or a character outside printable ASCII, and otherwise relies on exact replacement of the selected credential. --- .../responses/terminal-error-redaction.ts | 40 +++---------------- structure/transports/responses.md | 2 +- .../responses-translated-reset.test.ts | 19 +++++++-- 3 files changed, 23 insertions(+), 38 deletions(-) diff --git a/src/server/responses/terminal-error-redaction.ts b/src/server/responses/terminal-error-redaction.ts index affc6c44fca..454f4e18dcb 100644 --- a/src/server/responses/terminal-error-redaction.ts +++ b/src/server/responses/terminal-error-redaction.ts @@ -1,41 +1,13 @@ import { REDACTED_SECRET, SENSITIVE_KEY_PATTERN, redactSecrets } from "../../lib/redact"; -import { foldForMatching } from "../../lib/redact-folding"; import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "../sse-payload-rewrite"; -// Encoded spellings are withheld rather than decoded: a bounded decoder cannot -// be proven equivalent to every client decoder. Only terminal diagnostics take this path. +// Terminal diagnostics are withheld whenever they carry encoding syntax or non-ASCII text: +// no bounded decoder or Unicode fold can be proven equivalent to what a client or reader recovers. const ENCODING_SYNTAX = /%[0-9A-Fa-f]{2}|\\(?:u[0-9A-Fa-f]{4}|["\\/bfnrt])|&[A-Za-z#]/; +const NON_PRINTABLE_ASCII = /[^\t\n\r\x20-\x7E]/; -function maskEncodedCredentials(text: string, secrets: string[]): string { - if (ENCODING_SYNTAX.test(text)) return REDACTED_SECRET; - let view = text; - let offsets = Array.from({ length: text.length + 1 }, (_, index) => index); - for (let depth = 0; depth < 4; depth++) { - const next = foldForMatching(view, false); - if (next.folded === view) break; - offsets = next.map.map(index => offsets[index]!); - view = next.folded; - } - // Never expose a diagnostic whose folding exceeds the bounded matching view. - if (foldForMatching(view, false).folded !== view) return REDACTED_SECRET; - if (ENCODING_SYNTAX.test(view)) return REDACTED_SECRET; - const ranges: Array<{ start: number; end: number }> = []; - for (const secret of secrets) { - let needle = secret; - for (let depth = 0; depth < 4; depth++) needle = foldForMatching(needle, false).folded; - if (!needle) continue; - for (let at = view.indexOf(needle); at !== -1; at = view.indexOf(needle, at + 1)) { - ranges.push({ start: offsets[at]!, end: offsets[at + needle.length]! }); - } - } - const merged: typeof ranges = []; - for (const range of ranges.sort((a, b) => a.start - b.start)) { - const previous = merged.at(-1); - if (previous && range.start <= previous.end) previous.end = Math.max(previous.end, range.end); - else merged.push(range); - } - for (const { start, end } of merged.reverse()) text = text.slice(0, start) + REDACTED_SECRET + text.slice(end); - return text; +function withholdUnprovableDiagnostic(text: string): string { + return ENCODING_SYNTAX.test(text) || NON_PRINTABLE_ASCII.test(text) ? REDACTED_SECRET : text; } /** Mask the selected outbound credential even when upstream echoes only its raw value. */ @@ -55,7 +27,7 @@ export function createOutboundCredentialMask( .sort((a, b) => b.length - a.length); return (text) => { const safe = knownSecrets.reduce((value, secret) => value.replaceAll(secret, REDACTED_SECRET), text); - return encodedDiagnostics ? maskEncodedCredentials(safe, knownSecrets) : safe; + return encodedDiagnostics ? withholdUnprovableDiagnostic(safe) : safe; }; } diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 847ef14c89f..7884aa40c33 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -424,7 +424,7 @@ is composed from the following owners in `src/server/responses/`; none is a gene | `request-spend.ts` | This request's entries in the durable spend ledger: one per physical send, settled from the terminal usage. | | `passthrough-execution.ts` | Native host-lease transfer and the enclosing dispatch/delivery `finally`. | | `passthrough-dispatch.ts` | Native request preparation, upstream sends and pre-commit recovery. | -| `passthrough-delivery.ts`, `terminal-error-redaction.ts`, `non-replayable-error.ts` | Native HTTP/SSE/JSON delivery and terminal accounting. Marked real errors use bounded reads, mask the serving credential in decoded JSON keys/values/scalars, drop upstream headers, and retain status plus non-replayability; diagnostics containing percent, JSON-escape or HTML reference syntax are withheld rather than decoded, unsafe ones use a fallback, and synthetic refusals stay distinct. SSE diagnostics and xAI tool-envelope filtering precede client delivery/continuation storage. | +| `passthrough-delivery.ts`, `terminal-error-redaction.ts`, `non-replayable-error.ts` | Native HTTP/SSE/JSON delivery and terminal accounting. Marked real errors use bounded reads, mask the serving credential in decoded JSON keys/values/scalars, drop upstream headers, and retain status plus non-replayability; diagnostics containing percent, JSON-escape or HTML reference syntax, or any non-ASCII text, are withheld rather than decoded, unsafe ones use a fallback, and synthetic refusals stay distinct. SSE diagnostics and xAI tool-envelope filtering precede client delivery/continuation storage. | | `policy-refusal.ts` | Rewrites an allowlisted non-combo HTTP 403 model refusal (`isUpstreamPolicyRefusal` in `src/lib/errors.ts`) from an xAI destination only (`isXaiResponsesDestination`: api.x.ai or the Grok CLI proxy, on either wire) to an HTTP 200 Responses `incomplete` / `content_filter` payload, JSON or SSE, for both `adapter-dispatch.ts` and `passthrough-delivery.ts`. A streamed rewrite takes the turn admission lease and releases it when the body finishes, so the refusal stays inside active-turn accounting. Combo attempts keep the original 403 so failover classifies it as a hop. | | `sidecar-execution.ts` | Image/video versus web-search execution and their shared rotation hook. | | `completion-policy.ts`, `run-turn-execution.ts` | Empty-completion eligibility and adapter-owned event turns. | diff --git a/tests/responses/responses-translated-reset.test.ts b/tests/responses/responses-translated-reset.test.ts index 1d35ac00fc0..c187a965c49 100644 --- a/tests/responses/responses-translated-reset.test.ts +++ b/tests/responses/responses-translated-reset.test.ts @@ -219,13 +219,16 @@ describe("reset replacement error confidentiality", () => { test.each([ ["UTF-8 percent sequence", "éabcdeé-active-v1", "invalid request: %C3%A9%61%62%63%64%65%C3%A9-active-v1"], ["fullwidth percent", credential, "invalid request: %66ixture-active-credential-v1"], + ["uppercase Greek lookalike", "fixture-Active-credential-v1", "invalid request: fixture-\u0391ctive-credential-v1"], + ["ligature lookalike", credential, "invalid request: \uFB01xture-active-credential-v1"], + ["zero-width split", credential, "invalid request: fixture-active-\u200Bcredential-v1"], ["nested JSON short escape", "fixture/credential-v1", "invalid request: fixture\\/credential-v1"], ["uppercase hex numeric reference", credential, "invalid request: fixture-active-credential-v1"], ["numeric reference without semicolon", credential, "invalid request: fixture-active-credential-v1"], ["legacy named reference without semicolon", "fixture&credential-v1", "invalid request: fixture&credential-v1"], ["legacy reference before equals", "fixture&credential", "invalid request: fixture&credential="], ["legacy reference before long run", "fixture&" + "a".repeat(40), "invalid request: fixture&" + "a".repeat(40)], - ])(`${adapter} withholds diagnostic encoding (%s)`, async (_name, apiKey, message) => { + ])(`${adapter} withholds unprovable diagnostic text (%s)`, async (_name, apiKey, message) => { const result = await probe({ provider: { adapter, apiKey }, answer: ordinal => ordinal === 1 ? reset() : Response.json({ error: { message } }, { status: 400 }) }); expect(result.status).toBe(400); @@ -235,12 +238,22 @@ describe("reset replacement error confidentiality", () => { expect(JSON.parse(result.text)).toEqual({ error: { message: "[REDACTED]" } }); }); - test(`${adapter} masks lookalike diagnostic credentials without encoding`, async () => { + test(`${adapter} withholds lookalike diagnostic text`, async () => { const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 ? reset() : Response.json({ error: { message: "invalid request: fixture-active-credential-v1" } }, { status: 400 }) }); expect(result.status).toBe(400); expect(result.bodies).toHaveLength(2); - expect(JSON.parse(result.text).error.message).toBe("invalid request: [REDACTED]"); + expect(JSON.parse(result.text).error.message).toBe("[REDACTED]"); + }); + + test(`${adapter} withholds a non-ASCII diagnostic key`, async () => { + const result = await probe({ provider: { adapter, apiKey: "fixture-Active" }, answer: ordinal => ordinal === 1 + ? reset() : Response.json({ error: { message: "x", ["fixture-\u0391ctive"]: 1 } }, { status: 400 }) }); + expect(result.status).toBe(400); + expect(result.bodies).toHaveLength(2); + expect(result.used).toBe(2); + expect(result.grantSpent).toBe(true); + expect(JSON.parse(result.text)).toEqual({ error: { message: "x", "[REDACTED]": 1 } }); }); test.each([ From 1855977d88ae552732f3175d2ff1576e3ea70eca Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 00:16:29 +0900 Subject: [PATCH 08/10] fix(responses): withhold upstream text from terminal replacement errors Five review rounds each found another way a selected credential survived redaction while upstream diagnostic text was preserved (encodings, Unicode lookalikes, HTML markup, JSON number normalization, marker overlap). No bounded redaction can be proven complete against a client's decoders, so the terminal non-replayable projection now forwards no upstream body text: it keeps the real status, non-replayability, an allowlisted error type and x-should-retry: false, and returns a fixed message. The shared credential mask in terminal-error-redaction.ts returns to its dev form. The confidentiality matrix covers both adapters with every hostile diagnostic shape found in review and asserts the exact generic body, two physical sends, and the spent replacement grant. --- src/server/responses/adapter-dispatch.ts | 3 +- src/server/responses/non-replayable-error.ts | 51 ++-- src/server/responses/passthrough-delivery.ts | 2 +- .../responses/terminal-error-redaction.ts | 22 +- structure/transports/responses-failover.md | 2 +- structure/transports/responses.md | 2 +- .../responses-translated-reset.test.ts | 218 +++++++----------- 7 files changed, 108 insertions(+), 192 deletions(-) diff --git a/src/server/responses/adapter-dispatch.ts b/src/server/responses/adapter-dispatch.ts index fd9f2cbcea0..49338f83583 100644 --- a/src/server/responses/adapter-dispatch.ts +++ b/src/server/responses/adapter-dispatch.ts @@ -706,8 +706,7 @@ export async function prepareAdapterExchange( // This also covers a reset reached by a 401/429/413 recovery refetch. if (isNonReplayableResponse(upstreamResponse)) { try { - return await sanitizeNonReplayableUpstreamError(upstreamResponse, - transportState.sameTargetRequest?.headers ?? builtInitialRequest.headers, upstream.signal); + return await sanitizeNonReplayableUpstreamError(upstreamResponse, upstream.signal); } finally { cleanupUpstreamAbort(); } } if ( diff --git a/src/server/responses/non-replayable-error.ts b/src/server/responses/non-replayable-error.ts index 3d25875f2a9..2f29b668d86 100644 --- a/src/server/responses/non-replayable-error.ts +++ b/src/server/responses/non-replayable-error.ts @@ -1,42 +1,14 @@ -import { REDACTED_SECRET, redactSecrets, redactSecretString } from "../../lib/redact"; import { isNonReplayableResponse, isReplayRefusalResponse, markResponseNonReplayable } from "../../lib/upstream-retry"; import { clientCancelledResponse, readDisplaySafeErrorText } from "./core-errors"; -import { createOutboundCredentialMask } from "./terminal-error-redaction"; -function maskJson(value: unknown, mask: (text: string) => string, depth = 0): unknown { - if (depth > 64) throw new Error("Upstream diagnostic nesting exceeds the display limit"); - if (typeof value === "string") return mask(value); - if (value === null || typeof value === "number" || typeof value === "boolean") { - const text = String(value); - return mask(text) === text ? value : REDACTED_SECRET; - } - if (Array.isArray(value)) return value.map(item => maskJson(item, mask, depth + 1)); - if (value && typeof value === "object") { - return Object.fromEntries(Object.entries(value).map(([key, item]) => [ - mask(key), maskJson(item, mask, depth + 1), - ])); - } - return value; -} - -function diagnosticBody(text: string, mask: (text: string) => string, status: number): string { - const fallback = JSON.stringify({ error: { type: "upstream_error", message: `Provider error ${status}: diagnostic unavailable` } }); - if (!text.trim()) return fallback; - let parsed: unknown; - try { parsed = JSON.parse(text); } - catch { - // Malformed structured diagnostics may contain escaped secrets that cannot be decoded safely. - if (/^[\s]*[\[{\"]/.test(text)) return fallback; - return JSON.stringify({ error: { type: "upstream_error", message: redactSecretString(mask(text)) } }); - } - try { return JSON.stringify(redactSecrets(maskJson(parsed, mask))); } - catch { return fallback; } -} +const UPSTREAM_ERROR_TYPES = new Set([ + "invalid_request_error", "authentication_error", "permission_error", "not_found_error", + "rate_limit_error", "request_too_large", "overloaded_error", "api_error", "server_error", "insufficient_quota", +]); /** Client projection never grants another send or turns a real upstream error into our refusal. */ export async function sanitizeNonReplayableUpstreamError( response: Response, - outboundHeaders: Record, signal: AbortSignal, ): Promise { if (response.ok || !isNonReplayableResponse(response) || isReplayRefusalResponse(response)) return response; @@ -46,10 +18,21 @@ export async function sanitizeNonReplayableUpstreamError( markResponseNonReplayable(cancelled); return cancelled; } - const mask = createOutboundCredentialMask(outboundHeaders, true); + let type = "upstream_error"; + try { + const parsed = JSON.parse(text); + const upstreamType = parsed?.error?.type; + if (typeof upstreamType === "string" && UPSTREAM_ERROR_TYPES.has(upstreamType)) type = upstreamType; + } catch { + // Incomplete or non-JSON bodies retain the generic error type. + } + // Upstream text is not forwarded here: no bounded redaction can be proven complete against a client's decoders. + const body = JSON.stringify({ error: { type, + message: `Provider error ${response.status}: upstream diagnostic withheld after a connection-reset replacement`, + } }); const headers = new Headers({ "content-type": "application/json" }); if (response.headers.get("x-should-retry") === "false") headers.set("x-should-retry", "false"); - const safe = new Response(response.status === 304 ? null : diagnosticBody(text, mask, response.status), { + const safe = new Response(response.status === 304 ? null : body, { status: response.status, headers, }); markResponseNonReplayable(safe); diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index 640edfd27a0..3b4e84baa1e 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -545,7 +545,7 @@ export async function deliverPassthroughResponse( // masquerade as a pre-connection failure after the credential was seen. // The numeric outcome above already classified it neutral — no streak. if (isNonReplayableResponse(upstreamResponse) && !isReplayRefusalResponse(upstreamResponse)) { - return await sanitizeNonReplayableUpstreamError(upstreamResponse, nativeExchange.request.headers, upstream.signal); + return await sanitizeNonReplayableUpstreamError(upstreamResponse, upstream.signal); } if (upstreamResponse.status >= 300 && upstreamResponse.status < 400) { return new Response(upstreamResponse.body, { diff --git a/src/server/responses/terminal-error-redaction.ts b/src/server/responses/terminal-error-redaction.ts index 454f4e18dcb..f8b26d3aedc 100644 --- a/src/server/responses/terminal-error-redaction.ts +++ b/src/server/responses/terminal-error-redaction.ts @@ -1,22 +1,9 @@ import { REDACTED_SECRET, SENSITIVE_KEY_PATTERN, redactSecrets } from "../../lib/redact"; import { replaceSseDataPayload, sseDataPayload, type SseBlockRewrite } from "../sse-payload-rewrite"; -// Terminal diagnostics are withheld whenever they carry encoding syntax or non-ASCII text: -// no bounded decoder or Unicode fold can be proven equivalent to what a client or reader recovers. -const ENCODING_SYNTAX = /%[0-9A-Fa-f]{2}|\\(?:u[0-9A-Fa-f]{4}|["\\/bfnrt])|&[A-Za-z#]/; -const NON_PRINTABLE_ASCII = /[^\t\n\r\x20-\x7E]/; - -function withholdUnprovableDiagnostic(text: string): string { - return ENCODING_SYNTAX.test(text) || NON_PRINTABLE_ASCII.test(text) ? REDACTED_SECRET : text; -} - /** Mask the selected outbound credential even when upstream echoes only its raw value. */ -export function createOutboundCredentialMask( - outboundHeaders: Record, - encodedDiagnostics = false, -): (text: string) => string { - // Match the normalized values actually sent by Fetch, including trimmed header OWS. - const knownSecrets = [...new Headers(outboundHeaders).entries()] +export function createOutboundCredentialMask(outboundHeaders: Record): (text: string) => string { + const knownSecrets = Object.entries(outboundHeaders) .filter(([name]) => SENSITIVE_KEY_PATTERN.test(name)) .flatMap(([name, value]) => { const credential = /^(?:authorization|proxy-authorization)$/i.test(name) @@ -25,10 +12,7 @@ export function createOutboundCredentialMask( return credential ? [credential] : []; }) .sort((a, b) => b.length - a.length); - return (text) => { - const safe = knownSecrets.reduce((value, secret) => value.replaceAll(secret, REDACTED_SECRET), text); - return encodedDiagnostics ? withholdUnprovableDiagnostic(safe) : safe; - }; + return (text) => knownSecrets.reduce((safe, secret) => safe.replaceAll(secret, REDACTED_SECRET), text); } /** Mask upstream diagnostics before either SSE delivery or buffered JSON reconstruction. */ diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index 2a894f4330c..b5f56cee22c 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -374,7 +374,7 @@ the pre-header row in `fetchWithResetRetry` and the WebSocket row alike: | --- | --- | | 2xx | Returned unchanged. | | 307, 308, 401, 402, 408, 409, 413, 429, or any 5xx | Body released; settles as the refusal. | -| Any other status | Real status kept, marked non-replayable; client delivery applies bounded diagnostic redaction. | +| Any other status | Real status and non-replayability kept; bounded client projection retains an allowlisted error type and `x-should-retry: false` when present, withholds upstream body text and all other upstream headers, and emits a fixed generic message. | The refusal set is everything that would send again: the client retry table (408, 409, 429, every 5xx, which the Codex client retries whatever the headers say), a client following a diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 7884aa40c33..1c24bba5b01 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -424,7 +424,7 @@ is composed from the following owners in `src/server/responses/`; none is a gene | `request-spend.ts` | This request's entries in the durable spend ledger: one per physical send, settled from the terminal usage. | | `passthrough-execution.ts` | Native host-lease transfer and the enclosing dispatch/delivery `finally`. | | `passthrough-dispatch.ts` | Native request preparation, upstream sends and pre-commit recovery. | -| `passthrough-delivery.ts`, `terminal-error-redaction.ts`, `non-replayable-error.ts` | Native HTTP/SSE/JSON delivery and terminal accounting. Marked real errors use bounded reads, mask the serving credential in decoded JSON keys/values/scalars, drop upstream headers, and retain status plus non-replayability; diagnostics containing percent, JSON-escape or HTML reference syntax, or any non-ASCII text, are withheld rather than decoded, unsafe ones use a fallback, and synthetic refusals stay distinct. SSE diagnostics and xAI tool-envelope filtering precede client delivery/continuation storage. | +| `passthrough-delivery.ts`, `terminal-error-redaction.ts`, `non-replayable-error.ts` | Native HTTP/SSE/JSON delivery and terminal accounting. Marked real errors use bounded reads to retain only an allowlisted error type, keep status and non-replayability plus `x-should-retry: false` when present, and withhold upstream body text and all other upstream headers; the client receives a fixed generic message and synthetic refusals stay distinct. SSE diagnostics and xAI tool-envelope filtering precede client delivery/continuation storage. | | `policy-refusal.ts` | Rewrites an allowlisted non-combo HTTP 403 model refusal (`isUpstreamPolicyRefusal` in `src/lib/errors.ts`) from an xAI destination only (`isXaiResponsesDestination`: api.x.ai or the Grok CLI proxy, on either wire) to an HTTP 200 Responses `incomplete` / `content_filter` payload, JSON or SSE, for both `adapter-dispatch.ts` and `passthrough-delivery.ts`. A streamed rewrite takes the turn admission lease and releases it when the body finishes, so the refusal stays inside active-turn accounting. Combo attempts keep the original 403 so failover classifies it as a hop. | | `sidecar-execution.ts` | Image/video versus web-search execution and their shared rotation hook. | | `completion-policy.ts`, `run-turn-execution.ts` | Empty-completion eligibility and adapter-owned event turns. | diff --git a/tests/responses/responses-translated-reset.test.ts b/tests/responses/responses-translated-reset.test.ts index c187a965c49..e49234b0f0b 100644 --- a/tests/responses/responses-translated-reset.test.ts +++ b/tests/responses/responses-translated-reset.test.ts @@ -187,40 +187,23 @@ const chatSuccess = (): Response => Response.json({ id: "chat-test", choices: [{ describe("reset replacement error confidentiality", () => { const credential = "fixture-active-credential-v1"; - for (const adapter of ["openai-chat", "openai-responses"] as const) { - test.each([ - ["nested JSON escape", "\\u0066ixture-active-credential-v1"], - ["percent escape", "%66ixture-active-credential-v1"], - ["nested percent escape", "%2566ixture-active-credential-v1"], - ["numeric entity", "fixture-active-credential-v1"], - ])(`${adapter} withholds encoded diagnostic credentials (%s)`, async (_name, encoded) => { - const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 - ? reset() : Response.json({ error: { message: `invalid request: ${encoded}; path C:\\safe\\u1234.txt` } }, { status: 400 }) }); - expect(result.status).toBe(400); - expect(result.bodies).toHaveLength(2); - const message = JSON.parse(result.text).error.message; - expect(message).toBe("[REDACTED]"); - }); - - test.each([ - ["named entity", "fixture+credential-v1", "invalid request: fixture+credential-v1"], - ["percent-encoded named entity", "fixture+credential-v1", "invalid request: fixture%26plus%3Bcredential-v1"], - ["multi-code-point named entity", "fj-credential-v1", "bad key fj-credential-v1"], - ])(`${adapter} withholds unresolved named diagnostic references (%s)`, async (_name, apiKey, message) => { - const result = await probe({ provider: { adapter, apiKey }, answer: ordinal => ordinal === 1 - ? reset() : Response.json({ error: { message } }, { status: 400 }) }); - expect(result.status).toBe(400); - expect(result.bodies).toHaveLength(2); - expect(result.used).toBe(2); - expect(result.grantSpent).toBe(true); - expect(JSON.parse(result.text)).toEqual({ error: { message: "[REDACTED]" } }); - }); - - test.each([ + const genericBody = { error: { type: "upstream_error", + message: "Provider error 400: upstream diagnostic withheld after a connection-reset replacement", + } }; + const diagnostics = [ + ...([ + ["nested JSON escape", credential, "invalid request: \\u0066ixture-active-credential-v1; path C:\\safe\\u1234.txt"], + ["percent escape", credential, "invalid request: %66ixture-active-credential-v1"], + ["nested percent escape", credential, "invalid request: %2566ixture-active-credential-v1"], + ["numeric reference", credential, "invalid request: fixture-active-credential-v1"], + ["named reference", "fixture+credential-v1", "invalid request: fixture+credential-v1"], + ["percent-encoded named reference", "fixture+credential-v1", "invalid request: fixture%26plus%3Bcredential-v1"], + ["multi-code-point named reference", "fj-credential-v1", "bad key fj-credential-v1"], ["UTF-8 percent sequence", "éabcdeé-active-v1", "invalid request: %C3%A9%61%62%63%64%65%C3%A9-active-v1"], ["fullwidth percent", credential, "invalid request: %66ixture-active-credential-v1"], ["uppercase Greek lookalike", "fixture-Active-credential-v1", "invalid request: fixture-\u0391ctive-credential-v1"], ["ligature lookalike", credential, "invalid request: \uFB01xture-active-credential-v1"], + ["fullwidth lookalike", credential, "invalid request: fixture-active-credential-v1"], ["zero-width split", credential, "invalid request: fixture-active-\u200Bcredential-v1"], ["nested JSON short escape", "fixture/credential-v1", "invalid request: fixture\\/credential-v1"], ["uppercase hex numeric reference", credential, "invalid request: fixture-active-credential-v1"], @@ -228,130 +211,102 @@ describe("reset replacement error confidentiality", () => { ["legacy named reference without semicolon", "fixture&credential-v1", "invalid request: fixture&credential-v1"], ["legacy reference before equals", "fixture&credential", "invalid request: fixture&credential="], ["legacy reference before long run", "fixture&" + "a".repeat(40), "invalid request: fixture&" + "a".repeat(40)], - ])(`${adapter} withholds unprovable diagnostic text (%s)`, async (_name, apiKey, message) => { - const result = await probe({ provider: { adapter, apiKey }, answer: ordinal => ordinal === 1 - ? reset() : Response.json({ error: { message } }, { status: 400 }) }); - expect(result.status).toBe(400); - expect(result.bodies).toHaveLength(2); - expect(result.used).toBe(2); - expect(result.grantSpent).toBe(true); - expect(JSON.parse(result.text)).toEqual({ error: { message: "[REDACTED]" } }); - }); - - test(`${adapter} withholds lookalike diagnostic text`, async () => { - const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 - ? reset() : Response.json({ error: { message: "invalid request: fixture-active-credential-v1" } }, { status: 400 }) }); - expect(result.status).toBe(400); - expect(result.bodies).toHaveLength(2); - expect(JSON.parse(result.text).error.message).toBe("[REDACTED]"); - }); - - test(`${adapter} withholds a non-ASCII diagnostic key`, async () => { - const result = await probe({ provider: { adapter, apiKey: "fixture-Active" }, answer: ordinal => ordinal === 1 - ? reset() : Response.json({ error: { message: "x", ["fixture-\u0391ctive"]: 1 } }, { status: 400 }) }); - expect(result.status).toBe(400); - expect(result.bodies).toHaveLength(2); - expect(result.used).toBe(2); - expect(result.grantSpent).toBe(true); - expect(JSON.parse(result.text)).toEqual({ error: { message: "x", "[REDACTED]": 1 } }); - }); + ["over-nested percent encoding", credential, "invalid request: %2525252566ixture-active-credential-v1"], + ["HTML split", credential, "invalid request: fixture-active-credential-v1"], + ["query without reference start", credential, "see https://x.test/a?b=1 for details"], + ["query with reference start", credential, "see https://x.test/a?b=1&c=2"], + ["redaction marker overlap", "REDACTED", "api_key=REDACTED"], + ] as const).map(([name, apiKey, message]) => ({ name, apiKey, + text: JSON.stringify({ error: { message } }), contentType: "application/json", type: "upstream_error" })), + ...([ + ["plain echo", credential, `invalid request: ${credential}`, "text/plain"], + ["JSON unicode escape", credential, JSON.stringify({ error: { message: credential } }).replaceAll("f", "\\u0066"), "application/json"], + ["JSON short escape", "fixture/credential-v1", '{"error":{"message":"fixture\\/credential-v1"}}', "application/json"], + ["numeric credential normalized to exponent", "1000000000000000000000", '{"error":{"code":1e21}}', "application/json"], + ["numeric scalar credential", "867530912345", '{"error":{"message":"invalid request","code":867530912345,"count":17,"flag":false}}', "application/json"], + ["boolean scalar credential", "true", '{"error":{"message":"x","code":true}}', "application/json"], + ["null scalar credential", "null", '{"error":{"message":"x","code":null}}', "application/json"], + ["credential as JSON key", credential, JSON.stringify({ error: { [credential]: 1 } }), "application/json"], + ["non-ASCII JSON key", "fixture-Active", JSON.stringify({ error: { message: "x", ["fixture-\u0391ctive"]: 1 } }), "application/json"], + ["array body", credential, JSON.stringify([credential, { message: `invalid request: ${credential}` }, 17]), "application/json"], + ["non-2xx SSE body", credential, `event: response.failed\ndata: {"type":"response.failed","response":{"error":{"message":"invalid request: ${credential}"}}}\n\n`, "text/event-stream"], + ["malformed JSON", credential, `{"error":{"message":"invalid request: ${credential}"`, "application/json"], + ["over-nested JSON", credential, '{"detail":'.repeat(65) + JSON.stringify(credential) + "}".repeat(65), "application/json"], + ["escaped credential in every JSON field", credential, JSON.stringify({ error: { message: credential, type: credential, code: credential }, + [credential]: credential }).replaceAll(credential, credential.replaceAll("f", "\\u0066")), "application/json"], + ] as const).map(([name, apiKey, text, contentType]) => ({ name, apiKey, text, contentType, type: "upstream_error" })), + { name: "allowlisted type with hostile message", apiKey: credential, + text: JSON.stringify({ error: { type: "invalid_request_error", message: credential } }), + contentType: "application/json", type: "invalid_request_error" }, + ]; + test.each((["openai-chat", "openai-responses"] as const).flatMap(adapter => + diagnostics.map(diagnostic => [adapter, diagnostic.name, diagnostic] as const), + ))("%s withholds upstream diagnostics (%s)", async (adapter, _name, diagnostic) => { + const result = await probe({ provider: { adapter, apiKey: diagnostic.apiKey }, answer: ordinal => ordinal === 1 + ? reset() : new Response(diagnostic.text, { status: 400, headers: { "content-type": diagnostic.contentType } }) }); + expect(result.authorizations).toEqual([`Bearer ${diagnostic.apiKey}`, `Bearer ${diagnostic.apiKey}`]); + expect(result.status).toBe(400); + expect(result.bodies).toHaveLength(2); + expect(result.used).toBe(2); + expect(result.grantSpent).toBe(true); + expect(JSON.parse(result.text)).toEqual({ error: { type: diagnostic.type, message: genericBody.error.message } }); + expect(result.text).not.toContain(diagnostic.apiKey); + }); - test.each([ - ["preserves a query without a reference start", "see https://x.test/a?b=1 for details", "see https://x.test/a?b=1 for details"], - ["withholds a query with a reference start", "see https://x.test/a?b=1&c=2", "[REDACTED]"], - ])(`${adapter} %s with an unrelated credential`, async (_name, message, expected) => { + for (const adapter of ["openai-chat", "openai-responses"] as const) { + test(`${adapter} preserves an allowlisted upstream error type`, async () => { const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 - ? reset() : Response.json({ error: { message } }, { status: 400 }) }); + ? reset() : Response.json({ error: { type: "invalid_request_error", message: credential } }, { status: 400 }) }); expect(result.status).toBe(400); expect(result.bodies).toHaveLength(2); expect(result.used).toBe(2); expect(result.grantSpent).toBe(true); - expect(JSON.parse(result.text)).toEqual({ error: { message: expected } }); + expect(JSON.parse(result.text)).toEqual({ error: { type: "invalid_request_error", message: genericBody.error.message } }); + expect(result.text).not.toContain(credential); }); - test.each([ - ["malformed structured JSON", `{"error":{"message":"invalid request: ${credential}"`, "application/json", - { error: { type: "upstream_error", message: "Provider error 400: diagnostic unavailable" } }], - ["JSON nested beyond the display limit", '{"detail":'.repeat(65) + JSON.stringify(credential) + "}".repeat(65), "application/json", - { error: { type: "upstream_error", message: "Provider error 400: diagnostic unavailable" } }], - ["array diagnostic", JSON.stringify([credential, { message: `invalid request: ${credential}` }, 17]), "application/json", - ["[REDACTED]", { message: "invalid request: [REDACTED]" }, 17]], - ["non-2xx SSE diagnostic", `event: response.failed\ndata: {"type":"response.failed","response":{"error":{"message":"invalid request: ${credential}"}}}\n\n`, "text/event-stream", - { error: { type: "upstream_error", message: 'event: response.failed\ndata: {"type":"response.failed","response":{"error":{"message":"invalid request: [REDACTED]"}}}\n\n' } }], - ] as const)(`${adapter} safely projects %s`, async (_name, text, contentType, expected) => { + test(`${adapter} rejects a credential-valued upstream error type`, async () => { const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 - ? reset() : new Response(text, { status: 400, headers: { "content-type": contentType } }) }); + ? reset() : Response.json({ error: { type: credential } }, { status: 400 }) }); expect(result.status).toBe(400); expect(result.bodies).toHaveLength(2); expect(result.used).toBe(2); expect(result.grantSpent).toBe(true); - expect(JSON.parse(result.text)).toEqual(expected); + expect(JSON.parse(result.text)).toEqual(genericBody); + expect(result.text).not.toContain(credential); }); test(`${adapter} drops upstream diagnostic headers and preserves retry refusal`, async () => { const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 ? reset() : Response.json({ error: { message: `invalid request: ${credential}` } }, { status: 400, headers: { - "x-debug-token": credential, "set-cookie": `diagnostic=${credential}`, "x-should-retry": "false", + "x-debug-token": credential, "x-upstream-debug": credential, "set-cookie": `diagnostic=${credential}`, + "retry-after": "0", "x-should-retry": "false", } }) }); expect(result.status).toBe(400); expect(result.bodies).toHaveLength(2); expect(result.used).toBe(2); expect(result.grantSpent).toBe(true); - expect(JSON.parse(result.text)).toEqual({ error: { message: "invalid request: [REDACTED]" } }); + expect(JSON.parse(result.text)).toEqual(genericBody); + expect(result.text).not.toContain(credential); + expect([...result.headers.entries()]).toEqual([["content-type", "application/json"], ["x-should-retry", "false"]]); expect(result.headers.get("x-debug-token")).toBeNull(); + expect(result.headers.get("x-upstream-debug")).toBeNull(); expect(result.headers.get("set-cookie")).toBeNull(); + expect(result.headers.get("retry-after")).toBeNull(); expect(result.headers.get("x-should-retry")).toBe("false"); }); - test.each([ - ["number", "867530912345", { message: "invalid request", code: 867530912345, count: 17, flag: false }, - { message: "invalid request", code: "[REDACTED]", count: 17, flag: false }], - ["boolean", "true", { message: "x", code: true }, { message: "x", code: "[REDACTED]" }], - ["null", "null", { message: "x", code: null }, { message: "x", code: "[REDACTED]" }], - ] as const)(`${adapter} masks scalar diagnostic credentials (%s)`, async (_name, apiKey, error, expected) => { - const result = await probe({ provider: { adapter, apiKey }, answer: ordinal => ordinal === 1 - ? reset() : Response.json({ error }, { status: 400 }) }); - expect(result.authorizations).toEqual([`Bearer ${apiKey}`, `Bearer ${apiKey}`]); - expect(result.status).toBe(400); - expect(result.bodies).toHaveLength(2); - expect(result.used).toBe(2); - expect(result.grantSpent).toBe(true); - expect(JSON.parse(result.text)).toEqual({ error: expected }); - }); - - test(`${adapter} masks the actual wire credential after header OWS normalization`, async () => { + test(`${adapter} withholds diagnostics after header OWS normalization`, async () => { const result = await probe({ provider: { adapter, apiKey: `${credential} ` }, answer: (ordinal, authorization) => ordinal === 1 ? reset() : Response.json({ error: { message: `invalid request: ${authorization!.replace(/^Bearer /, "")}` } }, { status: 400 }) }); expect(result.authorizations).toEqual([`Bearer ${credential}`, `Bearer ${credential}`]); expect(result.status).toBe(400); expect(result.bodies).toHaveLength(2); - expect(JSON.parse(result.text).error.message).toBe("invalid request: [REDACTED]"); - }); - - test.each(["plain", "escaped-json"])(`${adapter} redacts replacement diagnostics (%s)`, async shape => { - const diagnostic = `invalid request: ${credential}`; - const text = shape === "plain" ? diagnostic : JSON.stringify({ error: { - message: diagnostic, type: credential, code: credential, - }, [credential]: credential }).replaceAll(credential, credential.replaceAll("f", "\\u0066")); - const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 - ? reset() : new Response(text, { status: 400, headers: { - "content-type": "application/json", "x-upstream-debug": credential, - "set-cookie": `diagnostic=${credential}`, "retry-after": "0", - } }) }); - expect(result.status).toBe(400); - expect(result.text).toContain("invalid request"); - expect(result.text).not.toContain(credential); - expect(result.text).not.toContain("\\u0066ixture-active"); - expect(result.text).toContain("[REDACTED]"); - expect(result.headers.get("x-upstream-debug")).toBeNull(); - expect(result.headers.get("set-cookie")).toBeNull(); - expect(result.headers.get("retry-after")).toBeNull(); - expect(result.text).not.toContain("upstream_reset_replay_refused"); - expect(result.bodies).toHaveLength(2); + expect(JSON.parse(result.text)).toEqual(genericBody); }); } - test("redacts the serving credential after an OAuth account replacement", async () => { + test("withholds diagnostics after an OAuth account replacement", async () => { const result = await probe({ oauth: true, providerName: "xai", provider: { adapter: "openai-chat", baseUrl: "https://api.x.ai/v1", authMode: "oauth", models: ["test"] }, body: { model: "xai/test" }, answer: (ordinal, authorization) => ordinal === 1 @@ -360,18 +315,10 @@ describe("reset replacement error confidentiality", () => { expect(result.status).toBe(400); expect(result.authorizations[0]).not.toBe(result.authorizations[2]); expect(result.text).not.toContain(result.authorizations[2]!.replace(/^Bearer /, "")); - expect(result.text).toContain("[REDACTED]"); + expect(JSON.parse(result.text)).toEqual(genericBody); expect(result.bodies).toHaveLength(3); }); - test("over-nested diagnostic encoding fails closed within the matching limit", async () => { - const upstream = Response.json({ error: { message: "%2525252566ixture-active-credential-v1" } }, { status: 400 }); - markResponseNonReplayable(upstream); - const safe = await sanitizeNonReplayableUpstreamError(upstream, { authorization: `Bearer ${credential}` }, new AbortController().signal); - expect(safe.status).toBe(400); - expect((await safe.json() as { error: { message: string } }).error.message).toBe("[REDACTED]"); - }); - test.each(["openai-chat", "openai-responses"] as const)("%s keeps a replacement terminal through combo consumption", async adapter => { const result = await probe({ provider: { adapter, apiKey: credential, models: ["test", "other"] }, config: { combos: { pair: { strategy: "failover", targets: [ @@ -381,34 +328,34 @@ describe("reset replacement error confidentiality", () => { : chatSuccess() }); expect(result.status).toBe(400); expect(result.text).not.toContain(credential); - expect(result.text).toContain("[REDACTED]"); + expect(JSON.parse(result.text)).toEqual(genericBody); expect(result.bodies).toHaveLength(2); }); test("the client projection preserves marker provenance before lifetime wrapping", async () => { const upstream = Response.json({ error: { message: `invalid request: ${credential}` } }, { status: 400 }); markResponseNonReplayable(upstream); - const safe = await sanitizeNonReplayableUpstreamError(upstream, { authorization: `Bearer ${credential}` }, new AbortController().signal); + const safe = await sanitizeNonReplayableUpstreamError(upstream, new AbortController().signal); expect(safe.status).toBe(400); expect(isNonReplayableResponse(safe)).toBe(true); expect(isReplayRefusalResponse(safe)).toBe(false); expect(await safe.text()).not.toContain(credential); const refusal = replayRefusalResponse(); - expect(await sanitizeNonReplayableUpstreamError(refusal, {}, new AbortController().signal)).toBe(refusal); + expect(await sanitizeNonReplayableUpstreamError(refusal, new AbortController().signal)).toBe(refusal); expect(isReplayRefusalResponse(refusal)).toBe(true); await refusal.text(); }); test("an ordinary error keeps its existing delivery owner", async () => { const upstream = Response.json({ error: { message: "ordinary error" } }, { status: 400 }); - expect(await sanitizeNonReplayableUpstreamError(upstream, {}, new AbortController().signal)).toBe(upstream); + expect(await sanitizeNonReplayableUpstreamError(upstream, new AbortController().signal)).toBe(upstream); await upstream.text(); }); test("bodyless terminal responses retain their real status", async () => { const upstream = new Response(null, { status: 304, headers: { "x-upstream-debug": credential } }); markResponseNonReplayable(upstream); - const safe = await sanitizeNonReplayableUpstreamError(upstream, { "x-api-key": credential }, new AbortController().signal); + const safe = await sanitizeNonReplayableUpstreamError(upstream, new AbortController().signal); expect(safe.status).toBe(304); expect(safe.body).toBeNull(); expect(safe.headers.get("x-upstream-debug")).toBeNull(); @@ -427,7 +374,7 @@ describe("reset replacement error confidentiality", () => { cancel() { cancelled = true; }, }), { status: 400 }); markResponseNonReplayable(upstream); - const safe = await sanitizeNonReplayableUpstreamError(upstream, { "x-api-key": credential }, abort.signal); + const safe = await sanitizeNonReplayableUpstreamError(upstream, abort.signal); expect(safe.status).toBe(abortRead ? 499 : 400); expect(await safe.text()).not.toContain(credential); expect(cancelled).toBe(true); @@ -464,7 +411,10 @@ describe("translated reset replay boundaries and accounting", () => { answer: ordinal => ordinal === 1 ? reset() : status === 400 ? effortRefusal() : Response.json({ error: { message: "recoverable-looking failure" } }, { status }) }); expect(result.status, result.text).toBe(status === 400 ? 400 : 429); - expect(result.text).toContain(status === 400 ? "reasoning_effort max is not supported" : "upstream_reset_replay_refused"); + if (status === 400) expect(JSON.parse(result.text)).toEqual({ error: { type: "invalid_request_error", + message: "Provider error 400: upstream diagnostic withheld after a connection-reset replacement", + } }); + else expect(result.text).toContain("upstream_reset_replay_refused"); expect(result.bodies).toHaveLength(2); expect(result.bodies[0]).toBe(result.bodies[1]); expect(result.grantSpent).toBe(true); From e7e71c0a9d0886abbdc9f457583c3b50f91e1306 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 00:24:31 +0900 Subject: [PATCH 09/10] test(responses): assert combo restates only the sanitized terminal projection The combo owner reformats a consumed child failure, so the exact generic body is not the combo contract; assert the generic phrase survives, the upstream diagnostic and credential do not, and the terminal stop keeps two sends. --- tests/responses/responses-translated-reset.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/responses/responses-translated-reset.test.ts b/tests/responses/responses-translated-reset.test.ts index e49234b0f0b..72806b50a8c 100644 --- a/tests/responses/responses-translated-reset.test.ts +++ b/tests/responses/responses-translated-reset.test.ts @@ -328,7 +328,9 @@ describe("reset replacement error confidentiality", () => { : chatSuccess() }); expect(result.status).toBe(400); expect(result.text).not.toContain(credential); - expect(JSON.parse(result.text)).toEqual(genericBody); + // The combo owner reformats the consumed failure; it can only restate the sanitized projection. + expect(JSON.parse(result.text).error.message).toContain(genericBody.error.message); + expect(result.text).not.toContain("context length exceeded"); expect(result.bodies).toHaveLength(2); }); From 0ceb2fbdd8f2eb76e6cbd1da74ed195667cdb295 Mon Sep 17 00:00:00 2001 From: JUN Date: Tue, 6 Oct 2026 00:50:48 +0900 Subject: [PATCH 10/10] fix(responses): generic message for every marked origin; allowlist error code Responses are marked non-replayable by more than reset replacements (WS socket death, compaction, unsafe run-turn activity), so the withheld-text message now says "for a non-replayable failure". A closed allowlist of machine error codes (context_length_exceeded, rate_limit_exceeded, ...) is kept alongside the allowlisted type so clients retain an actionable signal. Updates the two hosted-CI failures that asserted relayed upstream text on marked responses (console-go upload boundary, WS pool-mode replacement 400). --- src/server/responses/non-replayable-error.ts | 12 ++++++-- structure/transports/responses-failover.md | 2 +- structure/transports/responses.md | 2 +- .../responses-console-go-upload-retry.test.ts | 4 ++- .../responses-translated-reset.test.ts | 28 +++++++++++++++++-- tests/responses/ws-ambiguous-resend.test.ts | 5 +++- 6 files changed, 45 insertions(+), 8 deletions(-) diff --git a/src/server/responses/non-replayable-error.ts b/src/server/responses/non-replayable-error.ts index 2f29b668d86..53a21a42008 100644 --- a/src/server/responses/non-replayable-error.ts +++ b/src/server/responses/non-replayable-error.ts @@ -5,6 +5,11 @@ const UPSTREAM_ERROR_TYPES = new Set([ "invalid_request_error", "authentication_error", "permission_error", "not_found_error", "rate_limit_error", "request_too_large", "overloaded_error", "api_error", "server_error", "insufficient_quota", ]); +const UPSTREAM_ERROR_CODES = new Set([ + "context_length_exceeded", "rate_limit_exceeded", "insufficient_quota", "invalid_api_key", + "model_not_found", "invalid_prompt", "content_policy_violation", "server_error", + "unsupported_parameter", "invalid_value", "string_above_max_length", +]); /** Client projection never grants another send or turns a real upstream error into our refusal. */ export async function sanitizeNonReplayableUpstreamError( @@ -19,16 +24,19 @@ export async function sanitizeNonReplayableUpstreamError( return cancelled; } let type = "upstream_error"; + let code: string | undefined; try { const parsed = JSON.parse(text); const upstreamType = parsed?.error?.type; + const upstreamCode = parsed?.error?.code; if (typeof upstreamType === "string" && UPSTREAM_ERROR_TYPES.has(upstreamType)) type = upstreamType; + if (typeof upstreamCode === "string" && UPSTREAM_ERROR_CODES.has(upstreamCode)) code = upstreamCode; } catch { // Incomplete or non-JSON bodies retain the generic error type. } // Upstream text is not forwarded here: no bounded redaction can be proven complete against a client's decoders. - const body = JSON.stringify({ error: { type, - message: `Provider error ${response.status}: upstream diagnostic withheld after a connection-reset replacement`, + const body = JSON.stringify({ error: { type, ...(code === undefined ? {} : { code }), + message: `Provider error ${response.status}: upstream diagnostic withheld for a non-replayable failure`, } }); const headers = new Headers({ "content-type": "application/json" }); if (response.headers.get("x-should-retry") === "false") headers.set("x-should-retry", "false"); diff --git a/structure/transports/responses-failover.md b/structure/transports/responses-failover.md index b5f56cee22c..4e6e0a55180 100644 --- a/structure/transports/responses-failover.md +++ b/structure/transports/responses-failover.md @@ -374,7 +374,7 @@ the pre-header row in `fetchWithResetRetry` and the WebSocket row alike: | --- | --- | | 2xx | Returned unchanged. | | 307, 308, 401, 402, 408, 409, 413, 429, or any 5xx | Body released; settles as the refusal. | -| Any other status | Real status and non-replayability kept; bounded client projection retains an allowlisted error type and `x-should-retry: false` when present, withholds upstream body text and all other upstream headers, and emits a fixed generic message. | +| Any other status | Real status and non-replayability kept; bounded client projection retains allowlisted error type and code and `x-should-retry: false` when present, withholds upstream body text and all other upstream headers, and emits a fixed generic message. | The refusal set is everything that would send again: the client retry table (408, 409, 429, every 5xx, which the Codex client retries whatever the headers say), a client following a diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 1c24bba5b01..84fe86bde48 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -424,7 +424,7 @@ is composed from the following owners in `src/server/responses/`; none is a gene | `request-spend.ts` | This request's entries in the durable spend ledger: one per physical send, settled from the terminal usage. | | `passthrough-execution.ts` | Native host-lease transfer and the enclosing dispatch/delivery `finally`. | | `passthrough-dispatch.ts` | Native request preparation, upstream sends and pre-commit recovery. | -| `passthrough-delivery.ts`, `terminal-error-redaction.ts`, `non-replayable-error.ts` | Native HTTP/SSE/JSON delivery and terminal accounting. Marked real errors use bounded reads to retain only an allowlisted error type, keep status and non-replayability plus `x-should-retry: false` when present, and withhold upstream body text and all other upstream headers; the client receives a fixed generic message and synthetic refusals stay distinct. SSE diagnostics and xAI tool-envelope filtering precede client delivery/continuation storage. | +| `passthrough-delivery.ts`, `terminal-error-redaction.ts`, `non-replayable-error.ts` | Native HTTP/SSE/JSON delivery and terminal accounting. Marked real errors use bounded reads to retain only allowlisted error type and code, keep status and non-replayability plus `x-should-retry: false` when present, and withhold upstream body text and all other upstream headers; the client receives a fixed generic message and synthetic refusals stay distinct. SSE diagnostics and xAI tool-envelope filtering precede client delivery/continuation storage. | | `policy-refusal.ts` | Rewrites an allowlisted non-combo HTTP 403 model refusal (`isUpstreamPolicyRefusal` in `src/lib/errors.ts`) from an xAI destination only (`isXaiResponsesDestination`: api.x.ai or the Grok CLI proxy, on either wire) to an HTTP 200 Responses `incomplete` / `content_filter` payload, JSON or SSE, for both `adapter-dispatch.ts` and `passthrough-delivery.ts`. A streamed rewrite takes the turn admission lease and releases it when the body finishes, so the refusal stays inside active-turn accounting. Combo attempts keep the original 403 so failover classifies it as a hop. | | `sidecar-execution.ts` | Image/video versus web-search execution and their shared rotation hook. | | `completion-policy.ts`, `run-turn-execution.ts` | Empty-completion eligibility and adapter-owned event turns. | diff --git a/tests/responses/responses-console-go-upload-retry.test.ts b/tests/responses/responses-console-go-upload-retry.test.ts index 75805f3c64e..3151c48ce8d 100644 --- a/tests/responses/responses-console-go-upload-retry.test.ts +++ b/tests/responses/responses-console-go-upload-retry.test.ts @@ -282,7 +282,9 @@ describe("Console nonreplayable response boundary", () => { const response = await handleResponses(request(), cfg, { model: "", provider: "" }); expect(response.status).toBe(400); expect(sends).toBe(1); - expect(await response.text()).toContain("Invalid upload request."); + expect(await response.json()).toEqual({ error: { type: "invalid_request_error", + message: "Provider error 400: upstream diagnostic withheld for a non-replayable failure", + } }); }); } }); diff --git a/tests/responses/responses-translated-reset.test.ts b/tests/responses/responses-translated-reset.test.ts index 72806b50a8c..545245da5ad 100644 --- a/tests/responses/responses-translated-reset.test.ts +++ b/tests/responses/responses-translated-reset.test.ts @@ -188,7 +188,7 @@ const chatSuccess = (): Response => Response.json({ id: "chat-test", choices: [{ describe("reset replacement error confidentiality", () => { const credential = "fixture-active-credential-v1"; const genericBody = { error: { type: "upstream_error", - message: "Provider error 400: upstream diagnostic withheld after a connection-reset replacement", + message: "Provider error 400: upstream diagnostic withheld for a non-replayable failure", } }; const diagnostics = [ ...([ @@ -265,6 +265,30 @@ describe("reset replacement error confidentiality", () => { expect(result.text).not.toContain(credential); }); + test(`${adapter} preserves an allowlisted upstream error code without its diagnostic`, async () => { + const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 + ? reset() : Response.json({ error: { type: "invalid_request_error", code: "context_length_exceeded", message: credential } }, { status: 400 }) }); + expect(result.status).toBe(400); + expect(result.bodies).toHaveLength(2); + expect(result.used).toBe(2); + expect(result.grantSpent).toBe(true); + expect(JSON.parse(result.text)).toEqual({ error: { type: "invalid_request_error", + code: "context_length_exceeded", message: genericBody.error.message } }); + expect(result.text).not.toContain(credential); + }); + + test.each(["unrecognized_error_code", credential])(`${adapter} drops an unapproved upstream error code (%s)`, async code => { + const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 + ? reset() : Response.json({ error: { type: "invalid_request_error", code, message: credential } }, { status: 400 }) }); + expect(result.status).toBe(400); + expect(result.bodies).toHaveLength(2); + expect(result.used).toBe(2); + expect(result.grantSpent).toBe(true); + expect(JSON.parse(result.text)).toEqual({ error: { type: "invalid_request_error", message: genericBody.error.message } }); + expect(result.text).not.toContain(code); + expect(result.text).not.toContain(credential); + }); + test(`${adapter} rejects a credential-valued upstream error type`, async () => { const result = await probe({ provider: { adapter, apiKey: credential }, answer: ordinal => ordinal === 1 ? reset() : Response.json({ error: { type: credential } }, { status: 400 }) }); @@ -414,7 +438,7 @@ describe("translated reset replay boundaries and accounting", () => { : Response.json({ error: { message: "recoverable-looking failure" } }, { status }) }); expect(result.status, result.text).toBe(status === 400 ? 400 : 429); if (status === 400) expect(JSON.parse(result.text)).toEqual({ error: { type: "invalid_request_error", - message: "Provider error 400: upstream diagnostic withheld after a connection-reset replacement", + message: "Provider error 400: upstream diagnostic withheld for a non-replayable failure", } }); else expect(result.text).toContain("upstream_reset_replay_refused"); expect(result.bodies).toHaveLength(2); diff --git a/tests/responses/ws-ambiguous-resend.test.ts b/tests/responses/ws-ambiguous-resend.test.ts index 36e12ae4cb1..2fea2eeb0ed 100644 --- a/tests/responses/ws-ambiguous-resend.test.ts +++ b/tests/responses/ws-ambiguous-resend.test.ts @@ -375,7 +375,10 @@ describe("handleResponses replaces a dead socket's send once under retryOnReset expect(http[0]!.get("chatgpt-account-id")).toBe("acc-work"); if (status === 400) { expect(response.status).toBe(400); - expect(await response.text()).toBe(body); + // The fixture has only detail, so neither an allowlisted error type nor code survives. + expect(await response.json()).toEqual({ error: { type: "upstream_error", + message: "Provider error 400: upstream diagnostic withheld for a non-replayable failure", + } }); } else { expect(response.status).toBe(REPLAY_REFUSED_STATUS); expect(response.headers.get("x-should-retry")).toBe("false");