Skip to content

Commit dce044a

Browse files
committed
fix(codex): quarantine unreadable dynamic tools
1 parent 00d846d commit dce044a

2 files changed

Lines changed: 327 additions & 33 deletions

File tree

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

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,25 @@ function createTool(overrides: Partial<AnyAgentTool>): AnyAgentTool {
3636
} as unknown as AnyAgentTool;
3737
}
3838

39+
function createToolWithUnreadableField(
40+
field: "description" | "parameters",
41+
params: { name: string; execute: ReturnType<typeof vi.fn> },
42+
): AnyAgentTool {
43+
const base = {
44+
name: params.name,
45+
description: "Unreadable descriptor tool.",
46+
parameters: { type: "object", properties: {} },
47+
execute: params.execute,
48+
};
49+
Object.defineProperty(base, field, {
50+
enumerable: true,
51+
get() {
52+
throw new Error(`${field} getter exploded`);
53+
},
54+
});
55+
return base as unknown as AnyAgentTool;
56+
}
57+
3958
function mediaResult(mediaUrl: string, audioAsVoice?: boolean): AgentToolResult<unknown> {
4059
return {
4160
content: [{ type: "text", text: "Generated media reply." }],
@@ -371,6 +390,141 @@ describe("createCodexDynamicToolBridge", () => {
371390
expect(badExecute).not.toHaveBeenCalled();
372391
});
373392

393+
it("quarantines unreadable dynamic tool descriptors before wrapping", async () => {
394+
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
395+
const diagnosticEvents: DiagnosticEventPayload[] = [];
396+
const unsubscribeDiagnostics = onInternalDiagnosticEvent((event) =>
397+
diagnosticEvents.push(event),
398+
);
399+
const unreadableParametersExecute = vi.fn();
400+
const unreadableDescriptionExecute = vi.fn();
401+
let bridge!: ReturnType<typeof createCodexDynamicToolBridge>;
402+
try {
403+
bridge = createCodexDynamicToolBridge({
404+
tools: [
405+
createTool({
406+
name: "message",
407+
execute: vi.fn(async () => textToolResult("healthy reply")),
408+
}),
409+
createToolWithUnreadableField("parameters", {
410+
name: "fuzzplugin_unreadable_parameters",
411+
execute: unreadableParametersExecute,
412+
}),
413+
createToolWithUnreadableField("description", {
414+
name: "fuzzplugin_unreadable_description",
415+
execute: unreadableDescriptionExecute,
416+
}),
417+
],
418+
signal: new AbortController().signal,
419+
hookContext: {
420+
runId: "run-unreadable",
421+
sessionId: "session-unreadable",
422+
sessionKey: "agent:main:session-unreadable",
423+
},
424+
});
425+
await waitForDiagnosticEventsDrained();
426+
} finally {
427+
unsubscribeDiagnostics();
428+
}
429+
430+
expect(bridge.availableSpecs.map((tool) => tool.name)).toEqual(["message"]);
431+
expect(bridge.specs.map((tool) => tool.name)).toEqual(["message"]);
432+
expect(bridge.telemetry.quarantinedTools).toEqual([
433+
{
434+
tool: "fuzzplugin_unreadable_parameters",
435+
violations: ["fuzzplugin_unreadable_parameters.parameters is unreadable"],
436+
},
437+
{
438+
tool: "fuzzplugin_unreadable_description",
439+
violations: ["fuzzplugin_unreadable_description.description is unreadable"],
440+
},
441+
]);
442+
expect(warn).toHaveBeenCalledWith(
443+
expect.stringContaining("fuzzplugin_unreadable_parameters"),
444+
expect.objectContaining({
445+
tools: expect.arrayContaining([
446+
{
447+
tool: "fuzzplugin_unreadable_parameters",
448+
violations: ["fuzzplugin_unreadable_parameters.parameters is unreadable"],
449+
},
450+
{
451+
tool: "fuzzplugin_unreadable_description",
452+
violations: ["fuzzplugin_unreadable_description.description is unreadable"],
453+
},
454+
]),
455+
}),
456+
);
457+
const blockedToolNames = diagnosticEvents
458+
.filter(
459+
(event): event is Extract<DiagnosticEventPayload, { type: "tool.execution.blocked" }> =>
460+
event.type === "tool.execution.blocked",
461+
)
462+
.map((event) => event.toolName)
463+
.toSorted();
464+
expect(blockedToolNames).toEqual([
465+
"fuzzplugin_unreadable_description",
466+
"fuzzplugin_unreadable_parameters",
467+
]);
468+
469+
const result = await bridge.handleToolCall({
470+
threadId: "thread-1",
471+
turnId: "turn-1",
472+
callId: "call-1",
473+
namespace: null,
474+
tool: "message",
475+
arguments: {},
476+
});
477+
478+
expect(result).toEqual(expectInputText("healthy reply"));
479+
expect(unreadableParametersExecute).not.toHaveBeenCalled();
480+
expect(unreadableDescriptionExecute).not.toHaveBeenCalled();
481+
});
482+
483+
it("quarantines unreadable dynamic tool entries while keeping healthy siblings", async () => {
484+
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
485+
const hiddenExecute = vi.fn();
486+
const tools = [
487+
createTool({ name: "message" }),
488+
createTool({
489+
name: "hidden_bad_tool",
490+
execute: hiddenExecute,
491+
}),
492+
createTool({ name: "web_search" }),
493+
];
494+
Object.defineProperty(tools, "1", {
495+
configurable: true,
496+
get() {
497+
throw new Error("tool entry getter exploded");
498+
},
499+
});
500+
501+
const bridge = createCodexDynamicToolBridge({
502+
tools,
503+
signal: new AbortController().signal,
504+
});
505+
506+
expect(bridge.availableSpecs.map((tool) => tool.name)).toEqual(["message", "web_search"]);
507+
expect(bridge.specs.map((tool) => tool.name)).toEqual(["message", "web_search"]);
508+
expect(bridge.telemetry.quarantinedTools).toEqual([
509+
{
510+
tool: "tool[1]",
511+
violations: ["tool[1] is unreadable"],
512+
},
513+
]);
514+
expect(warn).toHaveBeenCalledWith(
515+
expect.stringContaining("tool[1]"),
516+
expect.objectContaining({
517+
tools: [
518+
{
519+
tool: "tool[1]",
520+
violations: ["tool[1] is unreadable"],
521+
},
522+
],
523+
}),
524+
);
525+
expect(hiddenExecute).not.toHaveBeenCalled();
526+
});
527+
374528
it("can expose all dynamic tools directly for compatibility", () => {
375529
const bridge = createCodexDynamicToolBridge({
376530
tools: [createTool({ name: "web_search" }), createTool({ name: "message" })],

0 commit comments

Comments
 (0)