From 93f4425e5eebb294661718090954f7d8489bee45 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sat, 22 Aug 2026 20:18:47 -0700 Subject: [PATCH 1/3] fix(xai): stop the undeclared-tool guard from killing hosted x_search turns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xAI executes hosted `x_search` itself and reports the activity as a `custom_tool_call` whose name is deliberately absent from the request catalog. The guard treats every `custom_tool_call` as client-executed, so once a request also declares any NAMED client tool the guard trips on xAI's own hosted call and fails the whole turn. Reproduced 2026-08-22 with the identical request ({type:"function",name:"shell"} + {type:"x_search"}): direct to xAI 200, custom_tool_call + message, 10 annotations through opencodex response.failed, no response.completed, reasoning item only, 0 output chars A request declaring ONLY x_search passes, because the guard activates only once a named client tool exists — which is why this is easy to miss with a minimal repro and why every realistic Codex request would hit it. The fix mirrors the existing NAMELESS_CLIENT_DECLARATION_CALL_TYPES in the other direction: PROVIDER_EXECUTED_DECLARATION_CALL_TYPES maps a hosted declaration to the item type the provider emits for it, and those items need no client name to be authorized. Two gates, both required, so this cannot widen into a blanket exemption: destination core.ts passes an empty set unless the route actually terminates at xAI (isXaiResponsesDestination: exact host, https, standard port — lookalikes and odd ports excluded) declaration the turn must actually declare x_search Names are never matched. One turn emitted `x_keyword_search` and `x_semantic_search`, and the other xAI host emits `x_user_search` — three literals for one tool, so the name channel carries no signal. RESIDUAL RISK, accepted deliberately and documented at the branch: inside a turn that declared x_search on xAI, a hallucinated client custom tool is exempted too, precisely because names cannot be trusted. The alternative is failing every hosted-search turn. #1700's protection is untouched for every other turn, provider and item type. Tests pin the two gates in the negative direction as well as the positive: no declaration still refuses, empty authorization still refuses, and apply_patch is still refused INSIDE an authorized turn. Gate: 14438 pass / 1 fail; that failure also fails on untouched upstream/dev at the same commit (baseline: 4 fail, a superset). Zero regressions. --- src/providers/xai-transport.ts | 21 ++++++ src/server/responses-undeclared-tool-guard.ts | 67 +++++++++++++++++-- src/server/responses/core.ts | 12 +++- tests/responses-undeclared-tool-guard.test.ts | 64 ++++++++++++++++++ 4 files changed, 159 insertions(+), 5 deletions(-) diff --git a/src/providers/xai-transport.ts b/src/providers/xai-transport.ts index 1e56506ecc..e005abdf4e 100644 --- a/src/providers/xai-transport.ts +++ b/src/providers/xai-transport.ts @@ -4,6 +4,27 @@ import { resolveGithubCopilotTransport } from "./github-copilot-transport"; export const XAI_GROK_CLI_BASE_URL = "https://cli-chat-proxy.grok.com/v1"; +/** The two hosts that serve xAI's Responses API: the public API and the Grok CLI proxy. */ +const XAI_RESPONSES_HOSTS = new Set(["api.x.ai", "cli-chat-proxy.grok.com"]); + +/** + * True when this provider's Responses traffic terminates at xAI itself. + * + * Probed 2026-08-22 one field per request: the two hosts accept and refuse exactly the same + * web_search fields, so they are one dialect rather than two. Matching is exact-host over + * https, which keeps lookalikes (`api.x.ai.evil.test`) and nonstandard ports out. + */ +export function isXaiResponsesDestination(provider: Pick): boolean { + try { + const url = new URL(provider.baseUrl); + return url.protocol === "https:" + && XAI_RESPONSES_HOSTS.has(url.hostname.toLowerCase()) + && (url.port === "" || url.port === "443"); + } catch { + return false; + } +} + export const XAI_GROK_COMPATIBILITY = { version: "0.2.93", userAgent: "opencodex-grok/0.2.93", diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index 658a1c6bab..7ca824a25b 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -4,6 +4,22 @@ import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; /** Item types the client executes through a request-declared wire name. */ const CLIENT_EXECUTED_CALL_TYPES = new Set(["function_call", "custom_tool_call"]); +/** + * Hosted declarations whose response items the PROVIDER executes, keyed by the item type it + * emits for them. These need no client answer, so their names are deliberately absent from the + * request catalog and must not be read as an undeclared client tool. + * + * xAI surfaces hosted `x_search` as `custom_tool_call`. Probed 2026-08-22 through the proxy: + * a request declaring a named client tool alongside `x_search` produced `response.failed` with + * zero output, while the identical request direct to xAI completed normally. Observed call + * names were `x_keyword_search` and `x_semantic_search` in one turn, and `x_user_search` on the + * other xAI host — three literals for one tool, which is why this keys on the DECLARATION and + * the item type, never on the name. + */ +export const PROVIDER_EXECUTED_DECLARATION_CALL_TYPES = new Map([ + ["x_search", "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"], @@ -19,6 +35,7 @@ const NAMELESS_CLIENT_CALL_DISPLAY_NAMES = new Map([ ]); const EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES: ReadonlySet = new Set(); +const EMPTY_PROVIDER_EXECUTED_CALL_TYPES: ReadonlySet = new Set(); /** Supported hosted/private declarations that carry no client-executable wire name. */ const NAMELESS_TOOL_SPEC_TYPES = new Set([ @@ -127,6 +144,36 @@ function addNamelessClientCallTypes(callTypes: Set, specs: unknown): voi } } +function addProviderExecutedCallTypes(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 = PROVIDER_EXECUTED_DECLARATION_CALL_TYPES.get(spec.type); + if (callType) callTypes.add(callType); + } +} + +/** + * Item types this turn's hosted declarations authorize the PROVIDER to emit unnamed. + * + * Caller must gate this on the destination actually being that provider; a declaration alone + * is not authority, or any upstream could claim a hosted shape it never serves. + */ +export function collectProviderExecutedCallTypes(body: unknown): Set { + const callTypes = new Set(); + if (!isPlainObject(body)) return callTypes; + addProviderExecutedCallTypes(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") + ) addProviderExecutedCallTypes(callTypes, item.tools); + } + } + return callTypes; +} + /** Nameless client-call item types authorized by supported request tool declarations. */ export function collectDeclaredNamelessClientCallTypes(body: unknown): Set { const callTypes = new Set(); @@ -190,9 +237,18 @@ function undeclaredNameInItem( item: unknown, declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet, + providerExecutedCallTypes: ReadonlySet = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, ): string | undefined { if (!isPlainObject(item)) return undefined; if (typeof item.type !== "string") return undefined; + // The provider executes this one itself, so there is no client name to authorize. + // + // RESIDUAL RISK, deliberate: inside a turn that declared such a hosted tool ON that provider, + // a hallucinated client custom tool is exempted too, because the three observed xAI names + // prove the name channel carries no signal. The alternative is failing every hosted-search + // turn. #1700's protection is untouched for every other turn, provider and item type — the + // exemption needs BOTH the destination and the declaration. + if (providerExecutedCallTypes.has(item.type)) 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. @@ -214,14 +270,15 @@ export function undeclaredToolCallName( payload: unknown, declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES, + providerExecutedCallTypes: ReadonlySet = EMPTY_PROVIDER_EXECUTED_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, declaredNamelessClientCallTypes); + return undeclaredNameInItem(payload.item, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes); } // 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, declaredNamelessClientCallTypes); + return undeclaredToolCallNameInResponse(payload.response, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes); } return undefined; } @@ -231,10 +288,11 @@ export function undeclaredToolCallNameInResponse( response: unknown, declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES, + providerExecutedCallTypes: ReadonlySet = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, ): string | undefined { if (!isPlainObject(response) || !Array.isArray(response.output)) return undefined; for (const item of response.output) { - const name = undeclaredNameInItem(item, declared, declaredNamelessClientCallTypes); + const name = undeclaredNameInItem(item, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes); if (name !== undefined) return name; } return undefined; @@ -274,6 +332,7 @@ function failedBlocks(name: string, newline: string): readonly string[] { export function createUndeclaredToolCallGuardBlockRewrite( declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES, + providerExecutedCallTypes: ReadonlySet = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, ): SseBlockRewrite { let tripped = false; return (block: string) => { @@ -286,7 +345,7 @@ export function createUndeclaredToolCallGuardBlockRewrite( } catch { return [block]; } - const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes); + const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes); 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 67005be579..a08d74bc05 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -191,7 +191,7 @@ import { rotateProviderTransportOn429, } from "../../providers/key-failover"; import { shouldAttemptImageTierRetry } from "../image-retry"; -import { resolveProviderTransport } from "../../providers/xai-transport"; +import { isXaiResponsesDestination, resolveProviderTransport } from "../../providers/xai-transport"; import type { WsData } from "../ws-bridge"; import { codexAccountSelectionForTurn, registerTurn, trackStreamLifetime, unregisterTurn } from "../lifecycle"; import { redactSecretString, sanitizeLogMetadataString } from "../../lib/redact"; @@ -308,6 +308,7 @@ import { import { collectDeclaredNamelessClientCallTypes, collectDeclaredWireToolNames, + collectProviderExecutedCallTypes, createUndeclaredToolCallGuardBlockRewrite, currentTurnWireToolCatalogBody, hasExplicitWireToolCatalog, @@ -2942,6 +2943,11 @@ async function handleResponsesInner( const clientDeclaredNamelessCallTypes = collectDeclaredNamelessClientCallTypes( clientToolAuthorizationBody, ); + // Hosted calls the PROVIDER runs itself. Gated on the destination actually being xAI, so a + // declaration alone cannot buy the exemption on some other upstream that never serves it. + const providerExecutedCallTypes = isXaiResponsesDestination(route.provider) + ? collectProviderExecutedCallTypes(clientToolAuthorizationBody) + : new Set(); let request: Awaited>; try { request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget }); @@ -3061,6 +3067,7 @@ async function handleResponsesInner( payload, declaredWireToolNames, declaredNamelessClientCallTypes, + providerExecutedCallTypes, ) !== undefined) { inspectionSawUndeclaredTool = true; } @@ -3074,6 +3081,7 @@ async function handleResponsesInner( response, declaredWireToolNames, declaredNamelessClientCallTypes, + providerExecutedCallTypes, ) !== undefined ) { return; @@ -3725,6 +3733,7 @@ async function handleResponsesInner( ? createUndeclaredToolCallGuardBlockRewrite( declaredWireToolNames, declaredNamelessClientCallTypes, + providerExecutedCallTypes, ) : undefined, ].filter((rewrite): rewrite is NonNullable => rewrite !== undefined); @@ -3946,6 +3955,7 @@ async function handleResponsesInner( JSON.parse(clientJson), declaredWireToolNames, declaredNamelessClientCallTypes, + providerExecutedCallTypes, ); } catch { return undefined; diff --git a/tests/responses-undeclared-tool-guard.test.ts b/tests/responses-undeclared-tool-guard.test.ts index f92b9fd083..2607b5cb08 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 { collectDeclaredNamelessClientCallTypes, collectDeclaredWireToolNames, + collectProviderExecutedCallTypes, createUndeclaredToolCallGuardBlockRewrite, currentTurnWireToolCatalogBody, hasExplicitWireToolCatalog, @@ -1248,3 +1249,66 @@ describe("undeclaredToolCallNameInResponse", () => { )).toBeUndefined(); }); }); + +/** + * xAI runs hosted `x_search` itself and reports the activity as a `custom_tool_call` whose name + * is absent from the request catalog. Probed 2026-08-22: a request declaring a named client tool + * alongside `x_search` completed normally direct to xAI, but through the proxy produced + * `response.failed` with zero output — the guard read the provider's own hosted call as an + * undeclared client tool. Observed names were `x_keyword_search` and `x_semantic_search` in one + * turn, and `x_user_search` on the other xAI host, so authorization keys on the declaration and + * the item type, never on the name. + */ +describe("provider-executed hosted calls", () => { + const declared = new Set(["shell"]); + const nameless = new Set(); + const xSearchAuthorized = collectProviderExecutedCallTypes({ + tools: [{ type: "function", name: "shell" }, { type: "x_search" }], + }); + + function hostedCall(name: string) { + return { output: [{ type: "custom_tool_call", name, call_id: "xs_call-1" }] }; + } + + test("authorizes the provider's hosted call under any of its observed names", () => { + expect(collectProviderExecutedCallTypes({ tools: [{ type: "x_search" }] })) + .toEqual(new Set(["custom_tool_call"])); + for (const name of ["x_keyword_search", "x_semantic_search", "x_user_search"]) { + expect(undeclaredToolCallNameInResponse( + hostedCall(name), declared, nameless, xSearchAuthorized, + )).toBeUndefined(); + } + }); + + test("without the x_search declaration the same item is still refused", () => { + const noHostedDeclaration = collectProviderExecutedCallTypes({ + tools: [{ type: "function", name: "shell" }], + }); + expect(noHostedDeclaration.size).toBe(0); + expect(undeclaredToolCallNameInResponse( + hostedCall("x_keyword_search"), declared, nameless, noHostedDeclaration, + )).toBe("x_keyword_search"); + }); + + test("the caller gates on destination: an empty authorization set refuses the same item", () => { + // core.ts passes an empty set unless the route actually terminates at xAI, so a declaration + // alone cannot buy the exemption on an upstream that never serves the hosted tool. + expect(undeclaredToolCallNameInResponse( + hostedCall("x_keyword_search"), declared, nameless, new Set(), + )).toBe("x_keyword_search"); + }); + + test("#1700 still holds: an undeclared client tool is refused inside an authorized turn", () => { + expect(undeclaredToolCallNameInResponse( + { output: [{ type: "function_call", name: "apply_patch", call_id: "c1" }] }, + declared, nameless, xSearchAuthorized, + )).toBe("apply_patch"); + }); + + test("a declared client tool is unaffected", () => { + expect(undeclaredToolCallNameInResponse( + { output: [{ type: "function_call", name: "shell", call_id: "c1" }] }, + declared, nameless, xSearchAuthorized, + )).toBeUndefined(); + }); +}); From f63cdcba411d80aa9d460a44a5d09651f8cf05d0 Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sat, 22 Aug 2026 21:33:14 -0700 Subject: [PATCH 2/3] fix(xai): require the hosted call-id prefix, not just the item type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review caught that the previous shape exempted EVERY custom_tool_call inside an authorized turn — and apply_patch, the tool #1700 exists to protect, arrives as a custom_tool_call (see the repo's own 'never blocks apply_patch' test). The regression test written to prove #1700 survived used a function_call, which the exemption never touched, so it asserted something true but irrelevant. Measured 2026-08-23 against cli-chat-proxy.grok.com, a hosted x_search item is {type:custom_tool_call, name:x_keyword_search, call_id:xs_call-428a4403-...}. Authorization now requires the item type AND that call-id prefix, on top of the existing destination and declaration gates. Names are still never matched — three literals have been observed for this one tool. The #1700 test now uses the real shape: an undeclared custom_tool_call named apply_patch with an ordinary call_id, inside an authorized turn, still refused. core.ts passes a correctly-typed empty set on the non-xAI branch, so a Set is now a compile error rather than a silent no-op. --- src/server/responses-undeclared-tool-guard.ts | 73 ++++++++++++------- src/server/responses/core.ts | 3 +- tests/responses-undeclared-tool-guard.test.ts | 17 ++--- 3 files changed, 56 insertions(+), 37 deletions(-) diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index 7ca824a25b..158e6585b8 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -5,19 +5,25 @@ import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; const CLIENT_EXECUTED_CALL_TYPES = new Set(["function_call", "custom_tool_call"]); /** - * Hosted declarations whose response items the PROVIDER executes, keyed by the item type it - * emits for them. These need no client answer, so their names are deliberately absent from the - * request catalog and must not be read as an undeclared client tool. + * Hosted declarations whose response items the PROVIDER executes, keyed by the request + * declaration type. These need no client answer, so their names are deliberately absent from + * the request catalog and must not be read as an undeclared client tool. * - * xAI surfaces hosted `x_search` as `custom_tool_call`. Probed 2026-08-22 through the proxy: - * a request declaring a named client tool alongside `x_search` produced `response.failed` with - * zero output, while the identical request direct to xAI completed normally. Observed call - * names were `x_keyword_search` and `x_semantic_search` in one turn, and `x_user_search` on the - * other xAI host — three literals for one tool, which is why this keys on the DECLARATION and - * the item type, never on the name. + * xAI surfaces hosted `x_search` as `custom_tool_call`. Probed 2026-08-23 against the OAuth CLI + * destination: its hosted calls use an `xs_call-` call-id prefix. Observed call names were + * `x_keyword_search`, `x_semantic_search`, and `x_user_search` — three literals for one tool, + * which is why authorization keys on the declaration, item type, and call-id prefix, never on + * the name. */ -export const PROVIDER_EXECUTED_DECLARATION_CALL_TYPES = new Map([ - ["x_search", "custom_tool_call"], +export type ProviderExecutedCallType = Readonly<{ + itemType: string; + callIdPrefix: string; +}>; + +type ProviderExecutedCallTypes = ReadonlySet; + +export const PROVIDER_EXECUTED_DECLARATION_CALL_TYPES = new Map([ + ["x_search", { itemType: "custom_tool_call", callIdPrefix: "xs_call-" }], ]); /** Nameless declaration kinds whose response items still require client execution. */ @@ -35,7 +41,7 @@ const NAMELESS_CLIENT_CALL_DISPLAY_NAMES = new Map([ ]); const EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES: ReadonlySet = new Set(); -const EMPTY_PROVIDER_EXECUTED_CALL_TYPES: ReadonlySet = new Set(); +const EMPTY_PROVIDER_EXECUTED_CALL_TYPES: ReadonlySet = new Set(); /** Supported hosted/private declarations that carry no client-executable wire name. */ const NAMELESS_TOOL_SPEC_TYPES = new Set([ @@ -144,7 +150,10 @@ function addNamelessClientCallTypes(callTypes: Set, specs: unknown): voi } } -function addProviderExecutedCallTypes(callTypes: Set, specs: unknown): void { +function addProviderExecutedCallTypes( + callTypes: Set, + specs: unknown, +): void { if (!Array.isArray(specs)) return; for (const spec of specs) { if (!isPlainObject(spec) || typeof spec.type !== "string") continue; @@ -159,8 +168,8 @@ function addProviderExecutedCallTypes(callTypes: Set, specs: unknown): v * Caller must gate this on the destination actually being that provider; a declaration alone * is not authority, or any upstream could claim a hosted shape it never serves. */ -export function collectProviderExecutedCallTypes(body: unknown): Set { - const callTypes = new Set(); +export function collectProviderExecutedCallTypes(body: unknown): Set { + const callTypes = new Set(); if (!isPlainObject(body)) return callTypes; addProviderExecutedCallTypes(callTypes, body.tools); if (Array.isArray(body.input)) { @@ -174,6 +183,20 @@ export function collectProviderExecutedCallTypes(body: unknown): Set { return callTypes; } +function isAuthorizedProviderExecutedCall( + item: Record, + callTypes: ProviderExecutedCallTypes, +): boolean { + if (typeof item.call_id !== "string") return false; + for (const callType of callTypes) { + if ( + item.type === callType.itemType + && item.call_id.startsWith(callType.callIdPrefix) + ) return true; + } + return false; +} + /** Nameless client-call item types authorized by supported request tool declarations. */ export function collectDeclaredNamelessClientCallTypes(body: unknown): Set { const callTypes = new Set(); @@ -237,18 +260,14 @@ function undeclaredNameInItem( item: unknown, declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet, - providerExecutedCallTypes: ReadonlySet = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, + providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, ): string | undefined { if (!isPlainObject(item)) return undefined; if (typeof item.type !== "string") return undefined; - // The provider executes this one itself, so there is no client name to authorize. - // - // RESIDUAL RISK, deliberate: inside a turn that declared such a hosted tool ON that provider, - // a hallucinated client custom tool is exempted too, because the three observed xAI names - // prove the name channel carries no signal. The alternative is failing every hosted-search - // turn. #1700's protection is untouched for every other turn, provider and item type — the - // exemption needs BOTH the destination and the declaration. - if (providerExecutedCallTypes.has(item.type)) return undefined; + // The provider executes this exact measured shape itself, so there is no client name to + // authorize. The caller supplies these signatures only for the matching destination and + // declarations; the item must additionally carry the hosted call-id prefix. + if (isAuthorizedProviderExecutedCall(item, providerExecutedCallTypes)) 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. @@ -270,7 +289,7 @@ export function undeclaredToolCallName( payload: unknown, declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES, - providerExecutedCallTypes: ReadonlySet = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, + providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, ): string | undefined { if (!isPlainObject(payload)) return undefined; if (payload.type === "response.output_item.added" || payload.type === "response.output_item.done") { @@ -288,7 +307,7 @@ export function undeclaredToolCallNameInResponse( response: unknown, declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES, - providerExecutedCallTypes: ReadonlySet = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, + providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, ): string | undefined { if (!isPlainObject(response) || !Array.isArray(response.output)) return undefined; for (const item of response.output) { @@ -332,7 +351,7 @@ function failedBlocks(name: string, newline: string): readonly string[] { export function createUndeclaredToolCallGuardBlockRewrite( declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES, - providerExecutedCallTypes: ReadonlySet = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, + providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, ): SseBlockRewrite { let tripped = false; return (block: string) => { diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index a08d74bc05..3aed07ef0f 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -315,6 +315,7 @@ import { undeclaredToolCallMessage, undeclaredToolCallName, undeclaredToolCallNameInResponse, + type ProviderExecutedCallType, } from "../responses-undeclared-tool-guard"; import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair"; import { responsesJsonToSseStream } from "../responses-json-events"; @@ -2947,7 +2948,7 @@ async function handleResponsesInner( // declaration alone cannot buy the exemption on some other upstream that never serves it. const providerExecutedCallTypes = isXaiResponsesDestination(route.provider) ? collectProviderExecutedCallTypes(clientToolAuthorizationBody) - : new Set(); + : new Set(); let request: Awaited>; try { request = await adapter.buildRequest(parsed, { headers: selectedForwardHeaders, translatorBudget }); diff --git a/tests/responses-undeclared-tool-guard.test.ts b/tests/responses-undeclared-tool-guard.test.ts index 2607b5cb08..c1ea2b3180 100644 --- a/tests/responses-undeclared-tool-guard.test.ts +++ b/tests/responses-undeclared-tool-guard.test.ts @@ -14,6 +14,7 @@ import { hasExplicitWireToolCatalog, undeclaredToolCallNameInResponse, UNDECLARED_TOOL_CALL_ERROR_CODE, + type ProviderExecutedCallType, } from "../src/server/responses-undeclared-tool-guard"; import { relaySseWithBlockRewrite } from "../src/server/sse-payload-rewrite"; import { handleResponses } from "../src/server/responses"; @@ -1252,12 +1253,10 @@ describe("undeclaredToolCallNameInResponse", () => { /** * xAI runs hosted `x_search` itself and reports the activity as a `custom_tool_call` whose name - * is absent from the request catalog. Probed 2026-08-22: a request declaring a named client tool - * alongside `x_search` completed normally direct to xAI, but through the proxy produced - * `response.failed` with zero output — the guard read the provider's own hosted call as an - * undeclared client tool. Observed names were `x_keyword_search` and `x_semantic_search` in one - * turn, and `x_user_search` on the other xAI host, so authorization keys on the declaration and - * the item type, never on the name. + * is absent from the request catalog. Probed 2026-08-23 against the OAuth CLI destination: the + * provider's hosted calls carry an `xs_call-` call-id prefix. Observed names were + * `x_keyword_search`, `x_semantic_search`, and `x_user_search`, so authorization keys on the + * declaration, item type, and call-id prefix, never on the name. */ describe("provider-executed hosted calls", () => { const declared = new Set(["shell"]); @@ -1272,7 +1271,7 @@ describe("provider-executed hosted calls", () => { test("authorizes the provider's hosted call under any of its observed names", () => { expect(collectProviderExecutedCallTypes({ tools: [{ type: "x_search" }] })) - .toEqual(new Set(["custom_tool_call"])); + .toEqual(new Set([{ itemType: "custom_tool_call", callIdPrefix: "xs_call-" }])); for (const name of ["x_keyword_search", "x_semantic_search", "x_user_search"]) { expect(undeclaredToolCallNameInResponse( hostedCall(name), declared, nameless, xSearchAuthorized, @@ -1294,13 +1293,13 @@ describe("provider-executed hosted calls", () => { // core.ts passes an empty set unless the route actually terminates at xAI, so a declaration // alone cannot buy the exemption on an upstream that never serves the hosted tool. expect(undeclaredToolCallNameInResponse( - hostedCall("x_keyword_search"), declared, nameless, new Set(), + hostedCall("x_keyword_search"), declared, nameless, new Set(), )).toBe("x_keyword_search"); }); test("#1700 still holds: an undeclared client tool is refused inside an authorized turn", () => { expect(undeclaredToolCallNameInResponse( - { output: [{ type: "function_call", name: "apply_patch", call_id: "c1" }] }, + { output: [{ type: "custom_tool_call", name: "apply_patch", call_id: "call_patch" }] }, declared, nameless, xSearchAuthorized, )).toBe("apply_patch"); }); From a083bd550995c6fbfd0f34be6038d79e01158bbd Mon Sep 17 00:00:00 2001 From: olddonkey Date: Sun, 23 Aug 2026 02:04:59 -0700 Subject: [PATCH 3/3] test(xai): pin hosted-call destination boundary --- tests/responses-undeclared-tool-guard.test.ts | 65 +++++++++++++++++++ tests/xai-transport.test.ts | 22 +++++++ 2 files changed, 87 insertions(+) diff --git a/tests/responses-undeclared-tool-guard.test.ts b/tests/responses-undeclared-tool-guard.test.ts index c1ea2b3180..1e93fb714d 100644 --- a/tests/responses-undeclared-tool-guard.test.ts +++ b/tests/responses-undeclared-tool-guard.test.ts @@ -1311,3 +1311,68 @@ describe("provider-executed hosted calls", () => { )).toBeUndefined(); }); }); + +describe("xAI hosted-call authorization through handleResponses", () => { + const hostedCall = { + type: "custom_tool_call", + id: "ctc_search", + call_id: "xs_call-1", + name: "x_keyword_search", + input: "{}", + status: "completed", + }; + + async function post(baseUrl: string): Promise { + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl, + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + const savedFetch = globalThis.fetch; + globalThis.fetch = (async () => Response.json({ + id: "resp_search", + status: "completed", + output: [hostedCall], + })) as typeof fetch; + try { + return await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/grok-4.6", + stream: false, + input: [{ role: "user", content: [{ type: "input_text", text: "search" }] }], + tools: [ + { type: "function", name: "shell", parameters: { type: "object" } }, + { type: "x_search" }, + ], + }), + }), config, { model: "", provider: "" }); + } finally { + globalThis.fetch = savedFetch; + } + } + + test("accepts the measured xs_call shape for an exact xAI destination", async () => { + const response = await post("https://api.x.ai/v1"); + + expect(response.status).toBe(200); + const body = await response.json() as { output: Array> }; + expect(body.output[0]).toMatchObject(hostedCall); + }); + + test("rejects the identical item for a lookalike destination", async () => { + const response = await post("https://api.x.ai.evil.test/v1"); + + expect(response.status).toBe(502); + const body = await response.json() as { error: { message: string } }; + expect(body.error.message).toContain('undeclared client tool "x_keyword_search"'); + }); +}); diff --git a/tests/xai-transport.test.ts b/tests/xai-transport.test.ts index 79e6cbf5e1..38cb29142f 100644 --- a/tests/xai-transport.test.ts +++ b/tests/xai-transport.test.ts @@ -3,6 +3,7 @@ import { createOpenAIChatAdapter } from "../src/adapters/openai-chat"; import { parseRequest } from "../src/responses/parser"; import { buildModelsRequest } from "../src/oauth"; import { + isXaiResponsesDestination, resolveProviderTransport, deriveXaiConvId, XAI_CONV_ID_HEADER, @@ -51,6 +52,27 @@ function parsed(): OcxParsedRequest { }; } +describe("xAI Responses destination detection", () => { + test.each([ + "https://api.x.ai/v1", + "https://api.x.ai:443/v1", + XAI_GROK_CLI_BASE_URL, + "https://CLI-CHAT-PROXY.GROK.COM:443/v1", + ])("accepts the exact xAI HTTPS destination %s", baseUrl => { + expect(isXaiResponsesDestination({ baseUrl })).toBe(true); + }); + + test.each([ + "http://api.x.ai/v1", + "https://api.x.ai:444/v1", + "https://api.x.ai.evil.test/v1", + "https://cli-chat-proxy.grok.com.evil.test/v1", + "not a URL", + ])("rejects a non-xAI or malformed destination %s", baseUrl => { + expect(isXaiResponsesDestination({ baseUrl })).toBe(false); + }); +}); + describe("xAI auth-mode transport selection", () => { test("OAuth selects the Grok CLI subscription transport and required headers", () => { const effective = resolveProviderTransport("xai", provider("oauth"));