From 9669d64edd46cc5be065e3558792aee0261c29ce Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 22 Aug 2026 18:37:56 +0900 Subject: [PATCH 1/4] fix(responses): enforce explicit empty tool catalogs --- src/server/responses/core.ts | 18 +++--- tests/responses-undeclared-tool-guard.test.ts | 59 ++++++++++++------- 2 files changed, 48 insertions(+), 29 deletions(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index df9a769165..da3fb4026b 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2945,13 +2945,13 @@ async function handleResponsesInner( // call it cannot execute, and the turn showed a bare `aborted` with the file untouched. // Forward auth is the canonical ChatGPT backend speaking Codex's own protocol rather than a // routed provider, so it keeps passing through unguarded, as it does for the rewrites above. - // The guard needs a catalog to compare against, so it stands down when the request carries - // none. That is not the same claim as "no tools means no tool may be called": a passthrough - // request can legitimately omit `tools` entirely and still receive a tool call the client - // understands — `tests/github-copilot-stream-contract.test.ts` sends `{model, input, stream}` - // with no tools and Copilot answers with a `custom_tool_call` for `apply_patch`. Policing an - // empty catalog truncates that turn. An unreadable body lands here too, since it yields no - // names either. + // The guard needs a catalog to compare against, so it stands down when the request omits one. + // An explicit empty catalog is still authoritative: it declares that no client tools may be + // called. A passthrough request can legitimately omit `tools` entirely and still receive a call + // the client understands — `tests/github-copilot-stream-contract.test.ts` sends + // `{model, input, stream}` with no tools and Copilot answers with a `custom_tool_call` for + // `apply_patch`. Policing an absent catalog truncates that turn. An unreadable body lands there + // too because the proxy cannot establish the caller's declared authorization boundary. const parseOutboundRequestBody = (bodyText: string): Record | undefined => { try { const body = JSON.parse(bodyText) as unknown; @@ -2970,7 +2970,9 @@ async function handleResponsesInner( // the client's name back. Widening only ever makes the guard fire less; a name declared in // neither place — #1700's `apply_patch` — is still refused. for (const name of toolBridgeMaps.declaredToolNames) declaredWireToolNames.add(name); - const undeclaredToolGuardActive = declaredWireToolNames.size > 0 + const hasExplicitWireToolCatalog = outboundRequestBody !== undefined + && Array.isArray(outboundRequestBody.tools); + const undeclaredToolGuardActive = (declaredWireToolNames.size > 0 || hasExplicitWireToolCatalog) && route.provider.authMode !== "forward"; // A refused turn must not seed `previous_response_id` replay. The inspection branch reads the // untouched upstream stream, so it can still observe a `response.completed` the client never diff --git a/tests/responses-undeclared-tool-guard.test.ts b/tests/responses-undeclared-tool-guard.test.ts index 3cf8a08e17..9998437486 100644 --- a/tests/responses-undeclared-tool-guard.test.ts +++ b/tests/responses-undeclared-tool-guard.test.ts @@ -101,10 +101,13 @@ describe("collectDeclaredWireToolNames", () => { expect(collectDeclaredWireToolNames({ tools: "nonsense" }).size).toBe(0); }); - test("is empty for a readable request that declares nothing", () => { - // Indistinguishable from the unreadable case by size alone, which is why the caller keeps - // readability as separate state: a request declaring no tools authorizes none. + test("is empty when a readable request omits the tool catalog", () => { expect(collectDeclaredWireToolNames({}).size).toBe(0); + }); + + test("is empty when a readable request explicitly declares an empty tool catalog", () => { + // The name set alone cannot distinguish omission from an explicit deny-all catalog, so the + // caller separately tracks whether the readable body contained a `tools` array. expect(collectDeclaredWireToolNames({ tools: [] }).size).toBe(0); }); @@ -462,7 +465,7 @@ describe("a refused turn does not become continuation state", () => { }); }); -describe("a request that declares no tools has no catalog to police", () => { +describe("empty and absent tool catalogs", () => { const config = { port: 0, defaultProvider: "fixture", @@ -520,25 +523,39 @@ describe("a request that declares no tools has no catalog to police", () => { // A passthrough request may omit `tools` entirely and still receive a tool call the client // understands — the Copilot contract in tests/github-copilot-stream-contract.test.ts does - // exactly that with `apply_patch`. Refusing on an empty catalog truncates those turns, so the - // guard needs at least one declared name before it has an opinion. - for (const [label, tools] of [["no tools field", undefined], ["tools: []", []]] as const) { - test(`non-streaming, ${label} — relayed, not refused`, async () => { - const response = await post(false, tools, jsonUpstream); - - expect(response.status).toBe(200); - const body = await response.json() as { output: Array> }; - expect(body.output[0]).toMatchObject({ name: "apply_patch" }); - }); + // exactly that with `apply_patch`. With no catalog the proxy has no authorization boundary to + // enforce, so the call remains untouched on both transports. + test("non-streaming, no tools field — relayed, not refused", async () => { + const response = await post(false, undefined, jsonUpstream); - test(`streaming, ${label} — relayed, not refused`, async () => { - const response = await post(true, tools, sseUpstream); - const body = await response.text(); + expect(response.status).toBe(200); + const body = await response.json() as { output: Array> }; + expect(body.output[0]).toMatchObject({ name: "apply_patch" }); + }); - expect(body).not.toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); - expect(body).toContain("response.completed"); - }); - } + test("streaming, no tools field — relayed, not refused", async () => { + const response = await post(true, undefined, sseUpstream); + const body = await response.text(); + + expect(body).not.toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + expect(body).toContain("response.completed"); + }); + + test("non-streaming, tools: [] — refuses an upstream client tool call", async () => { + const response = await post(false, [], jsonUpstream); + + expect(response.status).toBe(502); + const body = await response.json() as { error: { message: string } }; + expect(body.error.message).toContain('undeclared client tool "apply_patch"'); + }); + + test("streaming, tools: [] — refuses an upstream client tool call", async () => { + const response = await post(true, [], sseUpstream); + const body = await response.text(); + + expect(body).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + expect(body).not.toContain("response.completed"); + }); }); describe("undeclaredToolCallNameInResponse", () => { From 0b616803b1c1944748dd46055be20e639adef2de Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:01:16 +0900 Subject: [PATCH 2/4] fix(responses): honor embedded empty tool catalogs --- src/server/responses-undeclared-tool-guard.ts | 12 +++++ src/server/responses/core.ts | 6 +-- tests/responses-undeclared-tool-guard.test.ts | 52 +++++++++++++++++-- 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index 3a263f6fb0..f02a544965 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -59,6 +59,18 @@ export function collectDeclaredWireToolNames(body: unknown): Set { return names; } +/** Whether the outbound request contains any supported, readable client-tool catalog. */ +export function hasExplicitWireToolCatalog(body: unknown): boolean { + if (!isPlainObject(body)) return false; + if (Array.isArray(body.tools)) return true; + if (!Array.isArray(body.input)) return false; + return body.input.some(item => + isPlainObject(item) + && item.type === "additional_tools" + && Array.isArray(item.tools) + ); +} + function undeclaredNameInItem(item: unknown, declared: ReadonlySet): string | undefined { if (!isPlainObject(item)) return undefined; if (typeof item.type !== "string" || !CLIENT_EXECUTED_CALL_TYPES.has(item.type)) return undefined; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index da3fb4026b..1afefe5921 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -311,6 +311,7 @@ import { import { collectDeclaredWireToolNames, createUndeclaredToolCallGuardBlockRewrite, + hasExplicitWireToolCatalog, undeclaredToolCallMessage, undeclaredToolCallName, undeclaredToolCallNameInResponse, @@ -2970,9 +2971,8 @@ async function handleResponsesInner( // the client's name back. Widening only ever makes the guard fire less; a name declared in // neither place — #1700's `apply_patch` — is still refused. for (const name of toolBridgeMaps.declaredToolNames) declaredWireToolNames.add(name); - const hasExplicitWireToolCatalog = outboundRequestBody !== undefined - && Array.isArray(outboundRequestBody.tools); - const undeclaredToolGuardActive = (declaredWireToolNames.size > 0 || hasExplicitWireToolCatalog) + const explicitWireToolCatalog = hasExplicitWireToolCatalog(outboundRequestBody); + const undeclaredToolGuardActive = (declaredWireToolNames.size > 0 || explicitWireToolCatalog) && route.provider.authMode !== "forward"; // A refused turn must not seed `previous_response_id` replay. The inspection branch reads the // untouched upstream stream, so it can still observe a `response.completed` the client never diff --git a/tests/responses-undeclared-tool-guard.test.ts b/tests/responses-undeclared-tool-guard.test.ts index 9998437486..8a7ecf61ce 100644 --- a/tests/responses-undeclared-tool-guard.test.ts +++ b/tests/responses-undeclared-tool-guard.test.ts @@ -8,6 +8,7 @@ import { describe, expect, test } from "bun:test"; import { collectDeclaredWireToolNames, createUndeclaredToolCallGuardBlockRewrite, + hasExplicitWireToolCatalog, undeclaredToolCallNameInResponse, UNDECLARED_TOOL_CALL_ERROR_CODE, } from "../src/server/responses-undeclared-tool-guard"; @@ -107,7 +108,7 @@ describe("collectDeclaredWireToolNames", () => { test("is empty when a readable request explicitly declares an empty tool catalog", () => { // The name set alone cannot distinguish omission from an explicit deny-all catalog, so the - // caller separately tracks whether the readable body contained a `tools` array. + // caller separately tracks whether the readable body contained a supported catalog array. expect(collectDeclaredWireToolNames({ tools: [] }).size).toBe(0); }); @@ -120,6 +121,25 @@ describe("collectDeclaredWireToolNames", () => { }); }); +describe("hasExplicitWireToolCatalog", () => { + test("distinguishes omitted or unreadable catalogs from top-level arrays", () => { + expect(hasExplicitWireToolCatalog(undefined)).toBe(false); + expect(hasExplicitWireToolCatalog({})).toBe(false); + expect(hasExplicitWireToolCatalog({ tools: "nonsense" })).toBe(false); + expect(hasExplicitWireToolCatalog({ tools: [] })).toBe(true); + expect(hasExplicitWireToolCatalog({ tools: [{ type: "function", name: "exec" }] })).toBe(true); + }); + + test("recognizes an additional_tools array, including an explicit empty catalog", () => { + expect(hasExplicitWireToolCatalog({ + input: [{ type: "additional_tools", role: "developer", tools: [] }], + })).toBe(true); + expect(hasExplicitWireToolCatalog({ + input: [{ type: "additional_tools", role: "developer", tools: "nonsense" }], + })).toBe(false); + }); +}); + describe("undeclared tool call guard", () => { const declared = ["exec", "wait", "request_user_input"]; @@ -488,7 +508,12 @@ describe("empty and absent tool catalogs", () => { status: "completed", }; - async function post(stream: boolean, tools: unknown[] | undefined, upstream: () => Response) { + async function post( + stream: boolean, + tools: unknown[] | undefined, + upstream: () => Response, + additionalTools?: unknown[], + ) { const savedFetch = globalThis.fetch; globalThis.fetch = (async () => upstream()) as typeof fetch; try { @@ -498,7 +523,12 @@ describe("empty and absent tool catalogs", () => { body: JSON.stringify({ model: "fixture/deepseek-v4-flash", stream, - input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }], + input: [ + { role: "user", content: [{ type: "input_text", text: "hi" }] }, + ...(additionalTools === undefined + ? [] + : [{ type: "additional_tools", role: "developer", tools: additionalTools }]), + ], ...(tools === undefined ? {} : { tools }), }), }), config, { model: "", provider: "" }); @@ -556,6 +586,22 @@ describe("empty and absent tool catalogs", () => { expect(body).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); expect(body).not.toContain("response.completed"); }); + + test("non-streaming, additional_tools.tools: [] — refuses an upstream client tool call", async () => { + const response = await post(false, undefined, jsonUpstream, []); + + expect(response.status).toBe(502); + const body = await response.json() as { error: { message: string } }; + expect(body.error.message).toContain('undeclared client tool "apply_patch"'); + }); + + test("streaming, additional_tools.tools: [] — refuses an upstream client tool call", async () => { + const response = await post(true, undefined, sseUpstream, []); + const body = await response.text(); + + expect(body).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + expect(body).not.toContain("response.completed"); + }); }); describe("undeclaredToolCallNameInResponse", () => { From ab3c5742c9cffda1202e17c81761922cd6aac455 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:27:27 +0900 Subject: [PATCH 3/4] fix(responses): preserve catalog guards across rewrites --- src/server/responses-undeclared-tool-guard.ts | 50 +++++++++++- src/server/responses/core.ts | 40 +++++++--- tests/responses-opaque-blob-recovery.test.ts | 11 +++ tests/responses-undeclared-tool-guard.test.ts | 79 ++++++++++++++++++- 4 files changed, 164 insertions(+), 16 deletions(-) diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index f02a544965..d9c20b5141 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -8,6 +8,21 @@ import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; */ const CLIENT_EXECUTED_CALL_TYPES = new Set(["function_call", "custom_tool_call"]); +/** Supported hosted/private declarations that carry no client-executable wire name. */ +const NAMELESS_TOOL_SPEC_TYPES = new Set([ + "web_search", + "web_search_preview", + "file_search", + "computer_use_preview", + "code_interpreter", + "image_generation", + "image_gen", + "mcp", + "tool_search", + "local_shell", + "x_search", +]); + /** An upstream-supplied name reaches the error message; keep it bounded. */ const MAX_REPORTED_NAME_CHARS = 100; @@ -59,15 +74,44 @@ export function collectDeclaredWireToolNames(body: unknown): Set { return names; } -/** Whether the outbound request contains any supported, readable client-tool catalog. */ +function isReadableWireToolSpec(spec: unknown): boolean { + if (!isPlainObject(spec) || typeof spec.type !== "string" || spec.type.length === 0) return false; + if (spec.type === "function") { + return (typeof spec.name === "string" && spec.name.length > 0) + || (isPlainObject(spec.function) + && typeof spec.function.name === "string" + && spec.function.name.length > 0); + } + if (spec.type === "custom") return typeof spec.name === "string" && spec.name.length > 0; + if (spec.type === "namespace") { + return typeof spec.name === "string" + && spec.name.length > 0 + && Array.isArray(spec.tools) + && (spec.tools.length === 0 || spec.tools.some(inner => + isPlainObject(inner) + && (inner.type === "function" || inner.type === "custom") + && typeof inner.name === "string" + && inner.name.length > 0 + )); + } + if (NAMELESS_TOOL_SPEC_TYPES.has(spec.type)) return true; + return typeof spec.name === "string" && spec.name.length > 0; +} + +function isReadableWireToolCatalog(value: unknown): boolean { + return Array.isArray(value) + && (value.length === 0 || value.some(isReadableWireToolSpec)); +} + +/** Whether a request contains a supported catalog, including an explicit empty deny-all array. */ export function hasExplicitWireToolCatalog(body: unknown): boolean { if (!isPlainObject(body)) return false; - if (Array.isArray(body.tools)) return true; + if (isReadableWireToolCatalog(body.tools)) return true; if (!Array.isArray(body.input)) return false; return body.input.some(item => isPlainObject(item) && item.type === "additional_tools" - && Array.isArray(item.tools) + && isReadableWireToolCatalog(item.tools) ); } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 1afefe5921..7b48f5f8c6 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2904,6 +2904,9 @@ async function handleResponsesInner( + `(model ${parsed.modelId}); forwarding without it — earlier turns may be missing from this request`, ); } + // Preserve the caller's readable catalog boundary before provider-specific normalization can + // remove an unsupported final entry (for example xAI cached-only web search). + const clientExplicitWireToolCatalog = hasExplicitWireToolCatalog(parsed._rawBody); let request: Awaited>; try { request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget }); @@ -2963,17 +2966,28 @@ async function handleResponsesInner( return undefined; } }; - let outboundRequestBody = parseOutboundRequestBody(request.body); - const declaredWireToolNames = collectDeclaredWireToolNames(outboundRequestBody); - // Union with the caller's own catalog, because the outbound body is not always a complete - // record of it: hosted-tool preference REPLACES a client tool with its hosted form, so a - // request declaring `image_gen.generate` ships `{type:"image_generation"}` upstream and gets - // the client's name back. Widening only ever makes the guard fire less; a name declared in - // neither place — #1700's `apply_patch` — is still refused. - for (const name of toolBridgeMaps.declaredToolNames) declaredWireToolNames.add(name); - const explicitWireToolCatalog = hasExplicitWireToolCatalog(outboundRequestBody); - const undeclaredToolGuardActive = (declaredWireToolNames.size > 0 || explicitWireToolCatalog) - && route.provider.authMode !== "forward"; + let outboundRequestBody: Record | undefined; + const declaredWireToolNames = new Set(); + let undeclaredToolGuardActive = false; + const refreshUndeclaredToolGuard = (builtRequest: AdapterRequest): void => { + outboundRequestBody = parseOutboundRequestBody(builtRequest.body); + declaredWireToolNames.clear(); + for (const name of collectDeclaredWireToolNames(outboundRequestBody)) { + declaredWireToolNames.add(name); + } + // Union with the caller's own catalog, because the outbound body is not always a complete + // record of it: hosted-tool preference REPLACES a client tool with its hosted form, so a + // request declaring `image_gen.generate` ships `{type:"image_generation"}` upstream and gets + // the client's name back. Widening only ever makes the guard fire less; a name declared in + // neither place — #1700's `apply_patch` — is still refused. + for (const name of toolBridgeMaps.declaredToolNames) declaredWireToolNames.add(name); + undeclaredToolGuardActive = ( + declaredWireToolNames.size > 0 + || clientExplicitWireToolCatalog + || hasExplicitWireToolCatalog(outboundRequestBody) + ) && route.provider.authMode !== "forward"; + }; + refreshUndeclaredToolGuard(request); // A refused turn must not seed `previous_response_id` replay. The inspection branch reads the // untouched upstream stream, so it can still observe a `response.completed` the client never // received; checking the payload itself rather than a flag shared with the client relay keeps @@ -3166,7 +3180,7 @@ async function handleResponsesInner( ? request.usageLog.inputTokens : undefined; if (passthroughEstimate !== undefined) logCtx.usageLogInputTokens = passthroughEstimate; - outboundRequestBody = parseOutboundRequestBody(request.body); + refreshUndeclaredToolGuard(request); logCtx.providerAdapter = retryAdapter.name; sealRequestAttemptIdentity( logCtx.activeAttempt, @@ -3276,6 +3290,7 @@ async function handleResponsesInner( const msg = err instanceof Error ? err.message : String(err); return formatErrorResponse(400, "invalid_request_error", redactSecretString(msg)); } + refreshUndeclaredToolGuard(request); try { upstreamResponse = await fetchWithTransientRetry( recovery => { @@ -3427,6 +3442,7 @@ async function handleResponsesInner( authCtx = retry.authCtx; request = retry.request; refreshRoutedNamespaceToolAliases(request); + refreshUndeclaredToolGuard(request); upstreamResponse = retry.upstreamResponse; selectedForwardHeaders = retry.selectedForwardHeaders; // Keep subagent quota-failure health keyed to the account that actually served. diff --git a/tests/responses-opaque-blob-recovery.test.ts b/tests/responses-opaque-blob-recovery.test.ts index d161fe5522..9db7e179f1 100644 --- a/tests/responses-opaque-blob-recovery.test.ts +++ b/tests/responses-opaque-blob-recovery.test.ts @@ -372,6 +372,15 @@ describe("opaque blob recovery through /v1/responses", () => { adapter.buildRequest = async (parsed, incoming) => { const built = await buildRequest(parsed, incoming); buildCount += 1; + const body = JSON.parse(built.body) as Record; + built.body = JSON.stringify({ + ...body, + tools: [{ + type: "function", + name: buildCount === 1 ? "stale_catalog__read" : "fresh_catalog__read", + parameters: { type: "object" }, + }], + }); built.convertedRoutedNamespaceToolAliases = buildCount === 1 ? new Map([["stale_catalog__read", { namespace: "stale_catalog", name: "read" }]]) : new Map([["fresh_catalog__read", { namespace: "fresh_catalog", name: "read" }]]); @@ -422,6 +431,8 @@ describe("opaque blob recovery through /v1/responses", () => { expect(response.status).toBe(200); expect(buildCount).toBe(2); expect(outbound).toHaveLength(2); + expect((outbound[0]!.tools as Array>)[0]?.name).toBe("stale_catalog__read"); + expect((outbound[1]!.tools as Array>)[0]?.name).toBe("fresh_catalog__read"); expect(body.output[0]).toMatchObject({ type: "function_call", namespace: "fresh_catalog", diff --git a/tests/responses-undeclared-tool-guard.test.ts b/tests/responses-undeclared-tool-guard.test.ts index 8a7ecf61ce..2f8c31fed7 100644 --- a/tests/responses-undeclared-tool-guard.test.ts +++ b/tests/responses-undeclared-tool-guard.test.ts @@ -126,8 +126,24 @@ describe("hasExplicitWireToolCatalog", () => { expect(hasExplicitWireToolCatalog(undefined)).toBe(false); expect(hasExplicitWireToolCatalog({})).toBe(false); expect(hasExplicitWireToolCatalog({ tools: "nonsense" })).toBe(false); + expect(hasExplicitWireToolCatalog({ tools: [{ type: "function" }] })).toBe(false); expect(hasExplicitWireToolCatalog({ tools: [] })).toBe(true); expect(hasExplicitWireToolCatalog({ tools: [{ type: "function", name: "exec" }] })).toBe(true); + expect(hasExplicitWireToolCatalog({ + tools: [{ type: "function" }, { type: "custom", name: "apply_patch" }], + })).toBe(true); + expect(hasExplicitWireToolCatalog({ tools: [{ type: "web_search" }] })).toBe(true); + expect(hasExplicitWireToolCatalog({ tools: [{ type: "image_gen" }, { type: "x_search" }] })).toBe(true); + expect(hasExplicitWireToolCatalog({ + tools: [{ type: "namespace", name: "empty", tools: [] }], + })).toBe(true); + expect(hasExplicitWireToolCatalog({ + tools: [{ + type: "namespace", + name: "outer", + tools: [{ type: "namespace", name: "inner", tools: [] }], + }], + })).toBe(false); }); test("recognizes an additional_tools array, including an explicit empty catalog", () => { @@ -137,6 +153,9 @@ describe("hasExplicitWireToolCatalog", () => { expect(hasExplicitWireToolCatalog({ input: [{ type: "additional_tools", role: "developer", tools: "nonsense" }], })).toBe(false); + expect(hasExplicitWireToolCatalog({ + input: [{ type: "additional_tools", role: "developer", tools: [{}] }], + })).toBe(false); }); }); @@ -499,6 +518,19 @@ describe("empty and absent tool catalogs", () => { }, } as OcxConfig; + const xaiConfig = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://api.x.ai/v1", + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + const call = { type: "function_call", id: "fc_1", @@ -513,6 +545,7 @@ describe("empty and absent tool catalogs", () => { tools: unknown[] | undefined, upstream: () => Response, additionalTools?: unknown[], + requestConfig: OcxConfig = config, ) { const savedFetch = globalThis.fetch; globalThis.fetch = (async () => upstream()) as typeof fetch; @@ -531,7 +564,7 @@ describe("empty and absent tool catalogs", () => { ], ...(tools === undefined ? {} : { tools }), }), - }), config, { model: "", provider: "" }); + }), requestConfig, { model: "", provider: "" }); } finally { globalThis.fetch = savedFetch; } @@ -571,6 +604,22 @@ describe("empty and absent tool catalogs", () => { expect(body).toContain("response.completed"); }); + test("non-streaming, an unreadable top-level catalog — relayed, not refused", async () => { + const response = await post(false, [{ type: "function" }], jsonUpstream); + + expect(response.status).toBe(200); + const body = await response.json() as { output: Array> }; + expect(body.output[0]).toMatchObject({ name: "apply_patch" }); + }); + + test("streaming, an unreadable additional_tools catalog — relayed, not refused", async () => { + const response = await post(true, undefined, sseUpstream, [{}]); + const body = await response.text(); + + expect(body).not.toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + expect(body).toContain("response.completed"); + }); + test("non-streaming, tools: [] — refuses an upstream client tool call", async () => { const response = await post(false, [], jsonUpstream); @@ -602,6 +651,34 @@ describe("empty and absent tool catalogs", () => { expect(body).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); expect(body).not.toContain("response.completed"); }); + + test("non-streaming, a rewritten-away top-level catalog remains authoritative", async () => { + const response = await post( + false, + [{ type: "web_search", external_web_access: false }], + jsonUpstream, + undefined, + xaiConfig, + ); + + expect(response.status).toBe(502); + const body = await response.json() as { error: { message: string } }; + expect(body.error.message).toContain('undeclared client tool "apply_patch"'); + }); + + test("streaming, a rewritten-away additional_tools catalog remains authoritative", async () => { + const response = await post( + true, + undefined, + sseUpstream, + [{ type: "web_search", external_web_access: false }], + xaiConfig, + ); + const body = await response.text(); + + expect(body).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + expect(body).not.toContain("response.completed"); + }); }); describe("undeclaredToolCallNameInResponse", () => { From ff893ffeeed52077d4d0e7a891ee280c4ce7f1a4 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:49:54 +0900 Subject: [PATCH 4/4] fix(responses): guard nameless client tool calls --- src/server/responses-undeclared-tool-guard.ts | 115 +++- src/server/responses/core.ts | 72 ++- tests/responses-undeclared-tool-guard.test.ts | 572 +++++++++++++++++- 3 files changed, 721 insertions(+), 38 deletions(-) diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index d9c20b5141..658a1c6bab 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -1,19 +1,32 @@ import { namespacedToolName } from "../types"; import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; -/** - * Item types the CLIENT executes by name. Hosted calls (`web_search_call`, - * `image_generation_call`, `local_shell_call`, `tool_search_call`, …) are run upstream or - * carry no tool name, so they are never matched against the request catalog. - */ +/** Item types the client executes through a request-declared wire name. */ const CLIENT_EXECUTED_CALL_TYPES = new Set(["function_call", "custom_tool_call"]); +/** Nameless declaration kinds whose response items still require client execution. */ +const NAMELESS_CLIENT_DECLARATION_CALL_TYPES = new Map([ + ["local_shell", "local_shell_call"], + ["tool_search", "tool_search_call"], + ["computer_use_preview", "computer_call"], + ["computer_use", "computer_call"], +]); + +const NAMELESS_CLIENT_CALL_DISPLAY_NAMES = new Map([ + ["local_shell_call", "local_shell"], + ["tool_search_call", "tool_search"], + ["computer_call", "computer_use"], +]); + +const EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES: ReadonlySet = new Set(); + /** Supported hosted/private declarations that carry no client-executable wire name. */ const NAMELESS_TOOL_SPEC_TYPES = new Set([ "web_search", "web_search_preview", "file_search", "computer_use_preview", + "computer_use", "code_interpreter", "image_generation", "image_gen", @@ -33,12 +46,40 @@ function isPlainObject(value: unknown): value is Record { } function addWireToolName(names: Set, tool: unknown, namespace?: string): void { - if (!isPlainObject(tool) || typeof tool.name !== "string" || tool.name.length === 0) return; - names.add(tool.name); + if (!isPlainObject(tool)) return; + const nestedFunction = tool.type === "function" && isPlainObject(tool.function) + ? tool.function + : undefined; + const name = typeof tool.name === "string" && tool.name.length > 0 + ? tool.name + : typeof nestedFunction?.name === "string" && nestedFunction.name.length > 0 + ? nestedFunction.name + : undefined; + if (!name) return; + names.add(name); // Codex routes MCP calls by an explicit `namespace` field, so the same tool is reachable // as a bare inner name or as the flattened form; accept both rather than guess which // coordinate system this provider echoes back. - if (namespace) names.add(namespacedToolName(namespace, tool.name)); + if (namespace) names.add(namespacedToolName(namespace, name)); +} + +/** + * Catalog view owned by the current Responses turn. + * + * `previous_response_id` expansion prepends stored input items, including historical + * `additional_tools` declarations. Those items remain conversation history but cannot grant + * execution authority to this turn. Top-level `tools` always belongs to the current request; + * only input catalogs at or after the replay boundary are current. + */ +export function currentTurnWireToolCatalogBody( + body: unknown, + replayPrefixLength: number | undefined, +): unknown { + if (!isPlainObject(body) || !Array.isArray(body.input)) return body; + if (typeof replayPrefixLength !== "number" || !Number.isFinite(replayPrefixLength)) return body; + const start = Math.min(body.input.length, Math.max(0, Math.trunc(replayPrefixLength))); + if (start === 0) return body; + return { ...body, input: body.input.slice(start) }; } function addWireToolSpecs(names: Set, specs: unknown): void { @@ -68,12 +109,42 @@ export function collectDeclaredWireToolNames(body: unknown): Set { addWireToolSpecs(names, body.tools); if (Array.isArray(body.input)) { for (const item of body.input) { - if (isPlainObject(item) && item.type === "additional_tools") addWireToolSpecs(names, item.tools); + if ( + isPlainObject(item) + && (item.type === "additional_tools" || item.type === "tool_search_output") + ) addWireToolSpecs(names, item.tools); } } return names; } +function addNamelessClientCallTypes(callTypes: Set, specs: unknown): void { + if (!Array.isArray(specs)) return; + for (const spec of specs) { + if (!isPlainObject(spec) || typeof spec.type !== "string") continue; + const callType = NAMELESS_CLIENT_DECLARATION_CALL_TYPES.get(spec.type); + if (callType) callTypes.add(callType); + } +} + +/** Nameless client-call item types authorized by supported request tool declarations. */ +export function collectDeclaredNamelessClientCallTypes(body: unknown): Set { + const callTypes = new Set(); + if (!isPlainObject(body)) return callTypes; + addNamelessClientCallTypes(callTypes, body.tools); + if (Array.isArray(body.input)) { + for (const item of body.input) { + if ( + isPlainObject(item) + && (item.type === "additional_tools" || item.type === "tool_search_output") + ) { + addNamelessClientCallTypes(callTypes, item.tools); + } + } + } + return callTypes; +} + function isReadableWireToolSpec(spec: unknown): boolean { if (!isPlainObject(spec) || typeof spec.type !== "string" || spec.type.length === 0) return false; if (spec.type === "function") { @@ -115,9 +186,20 @@ export function hasExplicitWireToolCatalog(body: unknown): boolean { ); } -function undeclaredNameInItem(item: unknown, declared: ReadonlySet): string | undefined { +function undeclaredNameInItem( + item: unknown, + declared: ReadonlySet, + declaredNamelessClientCallTypes: ReadonlySet, +): string | undefined { if (!isPlainObject(item)) return undefined; - if (typeof item.type !== "string" || !CLIENT_EXECUTED_CALL_TYPES.has(item.type)) return undefined; + if (typeof item.type !== "string") return undefined; + const namelessDisplayName = NAMELESS_CLIENT_CALL_DISPLAY_NAMES.get(item.type); + if (namelessDisplayName !== undefined) { + // Only Codex's explicit `execution: "client"` form delegates tool search to the client. + if (item.type === "tool_search_call" && item.execution !== "client") return undefined; + return declaredNamelessClientCallTypes.has(item.type) ? undefined : namelessDisplayName; + } + if (!CLIENT_EXECUTED_CALL_TYPES.has(item.type)) return undefined; const name = item.name; if (typeof name !== "string" || name.length === 0) return undefined; if (declared.has(name)) return undefined; @@ -131,14 +213,15 @@ function undeclaredNameInItem(item: unknown, declared: ReadonlySet): str export function undeclaredToolCallName( payload: unknown, declared: ReadonlySet, + declaredNamelessClientCallTypes: ReadonlySet = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES, ): string | undefined { if (!isPlainObject(payload)) return undefined; if (payload.type === "response.output_item.added" || payload.type === "response.output_item.done") { - return undeclaredNameInItem(payload.item, declared); + return undeclaredNameInItem(payload.item, declared, declaredNamelessClientCallTypes); } // Sparse gateways skip incremental items and only ever ship the terminal snapshot. if (payload.type === "response.completed" || payload.type === "response.incomplete") { - return undeclaredToolCallNameInResponse(payload.response, declared); + return undeclaredToolCallNameInResponse(payload.response, declared, declaredNamelessClientCallTypes); } return undefined; } @@ -147,10 +230,11 @@ export function undeclaredToolCallName( export function undeclaredToolCallNameInResponse( response: unknown, declared: ReadonlySet, + declaredNamelessClientCallTypes: ReadonlySet = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES, ): string | undefined { if (!isPlainObject(response) || !Array.isArray(response.output)) return undefined; for (const item of response.output) { - const name = undeclaredNameInItem(item, declared); + const name = undeclaredNameInItem(item, declared, declaredNamelessClientCallTypes); if (name !== undefined) return name; } return undefined; @@ -189,6 +273,7 @@ function failedBlocks(name: string, newline: string): readonly string[] { */ export function createUndeclaredToolCallGuardBlockRewrite( declared: ReadonlySet, + declaredNamelessClientCallTypes: ReadonlySet = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES, ): SseBlockRewrite { let tripped = false; return (block: string) => { @@ -201,7 +286,7 @@ export function createUndeclaredToolCallGuardBlockRewrite( } catch { return [block]; } - const name = undeclaredToolCallName(parsed, declared); + const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes); if (name === undefined) return [block]; tripped = true; return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n"); diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 7b48f5f8c6..e53c4b268f 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -309,8 +309,10 @@ import { type RoutedNamespaceToolAliases, } from "../../responses/namespace-tool-compat"; import { + collectDeclaredNamelessClientCallTypes, collectDeclaredWireToolNames, createUndeclaredToolCallGuardBlockRewrite, + currentTurnWireToolCatalogBody, hasExplicitWireToolCatalog, undeclaredToolCallMessage, undeclaredToolCallName, @@ -2906,7 +2908,16 @@ async function handleResponsesInner( } // Preserve the caller's readable catalog boundary before provider-specific normalization can // remove an unsupported final entry (for example xAI cached-only web search). - const clientExplicitWireToolCatalog = hasExplicitWireToolCatalog(parsed._rawBody); + const replayedInputPrefixLength = parsed._replayPrefixLen ?? 0; + const clientToolAuthorizationBody = currentTurnWireToolCatalogBody( + parsed._rawBody, + replayedInputPrefixLength, + ); + const clientExplicitWireToolCatalog = hasExplicitWireToolCatalog(clientToolAuthorizationBody); + const clientDeclaredWireToolNames = collectDeclaredWireToolNames(clientToolAuthorizationBody); + const clientDeclaredNamelessCallTypes = collectDeclaredNamelessClientCallTypes( + clientToolAuthorizationBody, + ); let request: Awaited>; try { request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget }); @@ -2968,23 +2979,41 @@ async function handleResponsesInner( }; let outboundRequestBody: Record | undefined; const declaredWireToolNames = new Set(); + const declaredNamelessClientCallTypes = new Set(); let undeclaredToolGuardActive = false; const refreshUndeclaredToolGuard = (builtRequest: AdapterRequest): void => { outboundRequestBody = parseOutboundRequestBody(builtRequest.body); declaredWireToolNames.clear(); - for (const name of collectDeclaredWireToolNames(outboundRequestBody)) { - declaredWireToolNames.add(name); + // With no replay prefix the full outbound body belongs to this turn and its normalized + // aliases are authoritative. A continuation's outbound body still contains historical + // catalogs (and may promote historical tool-search definitions), so it can never widen the + // current caller snapshot captured above. + if (replayedInputPrefixLength === 0) { + for (const name of collectDeclaredWireToolNames(outboundRequestBody)) { + declaredWireToolNames.add(name); + } + } + for (const name of clientDeclaredWireToolNames) declaredWireToolNames.add(name); + declaredNamelessClientCallTypes.clear(); + if (replayedInputPrefixLength === 0) { + for (const callType of collectDeclaredNamelessClientCallTypes(outboundRequestBody)) { + declaredNamelessClientCallTypes.add(callType); + } + } + for (const callType of clientDeclaredNamelessCallTypes) { + declaredNamelessClientCallTypes.add(callType); + } + // On an ordinary request these maps capture caller-catalog identities that normalization may + // replace on the outbound wire (for example a client image tool becoming hosted). On replay, + // however, the parsed maps also contain historical catalog entries, so only the bounded + // current-turn wire snapshot above may authorize a call. + if (replayedInputPrefixLength === 0) { + for (const name of toolBridgeMaps.declaredToolNames) declaredWireToolNames.add(name); } - // Union with the caller's own catalog, because the outbound body is not always a complete - // record of it: hosted-tool preference REPLACES a client tool with its hosted form, so a - // request declaring `image_gen.generate` ships `{type:"image_generation"}` upstream and gets - // the client's name back. Widening only ever makes the guard fire less; a name declared in - // neither place — #1700's `apply_patch` — is still refused. - for (const name of toolBridgeMaps.declaredToolNames) declaredWireToolNames.add(name); undeclaredToolGuardActive = ( declaredWireToolNames.size > 0 + || clientDeclaredNamelessCallTypes.size > 0 || clientExplicitWireToolCatalog - || hasExplicitWireToolCatalog(outboundRequestBody) ) && route.provider.authMode !== "forward"; }; refreshUndeclaredToolGuard(request); @@ -3004,7 +3033,11 @@ async function handleResponsesInner( // provider) every name looks undeclared, and flipping this would stop recording continuation // state for exactly the passthrough traffic the guard deliberately stands down for. if (!undeclaredToolGuardActive || inspectionSawUndeclaredTool) return; - if (undeclaredToolCallName(payload, declaredWireToolNames) !== undefined) { + if (undeclaredToolCallName( + payload, + declaredWireToolNames, + declaredNamelessClientCallTypes, + ) !== undefined) { inspectionSawUndeclaredTool = true; } }; @@ -3013,7 +3046,11 @@ async function handleResponsesInner( if (inspectionSawUndeclaredTool) return; if ( undeclaredToolGuardActive - && undeclaredToolCallNameInResponse(response, declaredWireToolNames) !== undefined + && undeclaredToolCallNameInResponse( + response, + declaredWireToolNames, + declaredNamelessClientCallTypes, + ) !== undefined ) { return; } @@ -3659,7 +3696,10 @@ async function handleResponsesInner( // Last: every rewrite above can still rename or reshape a call item, so the guard must // compare the names the client will actually receive against the declared catalog. undeclaredToolGuardActive - ? createUndeclaredToolCallGuardBlockRewrite(declaredWireToolNames) + ? createUndeclaredToolCallGuardBlockRewrite( + declaredWireToolNames, + declaredNamelessClientCallTypes, + ) : undefined, ].filter((rewrite): rewrite is NonNullable => rewrite !== undefined); const clientBlockRewrite = blockRewrites.length > 0 @@ -3876,7 +3916,11 @@ async function handleResponsesInner( if (undeclaredToolGuardActive) { const undeclared = (() => { try { - return undeclaredToolCallNameInResponse(JSON.parse(clientJson), declaredWireToolNames); + return undeclaredToolCallNameInResponse( + JSON.parse(clientJson), + declaredWireToolNames, + declaredNamelessClientCallTypes, + ); } catch { return undefined; } diff --git a/tests/responses-undeclared-tool-guard.test.ts b/tests/responses-undeclared-tool-guard.test.ts index 2f8c31fed7..f92b9fd083 100644 --- a/tests/responses-undeclared-tool-guard.test.ts +++ b/tests/responses-undeclared-tool-guard.test.ts @@ -6,8 +6,10 @@ */ import { describe, expect, test } from "bun:test"; import { + collectDeclaredNamelessClientCallTypes, collectDeclaredWireToolNames, createUndeclaredToolCallGuardBlockRewrite, + currentTurnWireToolCatalogBody, hasExplicitWireToolCatalog, undeclaredToolCallNameInResponse, UNDECLARED_TOOL_CALL_ERROR_CODE, @@ -55,12 +57,19 @@ async function readAll(stream: ReadableStream): Promise { return text; } -async function relay(upstream: string, declared: Iterable): Promise { +async function relay( + upstream: string, + declared: Iterable, + declaredNamelessClientCallTypes: Iterable = [], +): Promise { const budget = createTestTranslatorBudget(); try { return await readAll(relaySseWithBlockRewrite( streamFromText(upstream), - createUndeclaredToolCallGuardBlockRewrite(new Set(declared)), + createUndeclaredToolCallGuardBlockRewrite( + new Set(declared), + new Set(declaredNamelessClientCallTypes), + ), budget, )); } finally { @@ -97,6 +106,26 @@ describe("collectDeclaredWireToolNames", () => { expect([...names]).toEqual(["wait"]); }); + test("reads definitions loaded by a current tool-search output", () => { + const names = collectDeclaredWireToolNames({ + input: [{ + type: "tool_search_output", + tools: [{ type: "function", name: "deferred_read" }], + }], + }); + + expect([...names]).toEqual(["deferred_read"]); + }); + + test("recognizes nested function names accepted by the parser", () => { + expect([...collectDeclaredWireToolNames({ + tools: [{ type: "function", function: { name: "lookup" } }], + })]).toEqual(["lookup"]); + expect(collectDeclaredWireToolNames({ + tools: [{ type: "function", function: {} }], + }).size).toBe(0); + }); + test("is empty for a body this proxy could not read", () => { expect(collectDeclaredWireToolNames(undefined).size).toBe(0); expect(collectDeclaredWireToolNames({ tools: "nonsense" }).size).toBe(0); @@ -159,6 +188,40 @@ describe("hasExplicitWireToolCatalog", () => { }); }); +describe("collectDeclaredNamelessClientCallTypes", () => { + test("maps supported nameless declarations to their client response call types", () => { + const callTypes = collectDeclaredNamelessClientCallTypes({ + tools: [{ type: "local_shell" }, { type: "tool_search" }, { type: "web_search" }], + input: [{ + type: "additional_tools", + tools: [{ type: "computer_use_preview" }, { type: "function", name: "exec" }], + }], + }); + + expect([...callTypes].sort()).toEqual(["computer_call", "local_shell_call", "tool_search_call"]); + }); +}); + +describe("currentTurnWireToolCatalogBody", () => { + test("keeps top-level tools and only the current input suffix", () => { + const body = { + tools: [], + input: [ + { type: "additional_tools", tools: [{ type: "function", name: "historical" }] }, + { type: "message", role: "assistant", content: [] }, + { type: "additional_tools", tools: [{ type: "function", name: "current" }] }, + ], + }; + const current = currentTurnWireToolCatalogBody(body, 2) as typeof body; + + expect(current.tools).toEqual([]); + expect(current.input).toEqual([ + { type: "additional_tools", tools: [{ type: "function", name: "current" }] }, + ]); + expect(body.input).toHaveLength(3); + }); +}); + describe("undeclared tool call guard", () => { const declared = ["exec", "wait", "request_user_input"]; @@ -260,6 +323,30 @@ describe("undeclared tool call guard", () => { expect(await relay(upstream, declared)).toBe(upstream); }); + test("relays a declared nameless client call and rejects an undeclared one", async () => { + const upstream = sse("response.output_item.added", { + output_index: 0, + item: { + type: "local_shell_call", + id: "sh_1", + call_id: "call_1", + action: { type: "exec", command: ["echo", "ok"] }, + }, + }); + + expect(await relay(upstream, [], ["local_shell_call"])).toBe(upstream); + expect(await relay(upstream, [])).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + }); + + test("does not classify a server-executed tool search as a client call", async () => { + const upstream = sse("response.output_item.added", { + output_index: 0, + item: { type: "tool_search_call", id: "ts_1", execution: "server", arguments: {} }, + }); + + expect(await relay(upstream, [])).toBe(upstream); + }); + test("leaves comment frames, [DONE], and unparseable payloads alone", async () => { const upstream = ": keep-alive\n\ndata: {not json\n\ndata: [DONE]\n\n"; @@ -546,6 +633,9 @@ describe("empty and absent tool catalogs", () => { upstream: () => Response, additionalTools?: unknown[], requestConfig: OcxConfig = config, + history: unknown[] = [], + model = "fixture/deepseek-v4-flash", + previousResponseId?: string, ) { const savedFetch = globalThis.fetch; globalThis.fetch = (async () => upstream()) as typeof fetch; @@ -554,9 +644,11 @@ describe("empty and absent tool catalogs", () => { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - model: "fixture/deepseek-v4-flash", + model, stream, + ...(previousResponseId ? { previous_response_id: previousResponseId } : {}), input: [ + ...history, { role: "user", content: [{ type: "input_text", text: "hi" }] }, ...(additionalTools === undefined ? [] @@ -620,12 +712,76 @@ describe("empty and absent tool catalogs", () => { expect(body).toContain("response.completed"); }); + test("Spark normalization cannot turn an unreadable catalog into deny-all", async () => { + const response = await post( + false, + [{ type: "some_future_hosted_tool" }], + jsonUpstream, + undefined, + config, + [], + "fixture/gpt-5.3-codex-spark", + ); + + expect(response.status).toBe(200); + const sparkBody = await response.json() as { output: Array> }; + expect(sparkBody.output[0]).toMatchObject({ name: "apply_patch" }); + }); + + test("Spark normalization preserves streaming stand-down for an unreadable top-level catalog", async () => { + const response = await post( + true, + [{ type: "some_future_hosted_tool" }], + sseUpstream, + undefined, + config, + [], + "fixture/gpt-5.3-codex-spark", + ); + const sparkBody = await response.text(); + + expect(sparkBody).not.toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + expect(sparkBody).toContain("response.completed"); + }); + + test("Spark normalization preserves non-streaming stand-down for unreadable additional tools", async () => { + const response = await post( + false, + undefined, + jsonUpstream, + [{ type: "some_future_hosted_tool" }], + config, + [], + "fixture/gpt-5.3-codex-spark", + ); + + expect(response.status).toBe(200); + const sparkBody = await response.json() as { output: Array> }; + expect(sparkBody.output[0]).toMatchObject({ name: "apply_patch" }); + }); + + test("Spark normalization preserves streaming stand-down for unreadable additional tools", async () => { + const response = await post( + true, + undefined, + sseUpstream, + [{ type: "some_future_hosted_tool" }], + config, + [], + "fixture/gpt-5.3-codex-spark", + ); + const sparkBody = await response.text(); + + expect(sparkBody).not.toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + expect(sparkBody).toContain("response.completed"); + }); + test("non-streaming, tools: [] — refuses an upstream client tool call", async () => { const response = await post(false, [], jsonUpstream); expect(response.status).toBe(502); - const body = await response.json() as { error: { message: string } }; - expect(body.error.message).toContain('undeclared client tool "apply_patch"'); + const topLevelEmptyBody = await response.json() as { error: { message: string } }; + expect(topLevelEmptyBody.error.message).toContain('undeclared client tool "apply_patch"'); }); test("streaming, tools: [] — refuses an upstream client tool call", async () => { @@ -640,8 +796,8 @@ describe("empty and absent tool catalogs", () => { const response = await post(false, undefined, jsonUpstream, []); expect(response.status).toBe(502); - const body = await response.json() as { error: { message: string } }; - expect(body.error.message).toContain('undeclared client tool "apply_patch"'); + const embeddedEmptyBody = await response.json() as { error: { message: string } }; + expect(embeddedEmptyBody.error.message).toContain('undeclared client tool "apply_patch"'); }); test("streaming, additional_tools.tools: [] — refuses an upstream client tool call", async () => { @@ -662,8 +818,8 @@ describe("empty and absent tool catalogs", () => { ); expect(response.status).toBe(502); - const body = await response.json() as { error: { message: string } }; - expect(body.error.message).toContain('undeclared client tool "apply_patch"'); + const rewrittenCatalogBody = await response.json() as { error: { message: string } }; + expect(rewrittenCatalogBody.error.message).toContain('undeclared client tool "apply_patch"'); }); test("streaming, a rewritten-away additional_tools catalog remains authoritative", async () => { @@ -679,6 +835,391 @@ describe("empty and absent tool catalogs", () => { expect(body).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); expect(body).not.toContain("response.completed"); }); + + test("non-streaming, tools: [] — refuses a nameless local shell call", async () => { + const response = await post(false, [], () => Response.json({ + id: "resp_shell", + status: "completed", + output: [{ + type: "local_shell_call", + id: "sh_1", + call_id: "call_shell", + action: { type: "exec", command: ["echo", "ok"] }, + status: "completed", + }], + })); + + expect(response.status).toBe(502); + const localShellBody = await response.json() as { error: { message: string } }; + expect(localShellBody.error.message).toContain('undeclared client tool "local_shell"'); + }); + + test("streaming, additional_tools.tools: [] — refuses a client tool-search call", async () => { + const response = await post(true, undefined, () => new Response( + [ + frame("response.output_item.added", { + output_index: 0, + item: { + type: "tool_search_call", + id: "ts_1", + call_id: "call_search", + execution: "client", + arguments: { query: "tools" }, + }, + }), + frame("response.completed", { + response: { id: "resp_search", status: "completed", output: [] }, + }), + "data: [DONE]", + ].join("\n\n") + "\n\n", + { headers: { "content-type": "text/event-stream" } }, + ), []); + const toolSearchBody = await response.text(); + + expect(toolSearchBody).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + expect(toolSearchBody).toContain('undeclared client tool \\"tool_search\\"'); + expect(toolSearchBody).not.toContain("response.completed"); + }); + + test("a declared tool_search authorizes its nameless client call", async () => { + const response = await post(false, [{ type: "tool_search" }], () => Response.json({ + id: "resp_search_allowed", + status: "completed", + output: [{ + type: "tool_search_call", + id: "ts_1", + call_id: "call_search", + execution: "client", + arguments: { query: "tools" }, + status: "completed", + }], + })); + + expect(response.status).toBe(200); + const allowedSearchBody = await response.json() as { output: Array> }; + expect(allowedSearchBody.output[0]).toMatchObject({ type: "tool_search_call", execution: "client" }); + }); + + test("history-only tool search restoration does not override a current empty catalog", async () => { + const response = await post( + false, + [], + () => Response.json({ + id: "resp_history_search", + status: "completed", + output: [{ + type: "function_call", + id: "fc_history_search", + call_id: "call_history_search", + name: "tool_search", + arguments: '{"query":"new tools"}', + status: "completed", + }], + }), + undefined, + config, + [ + { + type: "tool_search_call", + id: "tsc_old", + call_id: "call_old", + execution: "client", + arguments: { query: "old tools" }, + status: "completed", + }, + { + type: "tool_search_output", + call_id: "call_old", + execution: "client", + status: "completed", + tools: [], + }, + ], + ); + + expect(response.status).toBe(502); + const historySearchBody = await response.json() as { error: { message: string } }; + expect(historySearchBody.error.message).toContain('undeclared client tool "tool_search"'); + }); + + test("a replayed named catalog cannot authorize a deny-all continuation", async () => { + const previousId = "resp_replayed_named_catalog"; + const prime = await post( + false, + undefined, + () => Response.json({ id: previousId, status: "completed", output: [] }), + [{ type: "function", name: "exec", parameters: { type: "object" } }], + ); + expect(prime.status).toBe(200); + await prime.arrayBuffer(); + + const response = await post( + false, + [], + () => Response.json({ + id: "resp_named_continuation", + status: "completed", + output: [{ + type: "function_call", + id: "fc_replayed_exec", + call_id: "call_replayed_exec", + name: "exec", + arguments: "{}", + status: "completed", + }], + }), + undefined, + config, + [], + "fixture/deepseek-v4-flash", + previousId, + ); + + expect(response.status).toBe(502); + const namedBody = await response.json() as { error: { message: string } }; + expect(namedBody.error.message).toContain('undeclared client tool "exec"'); + }); + + test("a replayed nameless catalog cannot authorize a deny-all continuation", async () => { + const previousId = "resp_replayed_nameless_catalog"; + const prime = await post( + false, + undefined, + () => Response.json({ id: previousId, status: "completed", output: [] }), + [{ type: "local_shell" }], + ); + expect(prime.status).toBe(200); + await prime.arrayBuffer(); + + const response = await post( + true, + undefined, + () => { + const replayedShell = { + type: "local_shell_call", + id: "sh_replayed", + call_id: "call_replayed_shell", + action: { type: "exec", command: ["echo", "blocked"] }, + status: "completed", + }; + return new Response( + [ + frame("response.output_item.added", { output_index: 0, item: replayedShell }), + frame("response.completed", { + response: { id: "resp_nameless_continuation", status: "completed", output: [replayedShell] }, + }), + "data: [DONE]", + ].join("\n\n") + "\n\n", + { headers: { "content-type": "text/event-stream" } }, + ); + }, + [], + config, + [], + "fixture/deepseek-v4-flash", + previousId, + ); + + const namelessBody = await response.text(); + expect(namelessBody).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + expect(namelessBody).toContain('undeclared client tool \\"local_shell\\"'); + expect(namelessBody).not.toContain("response.completed"); + }); + + test("a replay without a current catalog retains passthrough compatibility", async () => { + const previousId = "resp_replayed_catalog_without_current_boundary"; + const prime = await post( + false, + undefined, + () => Response.json({ id: previousId, status: "completed", output: [] }), + [{ type: "function", name: "exec", parameters: { type: "object" } }], + ); + expect(prime.status).toBe(200); + await prime.arrayBuffer(); + + const response = await post( + false, + undefined, + () => Response.json({ + id: "resp_unbounded_continuation", + status: "completed", + output: [{ + type: "function_call", + id: "fc_unbounded_exec", + call_id: "call_unbounded_exec", + name: "exec", + arguments: "{}", + status: "completed", + }], + }), + undefined, + config, + [], + "fixture/deepseek-v4-flash", + previousId, + ); + + expect(response.status).toBe(200); + const unboundedBody = await response.json() as { output: Array> }; + expect(unboundedBody.output[0]).toMatchObject({ name: "exec" }); + }); + + test("a current additional-tools suffix still authorizes after replay", async () => { + const previousId = "resp_replay_with_current_catalog_suffix"; + const prime = await post( + false, + undefined, + () => Response.json({ id: previousId, status: "completed", output: [] }), + [{ type: "function", name: "historical", parameters: { type: "object" } }], + ); + expect(prime.status).toBe(200); + await prime.arrayBuffer(); + + const response = await post( + false, + undefined, + () => Response.json({ + id: "resp_current_suffix", + status: "completed", + output: [{ + type: "function_call", + id: "fc_current_exec", + call_id: "call_current_exec", + name: "exec", + arguments: "{}", + status: "completed", + }], + }), + [{ type: "function", name: "exec", parameters: { type: "object" } }], + config, + [], + "fixture/deepseek-v4-flash", + previousId, + ); + + expect(response.status).toBe(200); + const currentBody = await response.json() as { output: Array> }; + expect(currentBody.output[0]).toMatchObject({ name: "exec" }); + }); + + test("a current tool-search output still authorizes its discovered tool after replay", async () => { + const previousId = "resp_replay_with_current_tool_search_output"; + const prime = await post( + false, + undefined, + () => Response.json({ id: previousId, status: "completed", output: [] }), + ); + expect(prime.status).toBe(200); + await prime.arrayBuffer(); + + const response = await post( + false, + [{ type: "tool_search" }], + () => Response.json({ + id: "resp_discovered_tool", + status: "completed", + output: [{ + type: "function_call", + id: "fc_deferred_read", + call_id: "call_deferred_read", + name: "deferred_read", + arguments: "{}", + status: "completed", + }], + }), + undefined, + config, + [ + { + type: "tool_search_call", + id: "tsc_current", + call_id: "call_current_search", + execution: "client", + arguments: { query: "read tools" }, + status: "completed", + }, + { + type: "tool_search_output", + call_id: "call_current_search", + execution: "client", + status: "completed", + tools: [{ type: "function", name: "deferred_read", parameters: { type: "object" } }], + }, + ], + "fixture/deepseek-v4-flash", + previousId, + ); + + expect(response.status).toBe(200); + const discoveredBody = await response.json() as { output: Array> }; + expect(discoveredBody.output[0]).toMatchObject({ name: "deferred_read" }); + }); + + test("a nameless-only current discovery activates a bounded replay guard", async () => { + const previousId = "resp_replay_with_current_nameless_discovery"; + const prime = await post( + false, + undefined, + () => Response.json({ id: previousId, status: "completed", output: [] }), + ); + expect(prime.status).toBe(200); + await prime.arrayBuffer(); + + const discoveryInput = [ + { + type: "tool_search_call", + id: "tsc_nameless", + call_id: "call_nameless_search", + execution: "client", + arguments: { query: "shell tools" }, + status: "completed", + }, + { + type: "tool_search_output", + call_id: "call_nameless_search", + execution: "client", + status: "completed", + tools: [{ type: "local_shell" }], + }, + ]; + + const allowed = await post( + false, + undefined, + () => Response.json({ + id: "resp_discovered_shell", + status: "completed", + output: [{ + type: "local_shell_call", + id: "sh_discovered", + call_id: "call_discovered_shell", + action: { type: "exec", command: ["echo", "allowed"] }, + status: "completed", + }], + }), + undefined, + config, + discoveryInput, + "fixture/deepseek-v4-flash", + previousId, + ); + expect(allowed.status).toBe(200); + await allowed.arrayBuffer(); + + const refused = await post( + false, + undefined, + jsonUpstream, + undefined, + config, + discoveryInput, + "fixture/deepseek-v4-flash", + previousId, + ); + expect(refused.status).toBe(502); + const refusedBody = await refused.json() as { error: { message: string } }; + expect(refusedBody.error.message).toContain('undeclared client tool "apply_patch"'); + }); }); describe("undeclaredToolCallNameInResponse", () => { @@ -693,4 +1234,17 @@ describe("undeclaredToolCallNameInResponse", () => { expect(undeclaredToolCallNameInResponse(response, new Set(["exec"]))).toBe("apply_patch"); expect(undeclaredToolCallNameInResponse(response, new Set(["exec", "apply_patch"]))).toBeUndefined(); }); + + test("maps nameless client calls without colliding with ordinary function names", () => { + const response = { + output: [{ type: "computer_call", id: "cmp_1", action: { type: "screenshot" } }], + }; + + expect(undeclaredToolCallNameInResponse(response, new Set(["computer_use"]))).toBe("computer_use"); + expect(undeclaredToolCallNameInResponse( + response, + new Set(), + new Set(["computer_call"]), + )).toBeUndefined(); + }); });