From a4889429d3b15e69e09eb9a8677edf0013958669 Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 8 Oct 2026 10:20:03 +0900 Subject: [PATCH 1/3] fix(responses): relay undeclared passthrough tools for chat and anthropic --- scripts/test-layout/layout.json | 2 +- src/server/responses-undeclared-tool-guard.ts | 6 +- src/server/responses/passthrough-delivery.ts | 9 +- structure/transports/responses-wire-shapes.md | 10 +- tests/fixtures/test-layout-expected.json | 2 +- .../passthrough-deferred-tools.test.ts | 155 ++++++++++++++++++ 6 files changed, 173 insertions(+), 11 deletions(-) create mode 100644 tests/responses/passthrough-deferred-tools.test.ts diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 8d1f0b60845..9eb53b2a46f 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -283,7 +283,7 @@ "catalog-verbosity-default.test.ts": "codex-integration", "catalog-vision-sidecar-modalities.test.ts": "codex-integration", "catalog-zero-credit-picker.test.ts": "codex-integration", - "chat-completions-deferred-tools.test.ts": "responses", + "chat-completions-deferred-tools.test.ts": "responses", "passthrough-deferred-tools.test.ts": "responses", "chat-completions-endpoint.test.ts": "responses", "chat-completions-pool-mode.test.ts": "responses", "chat-conversation-affinity.test.ts": "responses", diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index 52cc338e509..905454044af 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -703,6 +703,7 @@ function failedBlocks(name: string, newline: string): readonly string[] { * @param providerExecutedCallTypes - Call types executed by the provider. * @param declaredBare - Explicitly declared bare tool names without namespace provenance. * @param declaredCustom - Current bare custom declarations eligible for code-mode recovery. + * @param enforceDeclaredToolNames - Refuse undeclared calls; normalization remains active when false. * @returns An SSE block rewrite function. */ export function createUndeclaredToolCallGuardBlockRewrite( @@ -711,6 +712,7 @@ export function createUndeclaredToolCallGuardBlockRewrite( providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, declaredBare?: ReadonlySet, declaredCustom?: ReadonlySet, + enforceDeclaredToolNames = true, ): SseBlockRewrite { let tripped = false; return (block: string) => { @@ -723,7 +725,9 @@ export function createUndeclaredToolCallGuardBlockRewrite( } catch { return [block]; } - const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare, declaredCustom); + const name = enforceDeclaredToolNames + ? undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare, declaredCustom) + : undefined; if (name !== undefined) { tripped = true; return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n"); diff --git a/src/server/responses/passthrough-delivery.ts b/src/server/responses/passthrough-delivery.ts index 3b4e84baa1e..5a99d8a466e 100644 --- a/src/server/responses/passthrough-delivery.ts +++ b/src/server/responses/passthrough-delivery.ts @@ -364,6 +364,7 @@ export async function deliverPassthroughResponse( | "subagentFallbackAccountId" | "clientRequestedStream" | "translatorBudget" + | "inboundWire" >, transportState: Pick, sidecarState: Pick, @@ -428,7 +429,8 @@ export async function deliverPassthroughResponse( normalizeFunctionCompletionJson, } = nativeExchange; const { commitReasoningReplayServingRoute, recordTerminalOutcomes } = responseEffects; - const { parsed, route, subagentQuotaFailureModel, clientRequestedStream, translatorBudget } = requestState; + const { parsed, route, subagentQuotaFailureModel, clientRequestedStream, translatorBudget, inboundWire } = requestState; + const enforceDeclaredToolNames = inboundWire !== "chat" && inboundWire !== "anthropic"; const { openAiSidecar } = sidecarState; const { requestBindings } = transportState; @@ -836,6 +838,7 @@ export async function deliverPassthroughResponse( providerExecutedCallTypes, declaredBareWireToolNames, recoverableBareCustomWireToolNames, + enforceDeclaredToolNames, ) : undefined, grokUpstreamEchoEnabled @@ -1293,7 +1296,7 @@ export async function deliverPassthroughResponse( if (grokUpstreamEchoEnabled) { clientJson = stripGrokUpstreamEnvelopeEchoFromResponsesJson(clientJson); } - // #1700: same fail-closed policy as the SSE relay above. Both the plain JSON answer and + // #1700: same inbound-wire refusal policy as the SSE relay above. Both the plain JSON answer and // the reframed-SSE branch below are built from this body, so one check covers them. This // runs BEFORE the continuation cache write below: a refused turn must not become state a // later `previous_response_id` replay can expand from. @@ -1312,7 +1315,7 @@ export async function deliverPassthroughResponse( return undefined; } })(); - if (undeclared !== undefined) { + if (enforceDeclaredToolNames && undeclared !== undefined) { return formatErrorResponse(502, "upstream_error", undeclaredToolCallMessage(undeclared)); } clientJson = normalizeDefaultNamespaceInJson( diff --git a/structure/transports/responses-wire-shapes.md b/structure/transports/responses-wire-shapes.md index d111c6e8914..3b46c1eb8dd 100644 --- a/structure/transports/responses-wire-shapes.md +++ b/structure/transports/responses-wire-shapes.md @@ -315,12 +315,12 @@ an empty tool list is making a statement rather than omitting one, which is how guard reads it through `clientExplicitWireToolCatalog` in `src/server/responses/passthrough-dispatch.ts`. -The passthrough guard is not wire-scoped. `undeclaredToolGuardActive` gates namespace normalization -and continuation-state suppression as well as the refusal, and it stands down only for -`authMode: "forward"` and for a request that declares no catalog at all. +The passthrough SSE/JSON refusal is wire-scoped too: only `responses` rejects undeclared calls. +`undeclaredToolGuardActive` still gates namespace normalization and continuation suppression on every wire, +standing down only for `authMode: "forward"` and requests with no catalog; relayed undeclared calls are not remembered. -`src/server/responses/run-turn-execution.ts` and `src/server/responses/adapter-delivery.ts` set the -flag from `inboundWire` on the streaming, buffered, and JSON paths alike, so the three cannot drift. +`src/server/responses/run-turn-execution.ts`, `src/server/responses/adapter-delivery.ts`, and +`src/server/responses/passthrough-delivery.ts` scope membership refusal from `inboundWire` for SSE and JSON alike. ## Selection outlives the declaration check diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index e6b8525744a..30e77df3544 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -202,7 +202,7 @@ "catalog-slug-uniqueness-boundary.test.ts": "codex-integration", "catalog-verbosity-default.test.ts": "codex-integration", "catalog-vision-sidecar-modalities.test.ts": "codex-integration", - "catalog-zero-credit-picker.test.ts": "codex-integration", "chat-completions-deferred-tools.test.ts": "responses", + "catalog-zero-credit-picker.test.ts": "codex-integration", "chat-completions-deferred-tools.test.ts": "responses", "passthrough-deferred-tools.test.ts": "responses", "chat-completions-endpoint.test.ts": "responses", "chat-completions-pool-mode.test.ts": "responses", "chat-conversation-affinity.test.ts": "responses", "chat-inbound-developer-position.test.ts": "responses", "protocol-contract.test.ts": "responses", "protocol-features.test.ts": "responses", diff --git a/tests/responses/passthrough-deferred-tools.test.ts b/tests/responses/passthrough-deferred-tools.test.ts new file mode 100644 index 00000000000..777737dc4f1 --- /dev/null +++ b/tests/responses/passthrough-deferred-tools.test.ts @@ -0,0 +1,155 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import { resetProviderRequestPacingForTest } from "../../src/providers/request-pacing"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; + +// #6648: membership refusal follows the caller's wire even when a routed provider uses +// Responses passthrough. Chat/Anthropic runners may defer tools; Responses fails closed. +let testDir = ""; +let previousHome: string | undefined; +let codexHome: IsolatedCodexHome | undefined; +beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + codexHome = installIsolatedCodexHome("ocx-passthrough-deferred-"); + testDir = mkdtempSync(join(tmpdir(), "ocx-passthrough-deferred-")); + process.env.OPENCODEX_HOME = testDir; +}); +afterEach(() => { + resetProviderRequestPacingForTest(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + codexHome?.restore(); + codexHome = undefined; + removeTreeWithRetry(testDir); +}); + +const endpoints = { + anthropic: "/v1/messages", + chat: "/v1/chat/completions", + responses: "/v1/responses", +} as const; +type InboundWire = keyof typeof endpoints; +const argumentsJson = '{"path":"todo.md"}'; + +function requestBody(wire: InboundWire, stream: boolean): Record { + const parameters = { type: "object", properties: { path: { type: "string" } } }; + const common = { model: "mock/test-model", stream }; + if (wire === "anthropic") return { + ...common, max_tokens: 128, + messages: [{ role: "user", content: "write to todo" }], + tools: [{ name: "Read", description: "Read a file", input_schema: parameters }], + }; + if (wire === "chat") return { + ...common, messages: [{ role: "user", content: "write to todo" }], + tools: [{ type: "function", function: { name: "Read", description: "Read a file", parameters } }], + }; + return { + ...common, input: "write to todo", + tools: [{ type: "function", name: "Read", description: "Read a file", parameters }], + }; +} + +function mockResponsesUpstream(toolName: string, upstreamSse: boolean) { + const seen: Array<{ path: string; body: Record }> = []; + const call = { + id: "fc_deferred", type: "function_call", call_id: "call_deferred", + name: toolName, arguments: argumentsJson, status: "completed", + }; + const response = { + id: "resp_deferred", object: "response", model: "test-model", status: "completed", + output: [call], usage: { input_tokens: 10, output_tokens: 5, total_tokens: 15 }, + }; + const frame = (type: string, payload: Record) => + `event: ${type}\ndata: ${JSON.stringify({ type, ...payload })}\n\n`; + const server = Bun.serve({ + port: 0, + async fetch(req) { + seen.push({ path: new URL(req.url).pathname, body: await req.json() as Record }); + if (!upstreamSse) return Response.json(response); + return new Response([ + frame("response.created", { response: { ...response, status: "in_progress", output: [] } }), + frame("response.output_item.added", { output_index: 0, item: { ...call, arguments: "", status: "in_progress" } }), + frame("response.function_call_arguments.delta", { item_id: call.id, output_index: 0, delta: argumentsJson }), + frame("response.function_call_arguments.done", { item_id: call.id, output_index: 0, arguments: argumentsJson }), + frame("response.output_item.done", { output_index: 0, item: call }), + frame("response.completed", { response }), + ].join(""), { headers: { "content-type": "text/event-stream" } }); + }, + }); + return { server, seen }; +} + +describe("Responses passthrough deferred tool delivery by inbound wire", () => { + for (const wire of Object.keys(endpoints) as InboundWire[]) { + for (const stream of [true, false]) { + for (const upstreamSse of [true, false]) { + // Native Responses passthrough preserves the upstream representation; the other + // endpoints translate SSE/JSON to the caller's requested representation. + if (wire === "responses" && upstreamSse !== stream) continue; + for (const toolName of ["Write", "Read", "default.Read"]) { + test(`${wire} ${stream ? "stream" : "buffered"} from ${upstreamSse ? "SSE" : "JSON"}: ${toolName}`, async () => { + const upstream = mockResponsesUpstream(toolName, upstreamSse); + saveConfig({ + port: 0, defaultProvider: "mock", + providers: { mock: { + adapter: "openai-responses", apiKey: "mock-key", authMode: "key", + baseUrl: `${upstream.server.url.toString().replace(/\/$/, "")}/v1`, allowPrivateNetwork: true, + } }, + } as OcxConfig); + const server = startServer(0); + try { + const result = await fetch(new URL(endpoints[wire], server.url), { + method: "POST", headers: { "content-type": "application/json", "anthropic-version": "2023-06-01" }, + body: JSON.stringify(requestBody(wire, stream)), + }); + const text = await result.text(); + // Prove the request reached the routed Responses upstream with the partial catalog. + expect(upstream.seen).toHaveLength(1); + expect(upstream.seen[0]!.path).toBe("/v1/responses"); + expect(upstream.seen[0]!.body.tools).toEqual(expect.arrayContaining([ + expect.objectContaining({ type: "function", name: "Read" }), + ])); + if (wire === "responses" && toolName === "Write") { + expect(text).toContain("undeclared client tool"); + expect(text).toContain("Write"); + if (stream && upstreamSse) { + expect(result.status).toBe(200); + expect(text).toContain("response.failed"); + expect(text).not.toContain("response.completed"); + } else expect(result.status).toBe(502); + return; + } + expect(result.status).toBe(200); + expect(text).not.toContain("undeclared client tool"); + expect(text).not.toContain('"error"'); + expect(text).not.toContain("default.Read"); + const expectedName = toolName === "Write" ? "Write" : "Read"; + if (stream) { + expect(result.headers.get("content-type")).toContain("text/event-stream"); + expect(text).toContain(`"name":"${expectedName}"`); + expect(text).toContain("call_deferred"); + expect(text).toContain(wire === "anthropic" ? "message_stop" : wire === "chat" ? "data: [DONE]" : "response.completed"); + } else { + const json = JSON.parse(text); + const tool = wire === "anthropic" ? json.content[0] + : wire === "chat" ? json.choices[0].message.tool_calls[0] : json.output[0]; + expect(wire === "chat" ? tool.function.name : tool.name).toBe(expectedName); + expect(wire === "responses" ? tool.call_id : tool.id).toBe("call_deferred"); + expect(wire === "anthropic" ? tool.input : JSON.parse(wire === "chat" ? tool.function.arguments : tool.arguments)).toEqual({ path: "todo.md" }); + } + } finally { + await server.stop(true); + upstream.server.stop(true); + } + }); + } + } + } + } +}); From e18ee220faff7a432efe9044e4d570f9a23c4b4c Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 8 Oct 2026 11:04:32 +0900 Subject: [PATCH 2/3] test(layout): keep layout.json under the NEW_OVERSIZED cap after #6721 --- scripts/test-layout/layout.json | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 052543ef8b5..91a68f072cd 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -165,8 +165,7 @@ "anthropic-empty-content.test.ts": "adapters/anthropic", "anthropic-eof-tolerance.test.ts": "adapters/anthropic", "anthropic-error-body.test.ts": "adapters/anthropic", - "anthropic-error-stop-reason.test.ts": "adapters/anthropic", - "anthropic-thinking-usage.test.ts": "adapters/anthropic", + "anthropic-error-stop-reason.test.ts": "adapters/anthropic", "anthropic-thinking-usage.test.ts": "adapters/anthropic", "anthropic-fast-opt-in.test.ts": "adapters/anthropic", "anthropic-fast-speed.test.ts": "adapters/anthropic", "anthropic-hardening.test.ts": "adapters/anthropic", From c3923286897b8b96d5e6c283bdc7f441129723bf Mon Sep 17 00:00:00 2001 From: JUN Date: Thu, 8 Oct 2026 12:04:27 +0900 Subject: [PATCH 3/3] test(responses): preserve CR framing coverage by inbound wire --- .../responses/chat-sse-framing-guard.test.ts | 57 +++++++++++++++---- 1 file changed, 47 insertions(+), 10 deletions(-) diff --git a/tests/responses/chat-sse-framing-guard.test.ts b/tests/responses/chat-sse-framing-guard.test.ts index 05100f8921d..31585439467 100644 --- a/tests/responses/chat-sse-framing-guard.test.ts +++ b/tests/responses/chat-sse-framing-guard.test.ts @@ -3,6 +3,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { handleChatCompletions } from "../../src/server/chat-completions"; +import { handleResponses } from "../../src/server/responses"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; import { removeTreeWithRetry } from "../helpers/remove-tree"; @@ -32,7 +33,7 @@ function terminal(newline: string): string { return `event: response.completed${newline}data: ${JSON.stringify({ type: "response.completed", response: { id: "resp_fixture", status: "completed", output: [], usage: { input_tokens: 1, output_tokens: 1 } } })}${newline}${newline}`; } -async function request(chunks: string[], stream: boolean, live = false) { +async function request(chunks: string[], stream: boolean, live = false, wire: "chat" | "responses" = "chat") { let requests = 0; let cancelled = false; const provider: OcxProviderConfig & { fetch: typeof fetch } = { @@ -59,10 +60,15 @@ async function request(chunks: string[], stream: boolean, live = false) { }) as typeof fetch, }; const config: OcxConfig = { port: 0, defaultProvider: "fixture", providers: { fixture: provider } }; - const response = await handleChatCompletions(new Request("http://localhost/v1/chat/completions", { + const handler = wire === "responses" ? handleResponses : handleChatCompletions; + const input = wire === "responses" + ? { input: "fixture", tools: [{ type: "function", name: "declared_tool", parameters: { type: "object" } }] } + : { messages: [{ role: "user", content: "fixture" }], + tools: [{ type: "function", function: { name: "declared_tool", parameters: { type: "object" } } }] }; + const endpoint = wire === "responses" ? "/v1/responses" : "/v1/chat/completions"; + const response = await handler(new Request(`http://localhost${endpoint}`, { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ model: "fixture/model", stream, messages: [{ role: "user", content: "fixture" }], - tools: [{ type: "function", function: { name: "declared_tool", parameters: { type: "object" } } }] }), + body: JSON.stringify({ model: "fixture/model", stream, ...input }), }), config, { model: "", provider: "" }); expect(requests).toBe(1); return { response, cancelled: () => cancelled }; @@ -91,13 +97,16 @@ async function readBounded(response: Response): Promise { describe("Responses-to-Chat shared SSE security framing", () => { test.each([[true, false], [false, false], [true, true], [false, true]])( - "rejects CR-hidden undeclared calls (stream=%s, fragmented=%s)", async (stream, fragmented) => { + "chat relays CR-hidden undeclared calls (stream=%s, fragmented=%s)", async (stream, fragmented) => { const wire = event("unlisted_tool", "\r", "\r\r\n\n") + terminal("\n"); const { response } = await request(fragmented ? [...wire] : [wire], stream); const text = await readBounded(response); - expect(text).toContain("routed provider emitted undeclared client tool"); - expect(text).toContain('"error"'); - expect(text).not.toContain('"tool_calls"'); + expect(text).toContain('"tool_calls"'); + expect(text).toContain('"name":"unlisted_tool"'); + expect(text).toContain('"id":"call_fixture"'); + expect(text).toContain('"finish_reason":"tool_calls"'); + expect(text).not.toContain("undeclared client tool"); + expect(text).not.toContain('"error"'); }); test.each(["\n", "\r\n", "\r"])("keeps declared calls with %j framing", async newline => { const { response } = await request([...event("declared_tool", newline), ...terminal(newline)], true); @@ -113,12 +122,40 @@ describe("Responses-to-Chat shared SSE security framing", () => { expect(text).toContain("[DONE]"); expect(result.cancelled()).toBe(true); }); - test("rejects consecutive CR-only events before upstream EOF", async () => { + test("chat relays consecutive CR-only undeclared events before upstream EOF", async () => { const result = await request([event("unlisted_tool", "\r") + terminal("\r")], true, true); const text = await readBounded(result.response); + expect(text).toContain('"tool_calls"'); + expect(text).toContain('"name":"unlisted_tool"'); + expect(text).toContain('"id":"call_fixture"'); + expect(text).toContain('"finish_reason":"tool_calls"'); + expect(text).toContain("[DONE]"); + expect(text).not.toContain("undeclared client tool"); + expect(text).not.toContain('"error"'); + expect(result.cancelled()).toBe(true); + }); + // #6461 framing protection remains on the refusing inbound wire after #6648. + test.each([[true, false], [false, false], [true, true], [false, true]])( + "responses rejects CR-hidden undeclared calls (stream=%s, fragmented=%s)", async (stream, fragmented) => { + const wire = event("unlisted_tool", "\r", "\r\r\n\n") + terminal("\n"); + const { response } = await request(fragmented ? [...wire] : [wire], stream, false, "responses"); + const text = await readBounded(response); + expect(text).toContain("routed provider emitted undeclared client tool"); + expect(text).toContain('"error"'); + expect(text).toContain("response.failed"); + expect(text).not.toContain("response.output_item.done"); + expect(text).not.toContain('"type":"function_call"'); + expect(text).not.toContain("response.completed"); + }); + test("responses rejects consecutive CR-only undeclared events before upstream EOF", async () => { + const result = await request([event("unlisted_tool", "\r") + terminal("\r")], true, true, "responses"); + const text = await readBounded(result.response); expect(text).toContain("routed provider emitted undeclared client tool"); expect(text).toContain('"error"'); - expect(text).not.toContain('"tool_calls"'); + expect(text).toContain("response.failed"); + expect(text).not.toContain("response.output_item.done"); + expect(text).not.toContain('"type":"function_call"'); + expect(text).not.toContain("response.completed"); expect(result.cancelled()).toBe(true); }); });