Skip to content

Commit 99b9c6a

Browse files
claude-code-bestglm-5.2
andcommitted
fix: ExecuteExtraTool 加 schema 预校验防止 deferred 工具崩溃
模型通过 ExecuteExtraTool 调 CronCreate 时把字段名拼成 schedule(而非 cron),raw params 透传到 validateInput,input.cron 为 undefined,触 发 parseCronExpression 的 expr.trim() 抛 TypeError。所有参数组合同 样崩溃,与具体参数无关。 三层修复: - ExecuteTool.call 在调 validateInput 前先跑 targetTool.inputSchema. safeParse(鸭子类型跳过 MCP),失败时返回 formatZodValidationError 友好消息,成功时透传 parsed data 让 .default() 生效、strictObject 拦截多余字段。这是架构根治,覆盖所有 deferred 工具。 - CronCreateTool.validateInput 顶部加 typeof string 守卫,给模型精确 字段错误消息。 - parseCronExpression 顶部加 typeof string 守卫,覆盖 cronToHuman 等 所有调用者。 新增 4 个测试覆盖:cron undefined 输入返回 null、ExecuteTool schema 验证拒绝错字段名且 validateInput 不被触达、.default() 透传、MCP-like 工具跳过 schema 校验。 Co-Authored-By: glm-5.2 <zai-org@claude-code-best.win>
1 parent b83395c commit 99b9c6a

5 files changed

Lines changed: 233 additions & 1 deletion

File tree

packages/builtin-tools/src/tools/ExecuteTool/ExecuteTool.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from 'src/Tool.js'
1111
import { lazySchema } from 'src/utils/lazySchema.js'
1212
import { createUserMessage } from 'src/utils/messages.js'
13+
import { formatZodValidationError } from 'src/utils/toolErrors.js'
1314
import {
1415
extractDiscoveredToolNames,
1516
isSearchExtraToolsEnabledOptimistic,
@@ -121,6 +122,42 @@ export const ExecuteTool = buildTool({
121122
}
122123
}
123124

125+
// Schema-validate params against the target tool BEFORE delegating.
126+
// ExecuteExtraTool passes raw params straight from the model to
127+
// validateInput/call without re-running the target's zod schema, so a
128+
// wrong field name (e.g. 'schedule' instead of 'cron') or a missing
129+
// required field reaches the tool as undefined and the first
130+
// .trim()/.length/.split() crashes with "undefined is not an object".
131+
// CronCreateTool's .trim() crash was the reported symptom; centralizing
132+
// the check here covers every deferred tool without relying on each one
133+
// to defensively guard its own validateInput. Duck-typed so MCP tools
134+
// (whose schema is inputJSONSchema, not zod) skip this branch.
135+
const targetSchema = targetTool.inputSchema as
136+
| { safeParse?: (data: unknown) => unknown }
137+
| undefined
138+
if (targetSchema?.safeParse) {
139+
const parsed = targetSchema.safeParse(input.params) as
140+
| { success: true; data: Record<string, unknown> }
141+
| { success: false; error: z.ZodError }
142+
if (!parsed.success) {
143+
return {
144+
data: {
145+
result: null,
146+
tool_name: input.tool_name,
147+
},
148+
newMessages: [
149+
createUserMessage({
150+
content: formatZodValidationError(input.tool_name, parsed.error),
151+
}),
152+
],
153+
}
154+
}
155+
// Use parsed params going forward — picks up .default() values and
156+
// strips unknown keys for strictObject schemas so validateInput/call
157+
// never see fields they don't expect.
158+
input.params = parsed.data
159+
}
160+
124161
// Validate input before delegating — prevents crashes when the model
125162
// omits required params (e.g. TeamCreate without team_name →
126163
// sanitizeName(undefined).replace() TypeError).

packages/builtin-tools/src/tools/ExecuteTool/__tests__/ExecuteTool.runner.ts

Lines changed: 167 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, test, expect } from 'bun:test'
22
import { mock } from 'bun:test'
3+
import { z } from 'zod/v4'
34
import { logMock } from '../../../../../../tests/mocks/log'
45
import { debugMock } from '../../../../../../tests/mocks/debug'
56

@@ -36,7 +37,16 @@ mock.module('src/utils/searchExtraTools.js', () => ({
3637
isSearchExtraToolsToolAvailable: () => true,
3738
isSearchExtraToolsEnabled: async () => true,
3839
isToolReferenceBlock: () => false,
39-
extractDiscoveredToolNames: () => new Set(['TestTool', 'SecretTool']),
40+
// Mark every name as discovered so tests can exercise tools other than
41+
// TestTool/SecretTool without being blocked by the discovery guard.
42+
extractDiscoveredToolNames: () =>
43+
new Set([
44+
'TestTool',
45+
'SecretTool',
46+
'CronCreate',
47+
'WithDefaults',
48+
'McpTool',
49+
]),
4050
isDeferredToolsDeltaEnabled: () => false,
4151
getDeferredToolsDelta: () => null,
4252
}))
@@ -52,6 +62,7 @@ mock.module('src/utils/messages.js', () => ({
5262
content,
5363
uuid: 'test-uuid',
5464
}),
65+
INTERRUPT_MESSAGE_FOR_TOOL_USE: '[Request interrupted]',
5566
}))
5667

5768
const { ExecuteTool } = await import('../ExecuteTool.js')
@@ -92,6 +103,48 @@ function makeMockTool(name: string, callResult: unknown = 'ok') {
92103
}
93104
}
94105

106+
/**
107+
* Builds a mock tool with a real zod inputSchema, mirroring how actual
108+
* deferred tools (e.g. CronCreateTool) expose their schema. Records the
109+
* params that reach call() so tests can assert what was delegated.
110+
*/
111+
function makeMockToolWithSchema(
112+
name: string,
113+
schema: z.ZodType,
114+
opts: {
115+
validateInput?: (input: Record<string, unknown>) => {
116+
result: boolean
117+
message?: string
118+
}
119+
} = {},
120+
) {
121+
const calls: Record<string, unknown>[] = []
122+
return {
123+
tool: {
124+
name,
125+
inputSchema: schema,
126+
call: async (input: Record<string, unknown>) => {
127+
calls.push(input)
128+
return { data: { ok: true, received: input } }
129+
},
130+
validateInput: opts.validateInput,
131+
checkPermissions: async () => ({ behavior: 'allow' as const }),
132+
isEnabled: () => true,
133+
isConcurrencySafe: () => true,
134+
isReadOnly: () => false,
135+
isMcp: false,
136+
userFacingName: () => name,
137+
renderToolUseMessage: () => `Running ${name}`,
138+
mapToolResultToToolResultBlockParam: (content: unknown, id: string) => ({
139+
tool_use_id: id,
140+
type: 'tool_result',
141+
content,
142+
}),
143+
},
144+
calls,
145+
}
146+
}
147+
95148
describe('ExecuteTool', () => {
96149
test('executes a target tool by name', async () => {
97150
const mockTarget = makeMockTool('TestTool', { result: 'success' })
@@ -182,4 +235,117 @@ describe('ExecuteTool', () => {
182235
expect(ExecuteTool.searchHint).toContain('execute')
183236
expect(ExecuteTool.searchHint).toContain('tool')
184237
})
238+
239+
test('schema-validates params against target tool before delegating', async () => {
240+
// Reproduces the CronCreate bug class: model passes 'schedule' but the
241+
// schema requires 'cron'. Without the pre-validation, params reach
242+
// validateInput with cron=undefined and crash on .trim().
243+
const { tool, calls } = makeMockToolWithSchema(
244+
'CronCreate',
245+
z.strictObject({
246+
cron: z.string(),
247+
prompt: z.string(),
248+
}),
249+
{
250+
validateInput: input => {
251+
// Mirrors CronCreateTool.validateInput pre-fix behavior — would
252+
// crash on undefined.trim() if schema pre-validation lets bad
253+
// params through. The guard in ExecuteTool must prevent this.
254+
const cron = input.cron as string | undefined
255+
if (typeof cron !== 'string') {
256+
throw new TypeError(
257+
"undefined is not an object (evaluating 'cron.trim')",
258+
)
259+
}
260+
return { result: true }
261+
},
262+
},
263+
)
264+
const ctx = makeContext([tool])
265+
266+
const result = await ExecuteTool.call(
267+
{
268+
tool_name: 'CronCreate',
269+
params: { schedule: '*/5 * * * *', prompt: 'hi' },
270+
},
271+
ctx,
272+
async () => ({ behavior: 'allow' }),
273+
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
274+
undefined,
275+
)
276+
277+
// Schema validation rejects the wrong field name and returns a model-
278+
// friendly error instead of crashing.
279+
expect(result.data).toEqual({
280+
result: null,
281+
tool_name: 'CronCreate',
282+
})
283+
expect(result.newMessages).toBeDefined()
284+
const message = result.newMessages![0].content as string
285+
// Model gets told both what was missing and what was unexpected.
286+
expect(message).toMatch(/cron/i)
287+
expect(message).toMatch(/schedule/i)
288+
// validateInput was never called, so no crash reached it.
289+
expect(calls.length).toBe(0)
290+
})
291+
292+
test('passes through parsed params to target tool, applying schema defaults', async () => {
293+
const { tool, calls } = makeMockToolWithSchema(
294+
'WithDefaults',
295+
z.strictObject({
296+
cron: z.string(),
297+
prompt: z.string(),
298+
recurring: z.boolean().default(true),
299+
}),
300+
)
301+
const ctx = makeContext([tool])
302+
303+
const result = await ExecuteTool.call(
304+
{
305+
// recurring intentionally omitted — schema default must fill it in.
306+
tool_name: 'WithDefaults',
307+
params: { cron: '*/5 * * * *', prompt: 'hi' },
308+
},
309+
ctx,
310+
async () => ({ behavior: 'allow' }),
311+
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
312+
undefined,
313+
)
314+
315+
expect(result.data).toEqual({
316+
result: {
317+
ok: true,
318+
received: { cron: '*/5 * * * *', prompt: 'hi', recurring: true },
319+
},
320+
tool_name: 'WithDefaults',
321+
})
322+
expect(calls.length).toBe(1)
323+
// .default() applied — target tool sees recurring: true without
324+
// needing to defend against undefined itself.
325+
expect(calls[0]).toEqual({
326+
cron: '*/5 * * * *',
327+
prompt: 'hi',
328+
recurring: true,
329+
})
330+
})
331+
332+
test('skips schema validation for tools without safeParse (e.g. MCP)', async () => {
333+
// MCP tools expose inputJSONSchema, not zod — must not crash on
334+
// duck-typed schema check.
335+
const mockTarget = makeMockTool('McpTool', { result: 'ok' })
336+
const ctx = makeContext([mockTarget])
337+
338+
const result = await ExecuteTool.call(
339+
{ tool_name: 'McpTool', params: { anything: true } },
340+
ctx,
341+
async () => ({ behavior: 'allow' }),
342+
{ type: 'assistant', content: [], uuid: 'msg1' } as never,
343+
undefined,
344+
)
345+
346+
expect(result.data).toEqual({
347+
result: { result: 'ok' },
348+
tool_name: 'McpTool',
349+
})
350+
})
185351
})

packages/builtin-tools/src/tools/ScheduleCronTool/CronCreateTool.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,19 @@ export const CronCreateTool = buildTool({
8080
return getCronFilePath()
8181
},
8282
async validateInput(input): Promise<ValidationResult> {
83+
// ExecuteExtraTool passes raw params through without re-running this
84+
// tool's inputSchema, so when the model uses a wrong field name (e.g.
85+
// 'schedule' instead of 'cron'), input.cron is undefined. parseCronExpression
86+
// would throw on .trim(undefined); catch here with a message that tells
87+
// the model which field is actually required.
88+
if (typeof input.cron !== 'string' || input.cron.length === 0) {
89+
return {
90+
result: false,
91+
message:
92+
"Missing required parameter 'cron' (5-field cron expression, e.g. '*/5 * * * *'). Check parameter names against the schema.",
93+
errorCode: 1,
94+
}
95+
}
8396
if (!parseCronExpression(input.cron)) {
8497
return {
8598
result: false,

src/utils/__tests__/cron.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,16 @@ describe('parseCronExpression', () => {
9494
test('returns null for non-numeric tokens', () => {
9595
expect(parseCronExpression('abc * * * *')).toBeNull()
9696
})
97+
98+
test('returns null for undefined input without throwing', () => {
99+
// CronCreateTool.validateInput receives raw params from ExecuteExtraTool;
100+
// when the model passes a wrong field name (e.g. 'schedule' instead of
101+
// 'cron'), input.cron is undefined. Calling .trim() on undefined crashes
102+
// with "undefined is not an object" — parseCronExpression must fail
103+
// gracefully so the tool layer can return a clear validation error.
104+
expect(parseCronExpression(undefined as unknown as string)).toBeNull()
105+
expect(parseCronExpression(null as unknown as string)).toBeNull()
106+
})
97107
})
98108

99109
describe('field range validation', () => {

src/utils/cron.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@ function expandField(field: string, range: FieldRange): number[] | null {
8181
* Returns null if invalid or unsupported syntax.
8282
*/
8383
export function parseCronExpression(expr: string): CronFields | null {
84+
// Defensive against non-string input: ExecuteExtraTool passes raw params
85+
// through to validateInput without re-running the target tool's schema, so
86+
// a wrong field name (e.g. 'schedule' instead of 'cron') surfaces here as
87+
// undefined. Without this guard, .trim() below throws "undefined is not an
88+
// object" — every CronCreate call from ExecuteExtraTool fails identically.
89+
if (typeof expr !== 'string') return null
8490
const parts = expr.trim().split(/\s+/)
8591
if (parts.length !== 5) return null
8692

0 commit comments

Comments
 (0)