fix(agents): guard CLI prompt tool names

This commit is contained in:
Vincent Koc
2026-06-02 04:04:33 +02:00
parent 29e9625b18
commit b89f8f3fe9
4 changed files with 149 additions and 4 deletions

View File

@@ -8,6 +8,7 @@ import { resolvePreferredOpenClawTmpDir } from "../infra/tmp-openclaw-dir.js";
import { escapeRegExp } from "../shared/regexp.js";
import {
buildCliArgs,
buildSystemPrompt,
loadPromptRefImages,
prepareCliPromptImagePayload,
resolveCliRunQueueKey,
@@ -219,6 +220,62 @@ describe("buildCliArgs", () => {
});
});
describe("buildSystemPrompt", () => {
it("skips unreadable tool names while preserving healthy CLI prompt tools", () => {
let reads = 0;
const unreadableTool = Object.defineProperty(
{
description: "bad experimental tool",
parameters: { type: "object", properties: {} },
execute: async () => ({ text: "bad" }),
},
"name",
{
get() {
throw new Error("fuzzplugin cli prompt tool name getter exploded");
},
},
);
const singleReadTool = {
description: "single read",
parameters: { type: "object", properties: {} },
execute: async () => ({ text: "ok" }),
get name() {
reads += 1;
if (reads > 1) {
throw new Error("cli prompt tool name read twice");
}
return " single_read ";
},
};
const systemPrompt = buildSystemPrompt({
workspaceDir: "/tmp",
modelDisplay: "codex/gpt-5.5",
tools: [
unreadableTool,
{
name: " ",
description: "blank",
parameters: {},
execute: async () => ({ text: "ok" }),
},
singleReadTool,
{
name: "sessions_spawn",
description: "delegate work",
parameters: { type: "object", properties: {} },
execute: async () => ({ text: "ok" }),
},
] as never,
});
expect(reads).toBe(1);
expect(systemPrompt).toContain("single_read");
expect(systemPrompt).toContain("sessions_spawn");
});
});
describe("writeCliImages", () => {
it("uses stable hashed file paths so repeated image hydration reuses the same path", async () => {
const workspaceDir = await fs.mkdtemp(

View File

@@ -124,7 +124,7 @@ export function buildCliAgentSystemPrompt(params: {
surface: "cli_backend",
}),
runtimeInfo,
toolNames: params.tools.map((tool) => tool.name),
toolNames: collectReadableCliToolNames(params.tools),
skillsPrompt: params.skillsPrompt,
userTimezone,
userTime,
@@ -135,6 +135,26 @@ export function buildCliAgentSystemPrompt(params: {
export const buildSystemPrompt = buildCliAgentSystemPrompt;
function collectReadableCliToolNames(tools: readonly AgentTool[]): string[] {
const names: string[] = [];
for (const tool of tools) {
try {
const rawName = tool.name;
if (typeof rawName !== "string") {
continue;
}
const name = rawName.trim();
if (name) {
names.push(name);
}
} catch {
// Runtime schema projection owns invalid-tool diagnostics; prompt setup must
// not let one unreadable tool descriptor abort the whole CLI turn.
}
}
return names;
}
export function normalizeCliModel(modelId: string, backend: CliBackendConfig): string {
const trimmed = modelId.trim();
if (!trimmed) {

View File

@@ -1161,9 +1161,57 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
}));
const ensureMcpLoopbackServer = vi.fn(createTestMcpLoopbackServer);
const createMcpLoopbackServerConfig = vi.fn(createTestMcpLoopbackServerConfig);
const unreadableNameTool = Object.defineProperty(
{
name: "broken_name",
label: "Broken Name",
description: "bad name",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
},
"name",
{
get() {
throw new Error("loopback tool name getter exploded");
},
},
);
const unreadableDescriptionTool = Object.defineProperty(
{
name: "memory_describe",
label: "Memory Describe",
description: "bad description",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
},
"description",
{
get() {
throw new Error("loopback tool description getter exploded");
},
},
);
const unreadableParametersTool = Object.defineProperty(
{
name: "memory_bad_schema",
label: "Bad Schema",
description: "bad schema",
parameters: { type: "object", properties: {} },
execute: vi.fn(),
},
"parameters",
{
get() {
throw new Error("loopback tool parameters getter exploded");
},
},
);
const resolveMcpLoopbackScopedTools = vi.fn(() => ({
agentId: "main",
tools: [
unreadableNameTool,
unreadableDescriptionTool,
unreadableParametersTool,
{
name: "memory_search",
label: "Memory Search",
@@ -1229,12 +1277,16 @@ describe("shouldSkipLocalCliCredentialEpoch", () => {
sourceReplyDeliveryMode: undefined,
});
expect(context.systemPrompt).toContain("## Memory Recall");
expect(context.systemPrompt).toContain("tools=memory_search");
expect(context.systemPrompt).toContain("tools=memory_describe,memory_search");
expect(context.systemPromptReport.tools.entries.map((entry) => entry.name)).toEqual([
"memory_describe",
"memory_search",
]);
expect(context.systemPromptReport.tools.entries[0]?.summaryChars).toBe(
"memory_describe".length,
);
expect(context.promptToolNamesHash).toBe(
hashCliSessionText(JSON.stringify(["memory_search"])),
hashCliSessionText(JSON.stringify(["memory_describe", "memory_search"])),
);
expect(context.reusableCliSession).toEqual({ invalidatedReason: "system-prompt" });
} finally {

View File

@@ -13,6 +13,7 @@ import {
resolveMcpLoopbackBearerToken,
} from "../../gateway/mcp-http.loopback-runtime.js";
import { resolveMcpLoopbackScopedTools } from "../../gateway/mcp-http.runtime.js";
import { buildMcpToolSchema } from "../../gateway/mcp-http.schema.js";
import { isClaudeCliProvider } from "../../plugin-sdk/anthropic-cli.js";
import type {
CliBackendAuthEpochMode,
@@ -61,6 +62,7 @@ import { composeSystemPromptWithHookContext } from "../embedded-agent-runner/run
import { buildCurrentInboundPrompt } from "../embedded-agent-runner/run/runtime-context-prompt.js";
import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-prompt.js";
import { applyPluginTextReplacements } from "../plugin-text-transforms.js";
import type { AgentTool } from "../runtime/index.js";
import { ensureSystemPromptCacheBoundary } from "../system-prompt-cache-boundary.js";
import { buildSystemPromptReport } from "../system-prompt-report.js";
import { appendModelIdentitySystemPrompt, buildModelIdentityPromptLine } from "../system-prompt.js";
@@ -107,6 +109,19 @@ const CLAUDE_CLI_CONTEXT_MODEL_ALIASES: Record<string, string> = {
"sonnet-4-6": "claude-sonnet-4-6",
};
function sanitizeCliPromptTools(tools: readonly AgentTool[]): AgentTool[] {
return buildMcpToolSchema([...tools] as Parameters<typeof buildMcpToolSchema>[0]).map(
(tool) =>
({
name: tool.name,
label: tool.name,
description: tool.description ?? "",
parameters: tool.inputSchema,
execute: async () => ({ content: [], details: {} }),
}) as unknown as AgentTool,
);
}
function resolveClaudeCliContextModelId(modelId: string): string {
const trimmed = modelId.trim();
const lower = trimmed.toLowerCase();
@@ -356,7 +371,7 @@ export async function prepareCliRunContext(
...(preparedBackendEnv ? { env: preparedBackendEnv } : {}),
...(preparedCleanup ? { cleanup: preparedCleanup } : {}),
};
const promptTools =
const rawPromptTools =
bundleMcpEnabled && mcpLoopbackRuntime
? prepareDeps.resolveMcpLoopbackScopedTools({
cfg: params.config ?? getRuntimeConfig(),
@@ -372,6 +387,7 @@ export async function prepareCliRunContext(
senderIsOwner: params.senderIsOwner,
}).tools
: [];
const promptTools = sanitizeCliPromptTools(rawPromptTools);
const promptToolNamesHash =
bundleMcpEnabled && mcpLoopbackRuntime
? hashCliSessionText(JSON.stringify(promptTools.map((tool) => tool.name).toSorted()))