Skip to content

Commit 88e0985

Browse files
committed
fix(codex): quarantine unreadable dynamic tools
1 parent a595aba commit 88e0985

2 files changed

Lines changed: 139 additions & 27 deletions

File tree

extensions/codex/src/app-server/dynamic-tools.test.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -371,6 +371,35 @@ describe("createCodexDynamicToolBridge", () => {
371371
expect(badExecute).not.toHaveBeenCalled();
372372
});
373373

374+
it("quarantines dynamic tools whose schema accessors throw", () => {
375+
const badExecute = vi.fn();
376+
const badTool = {
377+
name: "fuzzplugin_move_angles",
378+
description: "Broken dynamic tool",
379+
get parameters() {
380+
throw new Error("parameters getter exploded");
381+
},
382+
execute: badExecute,
383+
} as unknown as AnyAgentTool;
384+
385+
const bridge = createCodexDynamicToolBridge({
386+
tools: [createTool({ name: "message" }), badTool],
387+
signal: new AbortController().signal,
388+
});
389+
390+
expect(bridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]);
391+
expect(bridge.specs.map((tool) => tool.name)).toEqual(["message"]);
392+
expect(bridge.telemetry.quarantinedTools).toEqual([
393+
{
394+
tool: "fuzzplugin_move_angles",
395+
violations: [
396+
"fuzzplugin_move_angles.inputSchema could not be read: parameters getter exploded",
397+
],
398+
},
399+
]);
400+
expect(badExecute).not.toHaveBeenCalled();
401+
});
402+
374403
it("can expose all dynamic tools directly for compatibility", () => {
375404
const bridge = createCodexDynamicToolBridge({
376405
tools: [createTool({ name: "web_search" }), createTool({ name: "message" })],

extensions/codex/src/app-server/dynamic-tools.ts

Lines changed: 110 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ type CodexToolResultHookContext = Omit<CodexDynamicToolHookContext, "config">;
5151

5252
type ProjectedCodexDynamicTool = {
5353
tool: AnyAgentTool;
54+
name: string;
55+
description: string;
5456
inputSchema: JsonValue;
5557
};
5658

@@ -86,6 +88,7 @@ export const CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE = "openclaw";
8688
// spawn_agent remains the primary Codex subagent surface.
8789
const ALWAYS_DIRECT_DYNAMIC_TOOL_NAMES = new Set(["sessions_yield"]);
8890
const DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 16_000;
91+
const UNREADABLE_DYNAMIC_TOOL_NAME = "<unreadable dynamic tool name>";
8992

9093
export function createCodexDynamicToolBridge(params: {
9194
tools: AnyAgentTool[];
@@ -101,19 +104,19 @@ export function createCodexDynamicToolBridge(params: {
101104
const registeredProjection = params.registeredTools
102105
? projectCodexDynamicTools(params.registeredTools)
103106
: availableProjection;
104-
const availableTools = availableProjection.tools.map(({ tool, inputSchema }) => {
107+
const availableTools = availableProjection.tools.map((projectedTool) => {
108+
const { tool } = projectedTool;
105109
if (isToolWrappedWithBeforeToolCallHook(tool)) {
106110
setBeforeToolCallDiagnosticsEnabled(tool, false);
107-
return { tool, inputSchema };
111+
return projectedTool;
108112
}
109113
return {
114+
...projectedTool,
110115
tool: wrapToolWithBeforeToolCallHook(tool, params.hookContext, { emitDiagnostics: false }),
111-
inputSchema,
112116
};
113117
});
114-
const toolMap = new Map(availableTools.map(({ tool }) => [tool.name, tool]));
115-
const registeredTools = registeredProjection.tools.map(({ tool }) => tool);
116-
const registeredToolNames = new Set(registeredTools.map((tool) => tool.name));
118+
const toolMap = new Map(availableTools.map((tool) => [tool.name, tool]));
119+
const registeredToolNames = new Set(registeredProjection.tools.map((tool) => tool.name));
117120
const quarantinedTools = dedupeQuarantinedDynamicTools([
118121
...availableProjection.quarantinedTools,
119122
...registeredProjection.quarantinedTools,
@@ -142,26 +145,28 @@ export function createCodexDynamicToolBridge(params: {
142145
]);
143146

144147
return {
145-
availableSpecs: availableTools.map(({ tool, inputSchema }) =>
148+
availableSpecs: availableTools.map((tool) =>
146149
createCodexDynamicToolSpec({
147-
tool,
148-
inputSchema,
150+
name: tool.name,
151+
description: tool.description,
152+
inputSchema: tool.inputSchema,
149153
loading: params.loading ?? "searchable",
150154
directToolNames,
151155
}),
152156
),
153-
specs: registeredProjection.tools.map(({ tool, inputSchema }) =>
157+
specs: registeredProjection.tools.map((tool) =>
154158
createCodexDynamicToolSpec({
155-
tool,
156-
inputSchema,
159+
name: tool.name,
160+
description: tool.description,
161+
inputSchema: tool.inputSchema,
157162
loading: params.loading ?? "searchable",
158163
directToolNames,
159164
}),
160165
),
161166
telemetry,
162167
handleToolCall: async (call, options) => {
163-
const tool = toolMap.get(call.tool);
164-
if (!tool) {
168+
const toolEntry = toolMap.get(call.tool);
169+
if (!toolEntry) {
165170
if (registeredToolNames.has(call.tool)) {
166171
return {
167172
contentItems: [
@@ -178,6 +183,7 @@ export function createCodexDynamicToolBridge(params: {
178183
success: false,
179184
};
180185
}
186+
const { tool, name: toolName } = toolEntry;
181187
const args = jsonObjectToRecord(call.arguments);
182188
const startedAt = Date.now();
183189
const signal = composeAbortSignals(params.signal, options?.signal);
@@ -191,7 +197,7 @@ export function createCodexDynamicToolBridge(params: {
191197
threadId: call.threadId,
192198
turnId: call.turnId,
193199
toolCallId: call.callId,
194-
toolName: tool.name,
200+
toolName,
195201
args,
196202
isError: rawIsError,
197203
result: rawResult,
@@ -200,21 +206,21 @@ export function createCodexDynamicToolBridge(params: {
200206
threadId: call.threadId,
201207
turnId: call.turnId,
202208
toolCallId: call.callId,
203-
toolName: tool.name,
209+
toolName,
204210
args,
205211
result: middlewareResult,
206212
});
207213
const resultIsError = rawIsError || isToolResultError(result);
208214
collectToolTelemetry({
209-
toolName: tool.name,
215+
toolName,
210216
args,
211217
result,
212218
mediaTrustResult: rawResult,
213219
telemetry,
214220
isError: resultIsError,
215221
});
216222
void runAgentHarnessAfterToolCallHook({
217-
toolName: tool.name,
223+
toolName,
218224
toolCallId: call.callId,
219225
runId: toolResultHookContext.runId,
220226
agentId: toolResultHookContext.agentId,
@@ -247,14 +253,14 @@ export function createCodexDynamicToolBridge(params: {
247253
return withSideEffectEvidence(response, terminalType !== "blocked");
248254
} catch (error) {
249255
collectToolTelemetry({
250-
toolName: tool.name,
256+
toolName,
251257
args,
252258
result: undefined,
253259
telemetry,
254260
isError: true,
255261
});
256262
void runAgentHarnessAfterToolCallHook({
257-
toolName: tool.name,
263+
toolName,
258264
toolCallId: call.callId,
259265
runId: toolResultHookContext.runId,
260266
agentId: toolResultHookContext.agentId,
@@ -286,17 +292,18 @@ export function createCodexDynamicToolBridge(params: {
286292
}
287293

288294
function createCodexDynamicToolSpec(params: {
289-
tool: AnyAgentTool;
295+
name: string;
296+
description: string;
290297
inputSchema: JsonValue;
291298
loading: CodexDynamicToolsLoading;
292299
directToolNames: ReadonlySet<string>;
293300
}): CodexDynamicToolSpec {
294301
const base = {
295-
name: params.tool.name,
296-
description: params.tool.description,
302+
name: params.name,
303+
description: params.description,
297304
inputSchema: params.inputSchema,
298305
};
299-
if (params.loading === "direct" || params.directToolNames.has(params.tool.name)) {
306+
if (params.loading === "direct" || params.directToolNames.has(params.name)) {
300307
return base;
301308
}
302309
return {
@@ -313,16 +320,92 @@ function projectCodexDynamicTools(tools: readonly AnyAgentTool[]): {
313320
const projectedTools: ProjectedCodexDynamicTool[] = [];
314321
const quarantinedTools: CodexDynamicToolSchemaQuarantine[] = [];
315322
for (const tool of tools) {
316-
const projection = projectRuntimeToolInputSchema(tool.parameters, `${tool.name}.inputSchema`);
323+
const fields = readCodexDynamicToolFields(tool);
324+
if (fields.kind === "quarantined") {
325+
quarantinedTools.push({
326+
tool: fields.name,
327+
violations: fields.violations,
328+
});
329+
continue;
330+
}
331+
const projection = fields.projection;
317332
if (projection.violations.length > 0) {
318-
quarantinedTools.push({ tool: tool.name, violations: projection.violations });
333+
quarantinedTools.push({ tool: fields.name, violations: projection.violations });
319334
continue;
320335
}
321-
projectedTools.push({ tool, inputSchema: projection.schema as JsonValue });
336+
projectedTools.push({
337+
tool,
338+
name: fields.name,
339+
description: fields.description,
340+
inputSchema: projection.schema as JsonValue,
341+
});
322342
}
323343
return { tools: projectedTools, quarantinedTools };
324344
}
325345

346+
type ReadCodexDynamicToolFieldsResult =
347+
| {
348+
kind: "projected";
349+
name: string;
350+
description: string;
351+
projection: ReturnType<typeof projectRuntimeToolInputSchema>;
352+
}
353+
| {
354+
kind: "quarantined";
355+
name: string;
356+
violations: readonly string[];
357+
};
358+
359+
function readCodexDynamicToolFields(tool: AnyAgentTool): ReadCodexDynamicToolFieldsResult {
360+
let name = UNREADABLE_DYNAMIC_TOOL_NAME;
361+
try {
362+
name = tool.name;
363+
} catch (error) {
364+
return {
365+
kind: "quarantined",
366+
name,
367+
violations: [`${name}.name could not be read: ${formatUnknownError(error)}`],
368+
};
369+
}
370+
371+
let description: string;
372+
try {
373+
description = tool.description;
374+
} catch {
375+
description = "";
376+
}
377+
378+
let parameters: AnyAgentTool["parameters"];
379+
try {
380+
parameters = tool.parameters;
381+
} catch (error) {
382+
return {
383+
kind: "quarantined",
384+
name,
385+
violations: [`${name}.inputSchema could not be read: ${formatUnknownError(error)}`],
386+
};
387+
}
388+
389+
try {
390+
return {
391+
kind: "projected",
392+
name,
393+
description,
394+
projection: projectRuntimeToolInputSchema(parameters, `${name}.inputSchema`),
395+
};
396+
} catch (error) {
397+
return {
398+
kind: "quarantined",
399+
name,
400+
violations: [`${name}.inputSchema could not be projected: ${formatUnknownError(error)}`],
401+
};
402+
}
403+
}
404+
405+
function formatUnknownError(error: unknown): string {
406+
return error instanceof Error ? error.message : String(error);
407+
}
408+
326409
function warnQuarantinedDynamicTools(tools: readonly CodexDynamicToolSchemaQuarantine[]): void {
327410
if (tools.length === 0) {
328411
return;

0 commit comments

Comments
 (0)