Skip to content

Commit 179ab36

Browse files
committed
fix(agents): quarantine unreadable extension tools
1 parent a02a7aa commit 179ab36

3 files changed

Lines changed: 191 additions & 8 deletions

File tree

src/agents/sessions/extensions/loader.test.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { tmpdir } from "node:os";
33
import { join } from "node:path";
44
import { afterEach, describe, expect, it } from "vitest";
55
import { loadExtensions } from "./loader.js";
6+
import type { ExtensionContext } from "./types.js";
67

78
const tempDirs: string[] = [];
89

@@ -43,4 +44,81 @@ export default async function(api) {
4344
expect(result.extensions).toHaveLength(1);
4445
expect(result.extensions[0]?.commands.has("sdk-subpath-probe")).toBe(true);
4546
});
47+
48+
it("skips unreadable registered tools without failing the extension", async () => {
49+
const dir = await mkdtemp(join(tmpdir(), "openclaw-extension-bad-tool-"));
50+
tempDirs.push(dir);
51+
const extensionPath = join(dir, "extension.ts");
52+
await writeFile(
53+
extensionPath,
54+
`
55+
export default async function(api) {
56+
const unreadableNameTool = {
57+
get name() {
58+
throw new Error("boom name");
59+
},
60+
label: "Bad Name",
61+
description: "Breaks while reading the name.",
62+
parameters: { type: "object", properties: {} },
63+
async execute() {
64+
return { content: [{ type: "text", text: "bad" }], details: {} };
65+
},
66+
};
67+
const unreadableParametersTool = {
68+
name: "bad_parameters",
69+
label: "Bad Parameters",
70+
description: "Breaks while reading the schema.",
71+
get parameters() {
72+
throw new Error("boom params");
73+
},
74+
async execute() {
75+
return { content: [{ type: "text", text: "bad" }], details: {} };
76+
},
77+
};
78+
const healthyTool = {
79+
name: "healthy_lookup",
80+
label: "Healthy Lookup",
81+
description: "Uses state from the original tool object.",
82+
parameters: { type: "object", properties: {} },
83+
calls: 0,
84+
async execute() {
85+
this.calls += 1;
86+
return { content: [{ type: "text", text: String(this.calls) }], details: {} };
87+
},
88+
};
89+
api.registerTool(unreadableNameTool);
90+
api.registerTool(unreadableParametersTool);
91+
api.registerTool(healthyTool);
92+
api.registerCommand("after-bad-tool", {
93+
description: "probe",
94+
handler() {},
95+
});
96+
}
97+
`,
98+
);
99+
100+
const result = await loadExtensions([extensionPath], dir);
101+
102+
expect(result.errors).toEqual([
103+
{ path: extensionPath, error: "skipped extension tool registration: boom name" },
104+
{
105+
path: extensionPath,
106+
error: 'skipped extension tool registration for "bad_parameters": boom params',
107+
},
108+
]);
109+
expect(result.extensions).toHaveLength(1);
110+
expect(Array.from(result.extensions[0]?.tools.keys() ?? [])).toEqual(["healthy_lookup"]);
111+
expect(result.extensions[0]?.commands.has("after-bad-tool")).toBe(true);
112+
113+
const definition = result.extensions[0]?.tools.get("healthy_lookup")?.definition;
114+
const toolResult = await definition?.execute(
115+
"call-1",
116+
{},
117+
undefined,
118+
undefined,
119+
undefined as unknown as ExtensionContext,
120+
);
121+
122+
expect(toolResult?.content).toEqual([{ type: "text", text: "1" }]);
123+
});
46124
});

src/agents/sessions/extensions/loader.ts

Lines changed: 105 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ function createExtensionAPI(
214214
runtime: ExtensionRuntime,
215215
cwd: string,
216216
eventBus: EventBus,
217+
onRegistrationError?: (error: string) => void,
217218
): ExtensionAPI {
218219
const api = {
219220
// Registration methods - write to extension
@@ -226,8 +227,13 @@ function createExtensionAPI(
226227

227228
registerTool(tool: ToolDefinition): void {
228229
runtime.assertActive();
229-
extension.tools.set(tool.name, {
230-
definition: tool,
230+
const snapshot = snapshotExtensionToolDefinition(tool);
231+
if ("error" in snapshot) {
232+
onRegistrationError?.(snapshot.error);
233+
return;
234+
}
235+
extension.tools.set(snapshot.definition.name, {
236+
definition: snapshot.definition,
231237
sourceInfo: extension.sourceInfo,
232238
});
233239
runtime.refreshTools();
@@ -365,6 +371,75 @@ function createExtensionAPI(
365371
return api;
366372
}
367373

374+
function snapshotExtensionToolDefinition(
375+
definition: ToolDefinition,
376+
): { definition: ToolDefinition } | { error: string } {
377+
let toolName: string | undefined;
378+
try {
379+
if (!definition || typeof definition !== "object") {
380+
return { error: "skipped extension tool registration: tool definition is not an object" };
381+
}
382+
const name = Reflect.get(definition, "name");
383+
toolName = typeof name === "string" ? name : undefined;
384+
const execute = Reflect.get(definition, "execute");
385+
if (!toolName) {
386+
return { error: "skipped extension tool registration: tool name is missing or invalid" };
387+
}
388+
if (typeof execute !== "function") {
389+
return {
390+
error: `skipped extension tool registration for "${toolName}": execute is missing or invalid`,
391+
};
392+
}
393+
const promptGuidelines = Reflect.get(definition, "promptGuidelines");
394+
const prepareArguments = Reflect.get(definition, "prepareArguments");
395+
const renderCall = Reflect.get(definition, "renderCall");
396+
const renderResult = Reflect.get(definition, "renderResult");
397+
const executeWithReceiver = ((...args: Parameters<ToolDefinition["execute"]>) =>
398+
Reflect.apply(execute, definition, args)) as ToolDefinition["execute"];
399+
return {
400+
definition: {
401+
name: toolName,
402+
label: Reflect.get(definition, "label"),
403+
description: Reflect.get(definition, "description"),
404+
promptSnippet: Reflect.get(definition, "promptSnippet"),
405+
promptGuidelines: Array.isArray(promptGuidelines)
406+
? [...promptGuidelines]
407+
: promptGuidelines,
408+
parameters: Reflect.get(definition, "parameters"),
409+
renderShell: Reflect.get(definition, "renderShell"),
410+
prepareArguments:
411+
typeof prepareArguments === "function"
412+
? (((...args: Parameters<NonNullable<ToolDefinition["prepareArguments"]>>) =>
413+
Reflect.apply(
414+
prepareArguments,
415+
definition,
416+
args,
417+
)) as ToolDefinition["prepareArguments"])
418+
: prepareArguments,
419+
executionMode: Reflect.get(definition, "executionMode"),
420+
execute: executeWithReceiver,
421+
renderCall:
422+
typeof renderCall === "function"
423+
? (((...args: Parameters<NonNullable<ToolDefinition["renderCall"]>>) =>
424+
Reflect.apply(renderCall, definition, args)) as ToolDefinition["renderCall"])
425+
: renderCall,
426+
renderResult:
427+
typeof renderResult === "function"
428+
? (((...args: Parameters<NonNullable<ToolDefinition["renderResult"]>>) =>
429+
Reflect.apply(renderResult, definition, args)) as ToolDefinition["renderResult"])
430+
: renderResult,
431+
},
432+
};
433+
} catch (error) {
434+
const message = error instanceof Error ? error.message : String(error);
435+
return {
436+
error: toolName
437+
? `skipped extension tool registration for "${toolName}": ${message}`
438+
: `skipped extension tool registration: ${message}`,
439+
};
440+
}
441+
}
442+
368443
function resolveExtensionFactory(module: unknown): ExtensionFactory | undefined {
369444
const candidate =
370445
typeof module === "object" && module !== null && "default" in module
@@ -487,26 +562,34 @@ async function loadExtension(
487562
cwd: string,
488563
eventBus: EventBus,
489564
runtime: ExtensionRuntime,
490-
): Promise<{ extension: Extension | null; error: string | null }> {
565+
): Promise<{ extension: Extension | null; error: string | null; registrationErrors: string[] }> {
491566
const resolvedPath = resolvePath(extensionPath, cwd);
567+
const registrationErrors: string[] = [];
492568

493569
try {
494570
const factory = await loadExtensionModule(resolvedPath);
495571
if (!factory) {
496572
return {
497573
extension: null,
498574
error: `Extension does not export a valid factory function: ${extensionPath}`,
575+
registrationErrors,
499576
};
500577
}
501578

502579
const extension = createExtension(extensionPath, resolvedPath);
503-
const api = createExtensionAPI(extension, runtime, cwd, eventBus);
580+
const api = createExtensionAPI(extension, runtime, cwd, eventBus, (error) => {
581+
registrationErrors.push(error);
582+
});
504583
await factory(api);
505584

506-
return { extension, error: null };
585+
return { extension, error: null, registrationErrors };
507586
} catch (err) {
508587
const message = err instanceof Error ? err.message : String(err);
509-
return { extension: null, error: `Failed to load extension: ${message}` };
588+
return {
589+
extension: null,
590+
error: `Failed to load extension: ${message}`,
591+
registrationErrors,
592+
};
510593
}
511594
}
512595

@@ -519,9 +602,12 @@ export async function loadExtensionFromFactory(
519602
eventBus: EventBus,
520603
runtime: ExtensionRuntime,
521604
extensionPath = "<inline>",
605+
registrationErrors?: string[],
522606
): Promise<Extension> {
523607
const extension = createExtension(extensionPath, extensionPath);
524-
const api = createExtensionAPI(extension, runtime, cwd, eventBus);
608+
const api = createExtensionAPI(extension, runtime, cwd, eventBus, (error) => {
609+
registrationErrors?.push(error);
610+
});
525611
await factory(api);
526612
return extension;
527613
}
@@ -540,16 +626,27 @@ export async function loadExtensions(
540626
const runtime = createExtensionRuntime();
541627

542628
for (const extPath of paths) {
543-
const { extension, error } = await loadExtension(extPath, cwd, resolvedEventBus, runtime);
629+
const { extension, error, registrationErrors } = await loadExtension(
630+
extPath,
631+
cwd,
632+
resolvedEventBus,
633+
runtime,
634+
);
544635

545636
if (error) {
546637
errors.push({ path: extPath, error });
638+
for (const registrationError of registrationErrors) {
639+
errors.push({ path: extPath, error: registrationError });
640+
}
547641
continue;
548642
}
549643

550644
if (extension) {
551645
extensions.push(extension);
552646
}
647+
for (const registrationError of registrationErrors) {
648+
errors.push({ path: extPath, error: registrationError });
649+
}
553650
}
554651

555652
return {

src/agents/sessions/resource-loader.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -880,17 +880,25 @@ export class DefaultResourceLoader implements ResourceLoader {
880880

881881
for (const [index, factory] of this.extensionFactories.entries()) {
882882
const extensionPath = `<inline:${index + 1}>`;
883+
const registrationErrors: string[] = [];
883884
try {
884885
const extension = await loadExtensionFromFactory(
885886
factory,
886887
this.cwd,
887888
this.eventBus,
888889
runtime,
889890
extensionPath,
891+
registrationErrors,
890892
);
891893
extensions.push(extension);
894+
for (const registrationError of registrationErrors) {
895+
errors.push({ path: extensionPath, error: registrationError });
896+
}
892897
} catch (error) {
893898
const message = error instanceof Error ? error.message : "failed to load extension";
899+
for (const registrationError of registrationErrors) {
900+
errors.push({ path: extensionPath, error: registrationError });
901+
}
894902
errors.push({ path: extensionPath, error: message });
895903
}
896904
}

0 commit comments

Comments
 (0)