diff --git a/src/server/management/agent-settings-routes.ts b/src/server/management/agent-settings-routes.ts index d7617c5884..d9757d5b11 100644 --- a/src/server/management/agent-settings-routes.ts +++ b/src/server/management/agent-settings-routes.ts @@ -948,10 +948,16 @@ export async function handleAgentSettingsRoutes(ctx: ManagementContext): Promise const { parseDesktopProfile, reconcileDesktopProfile } = await import("../../claude/desktop-profile"); const parsed = parseDesktopProfile(body.profile); const current = await buildClaudeDesktopState(config); + const availableRoutes = new Set(current.models.filter(item => item.available).map(item => item.route)); + for (const route of Object.keys(parsed.assignments)) { + if (!current.profile.assignments[route] && !availableRoutes.has(route)) { + throw new Error(`현재 사용할 수 없는 모델은 추가할 수 없습니다: ${route}`); + } + } for (const model of current.models.filter(item => !item.available)) { const before = current.profile.assignments[model.route]; const after = parsed.assignments[model.route]; - if (JSON.stringify(before) !== JSON.stringify(after)) { + if (after !== undefined && JSON.stringify(before) !== JSON.stringify(after)) { throw new Error(`현재 사용할 수 없는 모델은 옮길 수 없습니다: ${model.route}`); } } diff --git a/src/server/responses-undeclared-tool-guard.ts b/src/server/responses-undeclared-tool-guard.ts index cad1b39011..69b445a713 100644 --- a/src/server/responses-undeclared-tool-guard.ts +++ b/src/server/responses-undeclared-tool-guard.ts @@ -5,7 +5,7 @@ import { namespacedToolName, normalizeDeclaredToolName, } from "../types"; -import { sseDataPayload, type SseBlockRewrite } from "./sse-payload-rewrite"; +import { replaceSseDataPayload, 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"]); @@ -171,6 +171,49 @@ export function collectDeclaredWireToolNames(body: unknown): Set { return names; } +/** + * Collects explicitly declared bare wire tool names from a Responses request body. + * + * Bare wire tools are top-level declarations (or grouped under the builtin `functions` + * namespace) that are not namespaced and do not carry a flattened namespace delimiter (`__`) + * or dotted namespace alias (`.`). + * + * @param body - The outbound or inbound request body. + * @returns A set of declared bare tool names. + */ +export function collectDeclaredBareWireToolNames(body: unknown): Set { + const names = new Set(); + if (!isPlainObject(body)) return names; + const specGroups: unknown[] = [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") + ) specGroups.push(item.tools); + } + } + for (const specs of specGroups) { + if (!Array.isArray(specs)) continue; + for (const spec of specs) { + if (!isPlainObject(spec)) continue; + if (spec.type === "namespace" && Array.isArray(spec.tools)) { + if (spec.name === BUILTIN_FUNCTIONS_NAMESPACE) { + for (const inner of spec.tools) { + if (!isPlainObject(inner)) continue; + const name = wireToolInnerName(inner); + if (name && !name.includes("__") && !name.includes(".")) names.add(name); + } + } + continue; + } + const name = wireToolInnerName(spec); + if (name && !name.includes("__") && !name.includes(".")) names.add(name); + } + } + return names; +} + function addNamelessClientCallTypes(callTypes: Set, specs: unknown): void { if (!Array.isArray(specs)) return; for (const spec of specs) { @@ -286,11 +329,22 @@ export function hasExplicitWireToolCatalog(body: unknown): boolean { ); } +/** + * Evaluates whether an individual output item represents an undeclared tool call. + * + * @param item - The item to check. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredNamelessClientCallTypes - Nameless client call types declared by the request. + * @param providerExecutedCallTypes - Call types executed by the provider. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns The undeclared tool call name if unauthorized, or undefined if permitted. + */ function undeclaredNameInItem( item: unknown, declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet, providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, + declaredBare?: ReadonlySet, ): string | undefined { if (!isPlainObject(item)) return undefined; if (typeof item.type !== "string") return undefined; @@ -319,51 +373,232 @@ function undeclaredNameInItem( dottedAliasIsUnambiguous(item.namespace, name) && declared.has(dottedToolName(item.namespace, name)) ) return undefined; + const bareDeclared = declaredBare ?? declared; + const bare = name.startsWith("default.") ? name.slice("default.".length) : name; + if ( + item.namespace === "default" + && bare.length > 0 + && bareDeclared.has(bare) + && !declared.has(namespacedToolName(item.namespace, bare)) + && !declared.has(dottedToolName(item.namespace, bare)) + ) return undefined; return name; } - const effectiveName = normalizeDeclaredToolName(name, declared); + const effectiveName = normalizeDeclaredToolName(name, declared, declaredBare); if (declared.has(effectiveName)) return undefined; return name; } -/** First undeclared client tool named by a Responses SSE payload, or undefined. */ +/** + * First undeclared client tool named by a Responses SSE payload, or undefined. + * + * @param payload - The parsed SSE event payload. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredNamelessClientCallTypes - Nameless client call types declared by the request. + * @param providerExecutedCallTypes - Call types executed by the provider. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns The name of the first undeclared tool call, or undefined. + */ export function undeclaredToolCallName( payload: unknown, declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES, providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, + declaredBare?: ReadonlySet, ): 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, providerExecutedCallTypes); + return undeclaredNameInItem(payload.item, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare); + } + if (payload.type === "response.function_call_arguments.done" && typeof payload.name === "string") { + const fakeItem = { type: "function_call", name: payload.name, namespace: payload.namespace }; + return undeclaredNameInItem(fakeItem, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare); } // 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, providerExecutedCallTypes); + return undeclaredToolCallNameInResponse(payload.response, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare); } return undefined; } -/** First undeclared client tool in a Responses object's `output` array, or undefined. */ +/** + * First undeclared client tool in a Responses object's `output` array, or undefined. + * + * @param response - The Responses result object containing `output`. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredNamelessClientCallTypes - Nameless client call types declared by the request. + * @param providerExecutedCallTypes - Call types executed by the provider. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns The name of the first undeclared tool call, or undefined. + */ export function undeclaredToolCallNameInResponse( response: unknown, declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES, providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, + declaredBare?: ReadonlySet, ): string | undefined { if (!isPlainObject(response) || !Array.isArray(response.output)) return undefined; for (const item of response.output) { - const name = undeclaredNameInItem(item, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes); + const name = undeclaredNameInItem(item, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare); if (name !== undefined) return name; } return undefined; } +/** + * Formats an error message indicating that a routed provider emitted an undeclared tool call. + * + * @param name - The undeclared tool name emitted by the provider. + * @returns A formatted error message string. + */ export function undeclaredToolCallMessage(name: string): string { const reported = name.slice(0, MAX_REPORTED_NAME_CHARS); return `routed provider emitted undeclared client tool "${reported}"; only request-declared tools may be called`; } +/** + * Normalizes a single output item's default-namespaced tool call back to declared bare tool. + * + * Strips invented `default.` prefixes or `namespace: "default"` from tool calls when the bare + * tool name was declared and neither dotted nor flattened namespaced forms were declared (#4176). + * + * @param item - The output item to normalize. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns An object with the normalized value and a boolean indicating if changes occurred. + */ +export function normalizeDefaultNamespaceInItem( + item: unknown, + declared: ReadonlySet, + declaredBare?: ReadonlySet, +): { value: unknown; changed: boolean } { + if (!isPlainObject(item)) return { value: item, changed: false }; + if (!CLIENT_EXECUTED_CALL_TYPES.has(item.type as string)) { + return { value: item, changed: false }; + } + const name = item.name; + if (typeof name !== "string" || name.length === 0) { + return { value: item, changed: false }; + } + const bareDeclared = declaredBare ?? declared; + if (item.namespace === "default") { + const bare = name.startsWith("default.") ? name.slice("default.".length) : name; + if ( + bare.length > 0 + && bareDeclared.has(bare) + && !declared.has(namespacedToolName("default", bare)) + && !declared.has(dottedToolName("default", bare)) + ) { + const next: Record = { ...(item as Record), name: bare }; + delete next.namespace; + return { value: next, changed: true }; + } + return { value: item, changed: false }; + } + if (item.namespace === undefined || item.namespace === BUILTIN_FUNCTIONS_NAMESPACE) { + if (name.startsWith("default.")) { + const bare = name.slice("default.".length); + if ( + bare.length > 0 + && bareDeclared.has(bare) + && !declared.has("default." + bare) + && !declared.has("default__" + bare) + ) { + return { value: { ...item, name: bare }, changed: true }; + } + } + } + return { value: item, changed: false }; +} + +/** + * Normalizes default-namespaced tool calls in a Responses object's `output` array. + * + * @param response - The Responses result object containing `output`. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns An object with the normalized response and a boolean indicating if changes occurred. + */ +export function normalizeDefaultNamespaceInResponse( + response: unknown, + declared: ReadonlySet, + declaredBare?: ReadonlySet, +): { value: unknown; changed: boolean } { + if (!isPlainObject(response) || !Array.isArray(response.output)) { + return { value: response, changed: false }; + } + let changed = false; + const newOutput = response.output.map(item => { + const res = normalizeDefaultNamespaceInItem(item, declared, declaredBare); + if (res.changed) changed = true; + return res.value; + }); + if (!changed) return { value: response, changed: false }; + return { value: { ...response, output: newOutput }, changed: true }; +} + +/** + * Normalizes default-namespaced tool calls in a Responses SSE payload object. + * + * @param payload - The parsed SSE event payload. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns An object with the normalized payload and a boolean indicating if changes occurred. + */ +export function normalizeDefaultNamespaceInPayload( + payload: unknown, + declared: ReadonlySet, + declaredBare?: ReadonlySet, +): { value: unknown; changed: boolean } { + if (!isPlainObject(payload)) return { value: payload, changed: false }; + if (payload.type === "response.output_item.added" || payload.type === "response.output_item.done") { + const res = normalizeDefaultNamespaceInItem(payload.item, declared, declaredBare); + if (!res.changed) return { value: payload, changed: false }; + return { value: { ...payload, item: res.value }, changed: true }; + } + if (payload.type === "response.function_call_arguments.done" && typeof payload.name === "string") { + const fakeItem = { type: "function_call", name: payload.name, namespace: payload.namespace }; + const res = normalizeDefaultNamespaceInItem(fakeItem, declared, declaredBare); + if (res.changed) { + const normalizedItem = res.value as Record; + const next: Record = { ...payload, name: normalizedItem.name }; + if ("namespace" in next && !("namespace" in normalizedItem)) { + delete next.namespace; + } + return { value: next, changed: true }; + } + } + if (payload.type === "response.completed" || payload.type === "response.incomplete") { + const res = normalizeDefaultNamespaceInResponse(payload.response, declared, declaredBare); + if (!res.changed) return { value: payload, changed: false }; + return { value: { ...payload, response: res.value }, changed: true }; + } + return { value: payload, changed: false }; +} + +/** + * Normalizes default-namespaced tool calls in a raw Responses JSON string. + * + * @param jsonText - Raw JSON string representing a Responses object. + * @param declared - All wire tool names declared in the request catalog. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns The normalized JSON string, or original text if unchanged or invalid JSON. + */ +export function normalizeDefaultNamespaceInJson( + jsonText: string, + declared: ReadonlySet, + declaredBare?: ReadonlySet, +): string { + try { + const parsed = JSON.parse(jsonText); + const normalized = normalizeDefaultNamespaceInResponse(parsed, declared, declaredBare); + return normalized.changed ? JSON.stringify(normalized.value) : jsonText; + } catch { + return jsonText; + } +} + function failedBlocks(name: string, newline: string): readonly string[] { const failure = { type: "upstream_error", @@ -378,7 +613,8 @@ function failedBlocks(name: string, newline: string): readonly string[] { } /** - * Fail closed when a routed provider calls a tool the request never declared (#1700). + * Fail closed when a routed provider calls a tool the request never declared (#1700), + * and normalize provider-invented default namespaces back to declared bare tools (#4176). * * The bridged paths already refuse such a call (`declaredToolNames` in src/bridge.ts), but the * native Responses passthrough relayed it verbatim: Codex received a `function_call` for a tool @@ -389,11 +625,18 @@ function failedBlocks(name: string, newline: string): readonly string[] { * * Everything after the trip is dropped so a later `response.completed` cannot contradict the * terminal already sent. Non-JSON and non-item blocks pass through untouched. + * + * @param declared - All wire tool names declared in the request catalog. + * @param declaredNamelessClientCallTypes - Nameless client call types declared by the request. + * @param providerExecutedCallTypes - Call types executed by the provider. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * @returns An SSE block rewrite function. */ export function createUndeclaredToolCallGuardBlockRewrite( declared: ReadonlySet, declaredNamelessClientCallTypes: ReadonlySet = EMPTY_DECLARED_NAMELESS_CLIENT_CALL_TYPES, providerExecutedCallTypes: ProviderExecutedCallTypes = EMPTY_PROVIDER_EXECUTED_CALL_TYPES, + declaredBare?: ReadonlySet, ): SseBlockRewrite { let tripped = false; return (block: string) => { @@ -406,9 +649,15 @@ export function createUndeclaredToolCallGuardBlockRewrite( } catch { return [block]; } - const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes); - if (name === undefined) return [block]; - tripped = true; - return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n"); + const name = undeclaredToolCallName(parsed, declared, declaredNamelessClientCallTypes, providerExecutedCallTypes, declaredBare); + if (name !== undefined) { + tripped = true; + return failedBlocks(name, block.includes("\r\n") ? "\r\n" : "\n"); + } + const normalized = normalizeDefaultNamespaceInPayload(parsed, declared, declaredBare); + if (normalized.changed) { + return [replaceSseDataPayload(block, JSON.stringify(normalized.value))]; + } + return [block]; }; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 3f4cfe4345..1925ad7d91 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -424,10 +424,13 @@ import { type RoutedNamespaceToolAliases, } from "../../responses/namespace-tool-compat"; import { + collectDeclaredBareWireToolNames, collectDeclaredNamelessClientCallTypes, collectDeclaredWireToolNames, collectProviderExecutedCallTypes, createUndeclaredToolCallGuardBlockRewrite, + normalizeDefaultNamespaceInJson, + normalizeDefaultNamespaceInResponse, currentTurnWireToolCatalogBody, hasExplicitWireToolCatalog, undeclaredToolCallMessage, @@ -4714,6 +4717,7 @@ async function handleResponsesInner( ); const clientExplicitWireToolCatalog = hasExplicitWireToolCatalog(clientToolAuthorizationBody); const clientDeclaredWireToolNames = collectDeclaredWireToolNames(clientToolAuthorizationBody); + const clientDeclaredBareWireToolNames = collectDeclaredBareWireToolNames(clientToolAuthorizationBody); const clientDeclaredNamelessCallTypes = collectDeclaredNamelessClientCallTypes( clientToolAuthorizationBody, ); @@ -4789,6 +4793,7 @@ async function handleResponsesInner( }; let outboundRequestBody: Record | undefined; const declaredWireToolNames = new Set(); + const declaredBareWireToolNames = new Set(); const declaredNamelessClientCallTypes = new Set(); // `buildToolBridgeMaps` creates a bare alias only when the caller selected exactly one // namespaced tool through a bare tool_choice. Restore that request-bounded identity before @@ -4838,12 +4843,17 @@ async function handleResponsesInner( // 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. + declaredBareWireToolNames.clear(); if (replayedInputPrefixLength === 0) { for (const name of collectDeclaredWireToolNames(outboundRequestBody)) { declaredWireToolNames.add(name); } + for (const name of collectDeclaredBareWireToolNames(outboundRequestBody)) { + declaredBareWireToolNames.add(name); + } } for (const name of clientDeclaredWireToolNames) declaredWireToolNames.add(name); + for (const name of clientDeclaredBareWireToolNames) declaredBareWireToolNames.add(name); declaredNamelessClientCallTypes.clear(); if (replayedInputPrefixLength === 0) { for (const callType of collectDeclaredNamelessClientCallTypes(outboundRequestBody)) { @@ -4936,6 +4946,7 @@ async function handleResponsesInner( declaredWireToolNames, declaredNamelessClientCallTypes, providerExecutedCallTypes, + declaredBareWireToolNames, ) !== undefined) { inspectionSawUndeclaredTool = true; } @@ -4973,11 +4984,19 @@ async function handleResponsesInner( declaredWireToolNames, declaredNamelessClientCallTypes, providerExecutedCallTypes, + declaredBareWireToolNames, ) !== undefined ) { return; } - rememberPassthroughResponse?.(replayResponse); + const normalizedReplayResponse = (undeclaredToolGuardActive + ? normalizeDefaultNamespaceInResponse( + replayResponse, + declaredWireToolNames, + declaredBareWireToolNames, + ).value + : replayResponse) as typeof replayResponse; + rememberPassthroughResponse?.(normalizedReplayResponse); const firstCompletion = !inspectedCompletionSeen; inspectedCompletionSeen = true; if (firstCompletion && (inspectedTerminal === null || firstTerminalAllowsRecall)) { @@ -5998,6 +6017,7 @@ async function handleResponsesInner( declaredWireToolNames, declaredNamelessClientCallTypes, providerExecutedCallTypes, + declaredBareWireToolNames, ) : undefined, ].filter((rewrite): rewrite is NonNullable => rewrite !== undefined); @@ -6198,7 +6218,7 @@ async function handleResponsesInner( } const text = bounded.text; inspectResponseLogJson(logCtx, text); - const clientJson = (() => { + let clientJson = (() => { const restoredNamespace = restoreRoutedNamespaceCallsInJson( scrubSelfNamedToolCallNamespaceInJson( restoreImageGenCallsInJson(text, imageGenCallAliases), @@ -6244,6 +6264,7 @@ async function handleResponsesInner( declaredWireToolNames, declaredNamelessClientCallTypes, providerExecutedCallTypes, + declaredBareWireToolNames, ); } catch { return undefined; @@ -6252,6 +6273,11 @@ async function handleResponsesInner( if (undeclared !== undefined) { return formatErrorResponse(502, "upstream_error", undeclaredToolCallMessage(undeclared)); } + clientJson = normalizeDefaultNamespaceInJson( + clientJson, + declaredWireToolNames, + declaredBareWireToolNames, + ); } commitReasoningReplayServingRoute(); try { diff --git a/src/types/tools.ts b/src/types/tools.ts index bb91ebe63f..fcc0689819 100644 --- a/src/types/tools.ts +++ b/src/types/tools.ts @@ -66,22 +66,52 @@ const CODE_MODE_HELPER_TOOL_NAMES = [ */ export const CODE_MODE_EXEC_TOOL_NAME = "exec"; +/** + * Normalizes provider-emitted tool names against declared tool catalogs. + * + * Rewrites invented `default.` prefixes back to a declared bare tool when that bare tool + * is declared and neither `default.` nor `default__` was explicitly declared (#4176). + * Also normalizes legacy helper names (`exec_command`, `shell_command`, `apply_patch`) to + * `exec` when code-mode `exec` is declared in the request catalog. + * + * @param name - The tool name emitted on the wire by the provider. + * @param declared - All wire tool names declared in the request catalog, including aliases. + * @param declaredBare - Explicitly declared bare tool names without namespace provenance. + * When omitted, falls back to `declared`. + * @returns The normalized tool name to expose downstream. + */ export function normalizeDeclaredToolName( name: string, declared: ReadonlySet | undefined, + declaredBare?: ReadonlySet, ): string { - if (!declared || !declared.has(CODE_MODE_EXEC_TOOL_NAME)) return name; + if (!declared) return name; if (declared.has(name)) return name; - if (name === "apply_patch") return CODE_MODE_EXEC_TOOL_NAME; + let candidate = name; + if (name.startsWith("default.")) { + const bare = name.slice("default.".length); + const bareDeclared = declaredBare ?? declared; + if ( + bare.length > 0 + && bareDeclared.has(bare) + && !declared.has("default." + bare) + && !declared.has("default__" + bare) + ) { + candidate = bare; + } + } + if (!declared.has(CODE_MODE_EXEC_TOOL_NAME)) return candidate; + if (declared.has(candidate)) return candidate; + if (candidate === "apply_patch") return CODE_MODE_EXEC_TOOL_NAME; // When the catalog explicitly declares any legacy shell bridge name, the environment // genuinely exposes that tool — turn normalization off so a call is never mis-routed // to `exec`. if ((LEGACY_SHELL_BRIDGE_TOOL_NAMES as readonly string[]).some(legacy => declared.has(legacy))) { - return name; + return candidate; } - return (CODE_MODE_HELPER_TOOL_NAMES as readonly string[]).includes(name) + return (CODE_MODE_HELPER_TOOL_NAMES as readonly string[]).includes(candidate) ? CODE_MODE_EXEC_TOOL_NAME - : name; + : candidate; } /** diff --git a/tests/adapters/bridge-legacy-shell-normalization.test.ts b/tests/adapters/bridge-legacy-shell-normalization.test.ts index c6d5cdbe2d..0b4a94283c 100644 --- a/tests/adapters/bridge-legacy-shell-normalization.test.ts +++ b/tests/adapters/bridge-legacy-shell-normalization.test.ts @@ -87,6 +87,16 @@ describe("bridge normalizes code-mode helper names against the declared catalog" expect(sse).toContain("await tools.apply_patch"); }); + test("default.view_image echoes are normalized back to declared bare view_image (#4176)", async () => { + const sse = await drain(bridgeToResponsesSSE( + toolTurn("default.view_image", "{\"path\":\"image.png\"}"), "deepseek-x", undefined, undefined, undefined, undefined, 50_000, + { declaredToolNames: new Set(["view_image"]) }, + )); + expect(sse).not.toContain("undeclared client tool"); + expect(sse).toContain("\"name\":\"view_image\""); + expect(sse).toContain("image.png"); + }); + test("a catalog that declares exec_command itself is never rewritten", async () => { const sse = await drain(bridgeToResponsesSSE( toolTurn("exec_command"), "deepseek-x", undefined, undefined, undefined, undefined, 50_000, diff --git a/tests/claude-integration/claude-management-api.test.ts b/tests/claude-integration/claude-management-api.test.ts index fa99f3c14f..60bc012fa2 100644 --- a/tests/claude-integration/claude-management-api.test.ts +++ b/tests/claude-integration/claude-management-api.test.ts @@ -895,3 +895,64 @@ test("Claude Desktop PUT retains but cannot move an unavailable route", async () await server.stop(true); } }); + +test("Claude Desktop PUT allows deleting an unavailable route, but rejects modifying or adding one", async () => { + const seeded = loadConfig(); + seeded.claudeCode = { + desktopProfile: { + version: 1, + assignments: { + "missing/old-model": { family: "opus", alias: "claude-opus-4-8-20260101" }, + }, + defaults: { opus: "missing/old-model", fable: null, sonnet: null, haiku: null }, + }, + }; + saveConfig(seeded); + const server = startServer(0); + try { + const state = await fetch(new URL("/api/claude-desktop", server.url)).then(r => r.json()) as Record; + expect(state.models.find((model: { route: string }) => model.route === "missing/old-model")?.available).toBe(false); + + // Modifying an existing unavailable assignment (e.g. changing alias) is rejected with 400. + const modifyEdit = structuredClone(state.profile); + modifyEdit.assignments["missing/old-model"].alias = "claude-opus-4-8-20260202"; + const putModify = await fetch(new URL("/api/claude-desktop", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: modifyEdit }), + }); + expect(putModify.status).toBe(400); + expect((await putModify.json() as { error: string }).error).toContain("현재 사용할 수 없는 모델은 옮길 수 없습니다: missing/old-model"); + expect(loadConfig().claudeCode?.desktopProfile?.assignments["missing/old-model"]?.alias).toBe("claude-opus-4-8-20260101"); + + // Deleting an existing unavailable assignment succeeds with 200. + const deleteEdit = structuredClone(state.profile); + delete deleteEdit.assignments["missing/old-model"]; + deleteEdit.defaults.opus = Object.keys(deleteEdit.assignments).filter(route => deleteEdit.assignments[route].family === "opus").sort()[0] ?? null; + + const putDelete = await fetch(new URL("/api/claude-desktop", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: deleteEdit }), + }); + expect(putDelete.status).toBe(200); + const deleteResult = await putDelete.json() as Record; + expect(deleteResult.models.some((model: { route: string }) => model.route === "missing/old-model")).toBe(false); + expect(deleteResult.profile.assignments["missing/old-model"]).toBeUndefined(); + expect(loadConfig().claudeCode?.desktopProfile?.assignments["missing/old-model"]).toBeUndefined(); + + // Adding a newly unavailable assignment is rejected with 400. + const addEdit = structuredClone(deleteResult.profile); + addEdit.assignments["missing/new-model"] = { family: "fable", alias: "claude-opus-4-8-20260102" }; + addEdit.defaults.fable = "missing/new-model"; + const putAdd = await fetch(new URL("/api/claude-desktop", server.url), { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ profile: addEdit }), + }); + expect(putAdd.status).toBe(400); + expect((await putAdd.json() as { error: string }).error).toContain("현재 사용할 수 없는 모델은 추가할 수 없습니다: missing/new-model"); + } finally { + await server.stop(true); + } +}); diff --git a/tests/responses/responses-undeclared-tool-guard.test.ts b/tests/responses/responses-undeclared-tool-guard.test.ts index 41d8383a6c..6dd680c9d2 100644 --- a/tests/responses/responses-undeclared-tool-guard.test.ts +++ b/tests/responses/responses-undeclared-tool-guard.test.ts @@ -7,7 +7,11 @@ import { describe, expect, test } from "bun:test"; import { collectDeclaredNamelessClientCallTypes, + collectDeclaredBareWireToolNames, collectDeclaredWireToolNames, + normalizeDefaultNamespaceInJson, + normalizeDefaultNamespaceInPayload, + normalizeDefaultNamespaceInResponse, collectProviderExecutedCallTypes, createUndeclaredToolCallGuardBlockRewrite, currentTurnWireToolCatalogBody, @@ -63,6 +67,7 @@ async function relay( upstream: string, declared: Iterable, declaredNamelessClientCallTypes: Iterable = [], + declaredBare?: Iterable, ): Promise { const budget = createTestTranslatorBudget(); try { @@ -71,6 +76,8 @@ async function relay( createUndeclaredToolCallGuardBlockRewrite( new Set(declared), new Set(declaredNamelessClientCallTypes), + undefined, + declaredBare ? new Set(declaredBare) : undefined, ), budget, )); @@ -79,6 +86,33 @@ async function relay( } } +describe("collectDeclaredBareWireToolNames", () => { + test("collects top-level bare tools and functions namespace, ignoring other namespaces and flattened/dotted names", () => { + const names = collectDeclaredBareWireToolNames({ + tools: [ + { type: "function", name: "view_image" }, + { type: "custom", name: "exec" }, + { type: "function", name: "foo__tool" }, + { type: "function", name: "foo.tool" }, + { type: "namespace", name: "functions", tools: [{ type: "function", name: "shell" }, { type: "function", name: "bar.baz" }] }, + { type: "namespace", name: "linear", tools: [{ type: "function", name: "create_issue" }] }, + ], + input: [ + { + type: "additional_tools", + tools: [{ type: "function", name: "extra_tool" }, { type: "function", name: "pkg__sub" }], + }, + ], + }); + expect([...names].sort()).toEqual(["exec", "extra_tool", "shell", "view_image"]); + }); + + test("returns empty set for invalid or missing body", () => { + expect(collectDeclaredBareWireToolNames(null).size).toBe(0); + expect(collectDeclaredBareWireToolNames({}).size).toBe(0); + }); +}); + describe("collectDeclaredWireToolNames", () => { test("reads function, custom, and namespaced tools off the outbound body", () => { const names = collectDeclaredWireToolNames({ @@ -436,6 +470,235 @@ describe("undeclared tool call guard", () => { expect(await relay(upstream, ["linear.create_issue"])).toBe(upstream); }); + test("accepts and rewrites dotted default.view_image back to bare view_image in SSE added and done items (#4176)", async () => { + const outbound = { + tools: [{ type: "function", name: "view_image" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + + // output_item.added + const upstreamAdded = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + }); + const expectedAdded = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "view_image", arguments: "{}" }, + }); + expect(await relay(upstreamAdded, declared, [], declaredBare)).toBe(expectedAdded); + + // output_item.done with custom args + const upstreamDone = sse("response.output_item.done", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: JSON.stringify({ path: "/tmp/img.png" }) }, + }); + const expectedDone = sse("response.output_item.done", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "view_image", arguments: JSON.stringify({ path: "/tmp/img.png" }) }, + }); + expect(await relay(upstreamDone, declared, [], declaredBare)).toBe(expectedDone); + }); + + test("accepts and rewrites default. prefix in response.function_call_arguments.done SSE event (#4176)", async () => { + const outbound = { + tools: [{ type: "function", name: "view_image" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + + const upstreamDone = sse("response.function_call_arguments.done", { + item_id: "item_1", + output_index: 0, + call_id: "call_1", + name: "default.view_image", + arguments: JSON.stringify({ path: "/tmp/img.png" }), + }); + const expectedDone = sse("response.function_call_arguments.done", { + item_id: "item_1", + output_index: 0, + call_id: "call_1", + name: "view_image", + arguments: JSON.stringify({ path: "/tmp/img.png" }), + }); + expect(await relay(upstreamDone, declared, [], declaredBare)).toBe(expectedDone); + }); + + test("accepts and rewrites namespace: 'default' with bare name back to bare tool (#4176)", async () => { + const outbound = { + tools: [{ type: "function", name: "view_image" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + + const upstreamAdded = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", namespace: "default", name: "view_image", arguments: JSON.stringify({ detail: "high" }) }, + }); + const expectedAdded = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "view_image", arguments: JSON.stringify({ detail: "high" }) }, + }); + expect(await relay(upstreamAdded, declared, [], declaredBare)).toBe(expectedAdded); + + const upstreamDone = sse("response.output_item.done", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", namespace: "default", name: "view_image", arguments: JSON.stringify({ detail: "high" }) }, + }); + const expectedDone = sse("response.output_item.done", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "view_image", arguments: JSON.stringify({ detail: "high" }) }, + }); + expect(await relay(upstreamDone, declared, [], declaredBare)).toBe(expectedDone); + }); + + test("rewrites terminal snapshots (completed/incomplete) in SSE streams (#4176)", async () => { + const outbound = { + tools: [{ type: "function", name: "view_image" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + + const upstreamCompleted = sse("response.completed", { + response: { + id: "resp_1", + status: "completed", + output: [ + { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + { type: "function_call", id: "fc_2", call_id: "call_2", namespace: "default", name: "view_image", arguments: "{}" }, + ], + }, + }); + const expectedCompleted = sse("response.completed", { + response: { + id: "resp_1", + status: "completed", + output: [ + { type: "function_call", id: "fc_1", call_id: "call_1", name: "view_image", arguments: "{}" }, + { type: "function_call", id: "fc_2", call_id: "call_2", name: "view_image", arguments: "{}" }, + ], + }, + }); + expect(await relay(upstreamCompleted, declared, [], declaredBare)).toBe(expectedCompleted); + }); + + test("does not rewrite default.view_image to bare when default.view_image is explicitly declared (#4176)", async () => { + const outbound = { + tools: [{ type: "function", name: "view_image" }, { type: "function", name: "default.view_image" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + const upstream = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + }); + expect(await relay(upstream, declared, [], declaredBare)).toBe(upstream); + }); + + test("preserves namespaced default__view_image over bare normalization (#4176)", async () => { + const outbound = { + tools: [ + { type: "function", name: "view_image" }, + { type: "namespace", name: "default", tools: [{ type: "function", name: "view_image" }] }, + ], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + const upstream = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + }); + expect(await relay(upstream, declared, [], declaredBare)).toBe(upstream); + }); + + test("rejects default. prefix when the bare tool was not declared (#4176)", async () => { + const outbound = { + tools: [{ type: "function", name: "list_dir" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + const upstream = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + }); + const out = await relay(upstream, declared, [], declaredBare); + expect(out).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + }); + + test("rejects default.view_image and namespace=default when only a different namespaced tool was declared (#4176)", async () => { + const outbound = { + tools: [ + { type: "namespace", name: "foo", tools: [{ type: "function", name: "view_image" }] }, + ], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + + const upstreamDotted = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + }); + const outDotted = await relay(upstreamDotted, declared, [], declaredBare); + expect(outDotted).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + + const upstreamNs = sse("response.output_item.added", { + output_index: 0, + item: { type: "function_call", id: "fc_1", call_id: "call_1", namespace: "default", name: "view_image", arguments: "{}" }, + }); + const outNs = await relay(upstreamNs, declared, [], declaredBare); + expect(outNs).toContain(UNDECLARED_TOOL_CALL_ERROR_CODE); + }); + + test("normalizes default namespace in non-streaming JSON responses (#4176)", () => { + const outbound = { + tools: [{ type: "function", name: "view_image" }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + const jsonInput = JSON.stringify({ + id: "resp_1", + status: "completed", + output: [ + { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: JSON.stringify({ path: "img.png" }) }, + { type: "function_call", id: "fc_2", call_id: "call_2", namespace: "default", name: "view_image", arguments: "{}" }, + ], + }); + const normalized = normalizeDefaultNamespaceInJson(jsonInput, declared, declaredBare); + const parsed = JSON.parse(normalized); + expect(parsed.output[0]).toEqual({ + type: "function_call", + id: "fc_1", + call_id: "call_1", + name: "view_image", + arguments: JSON.stringify({ path: "img.png" }), + }); + expect(parsed.output[1]).toEqual({ + type: "function_call", + id: "fc_2", + call_id: "call_2", + name: "view_image", + arguments: "{}", + }); + }); + + test("does not normalize non-streaming JSON when bare tool was not declared (#4176)", () => { + const outbound = { + tools: [{ type: "namespace", name: "foo", tools: [{ type: "function", name: "view_image" }] }], + }; + const declared = collectDeclaredWireToolNames(outbound); + const declaredBare = collectDeclaredBareWireToolNames(outbound); + const jsonInput = JSON.stringify({ + id: "resp_1", + status: "completed", + output: [ + { type: "function_call", id: "fc_1", call_id: "call_1", name: "default.view_image", arguments: "{}" }, + ], + }); + const normalized = normalizeDefaultNamespaceInJson(jsonInput, declared, declaredBare); + expect(normalized).toBe(jsonInput); + expect(undeclaredToolCallNameInResponse(JSON.parse(normalized), declared, [], undefined, declaredBare)).toBe("default.view_image"); + }); + test("never blocks apply_patch when the request really declared it", async () => { // `apply_patch` is exempt from the routed custom-tool rewrite, so it reaches upstream as // `{type:"custom"}` and comes back as a `custom_tool_call`. A request that declares it must @@ -829,6 +1092,202 @@ describe("a refused turn does not become continuation state", () => { }); }); +describe("real relay and continuation caller normalization (#4176 / #4181)", () => { + const config = { + port: 0, + defaultProvider: "fixture", + providers: { + fixture: { + adapter: "openai-responses", + baseUrl: "https://fixture.test/v1", + authMode: "key", + apiKey: "fixture-key", + }, + }, + } as OcxConfig; + + test("Turn 1 stream normalizes default.view_image, and Turn 2 continuation expands normalized replay", async () => { + const originalFetch = globalThis.fetch; + const capturedOutbound: Array> = []; + let turn = 1; + + globalThis.fetch = (async (_input, init) => { + const body = init?.body ? JSON.parse(String(init.body)) as Record : {}; + capturedOutbound.push(body); + + if (turn === 2) { + return Response.json({ + id: "resp_turn2", + status: "completed", + output: [{ type: "message", role: "assistant", content: [{ type: "text", text: "image processed" }] }], + }); + } + turn++; + + const toolCall = { + type: "function_call", + id: "fc_img_1", + call_id: "call_img_1", + name: "default.view_image", + arguments: JSON.stringify({ path: "/tmp/sample.png" }), + status: "completed", + }; + const sse = [ + `data: ${JSON.stringify({ type: "response.created", response: { id: "resp_turn1", status: "in_progress" } })}\n\n`, + `data: ${JSON.stringify({ type: "response.output_item.added", output_index: 0, item: { ...toolCall, arguments: "", status: "in_progress" } })}\n\n`, + `data: ${JSON.stringify({ type: "response.output_item.done", output_index: 0, item: toolCall })}\n\n`, + `data: ${JSON.stringify({ type: "response.completed", response: { id: "resp_turn1", status: "completed", output: [toolCall] } })}\n\n`, + "data: [DONE]\n\n", + ].join(""); + return new Response(sse, { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + + try { + // 1. Turn 1 (stream): Client declares bare tool 'view_image'. + // Upstream sends SSE stream containing 'default.view_image' with call_id 'call_img_1'. + // Client receives normalized 'view_image' with 'call_img_1' and no 'default.view_image'. + const turn1Req = new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: true, + input: [{ role: "user", content: [{ type: "input_text", text: "inspect this image" }] }], + tools: [{ type: "function", name: "view_image", parameters: { type: "object" } }], + }), + }); + + const turn1Res = await handleResponses(turn1Req, config, { model: "", provider: "" }); + expect(turn1Res.status).toBe(200); + const clientStreamText = await turn1Res.text(); + + expect(clientStreamText).toContain('"name":"view_image"'); + expect(clientStreamText).toContain('"call_id":"call_img_1"'); + expect(clientStreamText).not.toContain("default.view_image"); + expect(clientStreamText).not.toContain("response.failed"); + + // Wait briefly for background stream inspector tee to commit normalized response state + await Bun.sleep(50); + + // 2. Turn 2: Client continuation with previous_response_id and function_call_output for 'call_img_1'. + // Outbound request to upstream expands the replayed tool call with normalized name 'view_image' and matching 'call_img_1'. + const turn2Req = new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: false, + previous_response_id: "resp_turn1", + input: [ + { + type: "function_call_output", + call_id: "call_img_1", + output: JSON.stringify({ width: 800, height: 600 }), + }, + ], + tools: [{ type: "function", name: "view_image", parameters: { type: "object" } }], + }), + }); + + const turn2Res = await handleResponses(turn2Req, config, { model: "", provider: "" }); + expect(turn2Res.status).toBe(200); + await turn2Res.json(); + + expect(capturedOutbound.length).toBe(2); + const turn2Outbound = capturedOutbound[1]; + const replayedToolCall = (turn2Outbound.input as Array>)?.find( + item => item.call_id === "call_img_1", + ); + expect(replayedToolCall).toBeDefined(); + expect(replayedToolCall).toMatchObject({ + type: "function_call", + id: "fc_img_1", + call_id: "call_img_1", + name: "view_image", + }); + expect(JSON.stringify(turn2Outbound)).not.toContain("default.view_image"); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("negative control: request declaring only 'foo__view_image', upstream returning 'default.view_image' is rejected with 502", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = (async () => Response.json({ + id: "resp_foo", + status: "completed", + output: [{ + type: "function_call", + id: "fc_img_bad", + call_id: "call_img_bad", + name: "default.view_image", + arguments: "{}", + status: "completed", + }], + })) as typeof fetch; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: false, + input: [{ role: "user", content: [{ type: "input_text", text: "inspect" }] }], + tools: [{ type: "function", name: "foo__view_image", parameters: { type: "object" } }], + }), + }), config, { model: "", provider: "" }); + + expect(response.status).toBe(502); + const body = await response.json() as { error: { message: string } }; + expect(body.error.message).toContain('undeclared client tool "default.view_image"'); + } finally { + globalThis.fetch = originalFetch; + } + }); + + test("negative control: request explicitly declaring 'default.view_image' preserves 'default.view_image'", async () => { + const originalFetch = globalThis.fetch; + const toolCall = { + type: "function_call", + id: "fc_img_explicit", + call_id: "call_img_explicit", + name: "default.view_image", + arguments: JSON.stringify({ path: "/tmp/explicit.png" }), + status: "completed", + }; + + globalThis.fetch = (async () => Response.json({ + id: "resp_explicit", + status: "completed", + output: [toolCall], + })) as typeof fetch; + + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/deepseek-v4-flash", + stream: false, + input: [{ role: "user", content: [{ type: "input_text", text: "inspect" }] }], + tools: [{ type: "function", name: "default.view_image", parameters: { type: "object" } }], + }), + }), config, { model: "", provider: "" }); + + expect(response.status).toBe(200); + const body = await response.json() as { output: Array> }; + expect(body.output[0]).toMatchObject({ + type: "function_call", + name: "default.view_image", + call_id: "call_img_explicit", + }); + } finally { + globalThis.fetch = originalFetch; + } + }); +}); + describe("empty and absent tool catalogs", () => { const config = { port: 0,