Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/server/management/agent-settings-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
}
Expand Down
273 changes: 261 additions & 12 deletions src/server/responses-undeclared-tool-guard.ts

Large diffs are not rendered by default.

30 changes: 28 additions & 2 deletions src/server/responses/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,10 +424,13 @@ import {
type RoutedNamespaceToolAliases,
} from "../../responses/namespace-tool-compat";
import {
collectDeclaredBareWireToolNames,
collectDeclaredNamelessClientCallTypes,
collectDeclaredWireToolNames,
collectProviderExecutedCallTypes,
createUndeclaredToolCallGuardBlockRewrite,
normalizeDefaultNamespaceInJson,
normalizeDefaultNamespaceInResponse,
currentTurnWireToolCatalogBody,
hasExplicitWireToolCatalog,
undeclaredToolCallMessage,
Expand Down Expand Up @@ -4714,6 +4717,7 @@ async function handleResponsesInner(
);
const clientExplicitWireToolCatalog = hasExplicitWireToolCatalog(clientToolAuthorizationBody);
const clientDeclaredWireToolNames = collectDeclaredWireToolNames(clientToolAuthorizationBody);
const clientDeclaredBareWireToolNames = collectDeclaredBareWireToolNames(clientToolAuthorizationBody);
const clientDeclaredNamelessCallTypes = collectDeclaredNamelessClientCallTypes(
clientToolAuthorizationBody,
);
Expand Down Expand Up @@ -4789,6 +4793,7 @@ async function handleResponsesInner(
};
let outboundRequestBody: Record<string, unknown> | undefined;
const declaredWireToolNames = new Set<string>();
const declaredBareWireToolNames = new Set<string>();
const declaredNamelessClientCallTypes = new Set<string>();
// `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
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -4936,6 +4946,7 @@ async function handleResponsesInner(
declaredWireToolNames,
declaredNamelessClientCallTypes,
providerExecutedCallTypes,
declaredBareWireToolNames,
) !== undefined) {
inspectionSawUndeclaredTool = true;
}
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -5998,6 +6017,7 @@ async function handleResponsesInner(
declaredWireToolNames,
declaredNamelessClientCallTypes,
providerExecutedCallTypes,
declaredBareWireToolNames,
)
: undefined,
].filter((rewrite): rewrite is NonNullable<typeof rewrite> => rewrite !== undefined);
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -6244,6 +6264,7 @@ async function handleResponsesInner(
declaredWireToolNames,
declaredNamelessClientCallTypes,
providerExecutedCallTypes,
declaredBareWireToolNames,
);
} catch {
return undefined;
Expand All @@ -6252,6 +6273,11 @@ async function handleResponsesInner(
if (undeclared !== undefined) {
return formatErrorResponse(502, "upstream_error", undeclaredToolCallMessage(undeclared));
}
clientJson = normalizeDefaultNamespaceInJson(
clientJson,
declaredWireToolNames,
declaredBareWireToolNames,
);
}
commitReasoningReplayServingRoute();
try {
Expand Down
40 changes: 35 additions & 5 deletions src/types/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.<name>` prefixes back to a declared bare tool when that bare tool
* is declared and neither `default.<name>` nor `default__<name>` 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<string> | undefined,
declaredBare?: ReadonlySet<string>,
): 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;
}

/**
Expand Down
10 changes: 10 additions & 0 deletions tests/adapters/bridge-legacy-shell-normalization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
61 changes: 61 additions & 0 deletions tests/claude-integration/claude-management-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, any>;
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<string, any>;
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);
}
});
Loading
Loading