diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index 3a263f6fb0..658a1c6bab 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -1,13 +1,41 @@ 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", + "mcp", + "tool_search", + "local_shell", + "x_search", +]); + /** An upstream-supplied name reaches the error message; keep it bounded. */ const MAX_REPORTED_NAME_CHARS = 100; @@ -18,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 { @@ -53,15 +109,97 @@ 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 undeclaredNameInItem(item: unknown, declared: ReadonlySet): string | undefined { +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") { + 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 (isReadableWireToolCatalog(body.tools)) return true; + if (!Array.isArray(body.input)) return false; + return body.input.some(item => + isPlainObject(item) + && item.type === "additional_tools" + && isReadableWireToolCatalog(item.tools) + ); +} + +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; @@ -75,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; } @@ -91,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; @@ -133,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) => { @@ -145,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 df9a769165..e53c4b268f 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -309,8 +309,11 @@ import { type RoutedNamespaceToolAliases, } from "../../responses/namespace-tool-compat"; import { + collectDeclaredNamelessClientCallTypes, collectDeclaredWireToolNames, createUndeclaredToolCallGuardBlockRewrite, + currentTurnWireToolCatalogBody, + hasExplicitWireToolCatalog, undeclaredToolCallMessage, undeclaredToolCallName, undeclaredToolCallNameInResponse, @@ -2903,6 +2906,18 @@ 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 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 }); @@ -2945,13 +2960,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; @@ -2962,16 +2977,46 @@ 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 undeclaredToolGuardActive = declaredWireToolNames.size > 0 - && route.provider.authMode !== "forward"; + 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(); + // 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); + } + undeclaredToolGuardActive = ( + declaredWireToolNames.size > 0 + || clientDeclaredNamelessCallTypes.size > 0 + || clientExplicitWireToolCatalog + ) && 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 @@ -2988,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; } }; @@ -2997,7 +3046,11 @@ async function handleResponsesInner( if (inspectionSawUndeclaredTool) return; if ( undeclaredToolGuardActive - && undeclaredToolCallNameInResponse(response, declaredWireToolNames) !== undefined + && undeclaredToolCallNameInResponse( + response, + declaredWireToolNames, + declaredNamelessClientCallTypes, + ) !== undefined ) { return; } @@ -3164,7 +3217,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, @@ -3274,6 +3327,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 => { @@ -3425,6 +3479,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. @@ -3641,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 @@ -3858,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-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 3cf8a08e17..f92b9fd083 100644 --- a/tests/responses-undeclared-tool-guard.test.ts +++ b/tests/responses-undeclared-tool-guard.test.ts @@ -6,8 +6,11 @@ */ import { describe, expect, test } from "bun:test"; import { + collectDeclaredNamelessClientCallTypes, collectDeclaredWireToolNames, createUndeclaredToolCallGuardBlockRewrite, + currentTurnWireToolCatalogBody, + hasExplicitWireToolCatalog, undeclaredToolCallNameInResponse, UNDECLARED_TOOL_CALL_ERROR_CODE, } from "../src/server/responses-undeclared-tool-guard"; @@ -54,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 { @@ -96,15 +106,38 @@ 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); }); - 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 supported catalog array. expect(collectDeclaredWireToolNames({ tools: [] }).size).toBe(0); }); @@ -117,6 +150,78 @@ 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: [{ 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", () => { + expect(hasExplicitWireToolCatalog({ + input: [{ type: "additional_tools", role: "developer", tools: [] }], + })).toBe(true); + expect(hasExplicitWireToolCatalog({ + input: [{ type: "additional_tools", role: "developer", tools: "nonsense" }], + })).toBe(false); + expect(hasExplicitWireToolCatalog({ + input: [{ type: "additional_tools", role: "developer", tools: [{}] }], + })).toBe(false); + }); +}); + +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"]; @@ -218,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"; @@ -462,7 +591,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", @@ -476,6 +605,19 @@ describe("a request that declares no tools has no catalog to police", () => { }, } 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", @@ -485,7 +627,16 @@ describe("a request that declares no tools has no catalog to police", () => { status: "completed", }; - async function post(stream: boolean, tools: unknown[] | undefined, upstream: () => Response) { + async function post( + stream: boolean, + tools: unknown[] | undefined, + 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; try { @@ -493,12 +644,19 @@ describe("a request that declares no tools has no catalog to police", () => { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ - model: "fixture/deepseek-v4-flash", + model, stream, - input: [{ role: "user", content: [{ type: "input_text", text: "hi" }] }], + ...(previousResponseId ? { previous_response_id: previousResponseId } : {}), + input: [ + ...history, + { role: "user", content: [{ type: "input_text", text: "hi" }] }, + ...(additionalTools === undefined + ? [] + : [{ type: "additional_tools", role: "developer", tools: additionalTools }]), + ], ...(tools === undefined ? {} : { tools }), }), - }), config, { model: "", provider: "" }); + }), requestConfig, { model: "", provider: "" }); } finally { globalThis.fetch = savedFetch; } @@ -520,25 +678,548 @@ 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, 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("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 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 () => { + 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"); + }); + + 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 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 () => { + 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"); + }); + + 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 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 () => { + 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"); + }); + + 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", () => { @@ -553,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(); + }); });