Skip to content

Commit 443f703

Browse files
committed
fix(plugins): filter unavailable optional tools
1 parent c308d04 commit 443f703

3 files changed

Lines changed: 141 additions & 14 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Docs: https://docs.openclaw.ai
3737

3838
- Channels/WhatsApp: allow `@whiskeysockets/libsignal-node` in `onlyBuiltDependencies` so pnpm v9+ `blockExoticSubdeps` no longer rejects the baileys git-tarball subdep and silences all inbound agent replies. Fixes #76539. Thanks @ottodeng and @vincentkoc.
3939
- Gateway/systemd: preserve operator-added secrets in the Gateway env file across re-stage while clearing OpenClaw-managed keys (such as `OPENCLAW_GATEWAY_TOKEN`) so a fresh staging value is never shadowed by a stale env-file copy; operator secrets are also retained when the state-dir `.env` is empty. Fixes #76860. Thanks @hclsys.
40+
- Plugin tools: keep auth-unavailable optional tools hidden even when another default tool from the same plugin is available and `tools.alsoAllow` names the optional tool. Thanks @vincentkoc.
4041
- Realtime transcription: report socket closes before provider readiness as closed-before-ready failures instead of mislabeling them as connection timeouts for OpenAI, xAI, and Deepgram streaming transcription. Thanks @vincentkoc.
4142
- OpenAI/Google Meet: fail realtime voice connection attempts when the socket closes before `session.updated`, avoiding stuck Meet joins waiting on a bridge that never became ready. Thanks @vincentkoc.
4243
- QA/cache: require the full `CACHE-OK <suffix>` marker before live cache probes stop retrying, so suffix-only prose cannot hide a broken probe response. Thanks @vincentkoc.

src/plugins/tools.optional.test.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1144,6 +1144,74 @@ describe("resolvePluginTools optional tools", () => {
11441144
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
11451145
});
11461146

1147+
it("does not materialize manifest-unavailable optional sibling tools under alsoAllow", () => {
1148+
const config = createContext().config;
1149+
installToolManifestSnapshot({
1150+
config,
1151+
env: {},
1152+
plugin: {
1153+
id: "multi",
1154+
origin: "bundled",
1155+
enabledByDefault: true,
1156+
channels: [],
1157+
providers: [],
1158+
providerAuthEnvVars: {
1159+
xai: ["XAI_API_KEY"],
1160+
},
1161+
contracts: {
1162+
tools: ["other_tool", "optional_tool"],
1163+
},
1164+
toolMetadata: {
1165+
optional_tool: {
1166+
optional: true,
1167+
authSignals: [{ provider: "xai" }],
1168+
},
1169+
},
1170+
},
1171+
});
1172+
const defaultFactory = vi.fn(() => makeTool("other_tool"));
1173+
const optionalFactory = vi.fn(() => makeTool("optional_tool"));
1174+
setActivePluginRegistry(
1175+
createToolRegistry([
1176+
{
1177+
pluginId: "multi",
1178+
optional: false,
1179+
source: "/tmp/multi.js",
1180+
names: ["other_tool"],
1181+
declaredNames: ["other_tool"],
1182+
factory: defaultFactory,
1183+
},
1184+
{
1185+
pluginId: "multi",
1186+
optional: true,
1187+
source: "/tmp/multi.js",
1188+
names: ["optional_tool"],
1189+
declaredNames: ["optional_tool"],
1190+
factory: optionalFactory,
1191+
},
1192+
]) as never,
1193+
"test-tool-registry",
1194+
"gateway-bindable",
1195+
"/tmp",
1196+
);
1197+
1198+
const tools = resolvePluginTools(
1199+
createResolveToolsParams({
1200+
context: {
1201+
...createContext(),
1202+
config,
1203+
},
1204+
env: {},
1205+
toolAllowlist: [DEFAULT_PLUGIN_TOOLS_ALLOWLIST_ENTRY, "optional_tool"],
1206+
}),
1207+
);
1208+
1209+
expectResolvedToolNames(tools, ["other_tool"]);
1210+
expect(defaultFactory).toHaveBeenCalledTimes(1);
1211+
expect(optionalFactory).not.toHaveBeenCalled();
1212+
expect(loadOpenClawPluginsMock).not.toHaveBeenCalled();
1213+
});
1214+
11471215
it("rejects plugin id collisions with core tool names", () => {
11481216
const registry = setRegistry([
11491217
{

src/plugins/tools.ts

Lines changed: 72 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,40 @@ function listManifestToolNamesForAvailability(params: {
349349
return listManifestToolNamesForAllowlist(params);
350350
}
351351

352+
function isManifestToolNameAvailable(params: {
353+
plugin: PluginManifestRecord;
354+
toolName: string;
355+
config: PluginLoadOptions["config"];
356+
env: NodeJS.ProcessEnv;
357+
hasAuthForProvider?: (providerId: string) => boolean;
358+
}): boolean {
359+
return hasManifestToolAvailability({
360+
plugin: params.plugin,
361+
toolNames: [params.toolName],
362+
config: params.config,
363+
env: params.env,
364+
hasAuthForProvider: params.hasAuthForProvider,
365+
});
366+
}
367+
368+
function filterManifestToolNamesForAvailability(params: {
369+
plugin: PluginManifestRecord;
370+
toolNames: readonly string[];
371+
config: PluginLoadOptions["config"];
372+
env: NodeJS.ProcessEnv;
373+
hasAuthForProvider?: (providerId: string) => boolean;
374+
}): string[] {
375+
return params.toolNames.filter((toolName) =>
376+
isManifestToolNameAvailable({
377+
plugin: params.plugin,
378+
toolName,
379+
config: params.config,
380+
env: params.env,
381+
hasAuthForProvider: params.hasAuthForProvider,
382+
}),
383+
);
384+
}
385+
352386
function resolvePluginToolRuntimePluginIds(params: {
353387
config: PluginLoadOptions["config"];
354388
availabilityConfig?: PluginLoadOptions["config"];
@@ -546,21 +580,20 @@ function resolveCachedPluginTools(params: {
546580
continue;
547581
}
548582
const contractToolNames = plugin.contracts?.tools ?? [];
549-
const availableToolNames = listManifestToolNamesForAvailability({
583+
const allowedToolNames = listManifestToolNamesForAvailability({
550584
plugin,
551585
toolNames: contractToolNames,
552586
pluginId: plugin.id,
553587
allowlist: params.allowlist,
554588
});
555-
if (
556-
!hasManifestToolAvailability({
557-
plugin,
558-
toolNames: availableToolNames,
559-
config: params.availabilityConfig,
560-
env: params.env,
561-
hasAuthForProvider: params.hasAuthForProvider,
562-
})
563-
) {
589+
const availableToolNames = filterManifestToolNamesForAvailability({
590+
plugin,
591+
toolNames: allowedToolNames,
592+
config: params.availabilityConfig,
593+
env: params.env,
594+
hasAuthForProvider: params.hasAuthForProvider,
595+
});
596+
if (availableToolNames.length === 0) {
564597
continue;
565598
}
566599
if (params.existingNormalized.has(normalizeToolName(plugin.id))) {
@@ -904,10 +937,25 @@ export function resolvePluginTools(params: {
904937
blockedPlugins.add(entry.pluginId);
905938
continue;
906939
}
940+
const manifestPlugin = manifestPluginsById.get(entry.pluginId);
907941
const declaredNames = entry.names ?? [];
942+
const availabilityNames =
943+
declaredNames.length > 0 ? declaredNames : (entry.declaredNames ?? []);
944+
const allowlistNames = manifestPlugin
945+
? filterManifestToolNamesForAvailability({
946+
plugin: manifestPlugin,
947+
toolNames: availabilityNames,
948+
config: params.context.runtimeConfig ?? context.config,
949+
env,
950+
hasAuthForProvider: params.hasAuthForProvider,
951+
})
952+
: declaredNames;
953+
if (manifestPlugin && availabilityNames.length > 0 && allowlistNames.length === 0) {
954+
continue;
955+
}
908956
if (
909957
!pluginToolNamesMatchAllowlist({
910-
names: declaredNames,
958+
names: allowlistNames,
911959
pluginId: entry.pluginId,
912960
optional: entry.optional,
913961
allowlist,
@@ -936,15 +984,26 @@ export function resolvePluginTools(params: {
936984
continue;
937985
}
938986
const listRaw: unknown[] = Array.isArray(resolved) ? resolved : [resolved];
939-
const list = entry.optional
987+
const availableList = manifestPlugin
940988
? listRaw.filter((tool) =>
989+
isManifestToolNameAvailable({
990+
plugin: manifestPlugin,
991+
toolName: readPluginToolName(tool),
992+
config: params.context.runtimeConfig ?? context.config,
993+
env,
994+
hasAuthForProvider: params.hasAuthForProvider,
995+
}),
996+
)
997+
: listRaw;
998+
const list = entry.optional
999+
? availableList.filter((tool) =>
9411000
isOptionalToolAllowed({
9421001
toolName: readPluginToolName(tool),
9431002
pluginId: entry.pluginId,
9441003
allowlist,
9451004
}),
9461005
)
947-
: listRaw;
1006+
: availableList;
9481007
if (list.length === 0) {
9491008
continue;
9501009
}
@@ -1003,7 +1062,6 @@ export function resolvePluginTools(params: {
10031062
pluginId: entry.pluginId,
10041063
optional: entry.optional,
10051064
});
1006-
const manifestPlugin = manifestPluginsById.get(entry.pluginId);
10071065
if (manifestPlugin) {
10081066
const capturedDescriptors = capturedDescriptorsByPluginId.get(entry.pluginId) ?? [];
10091067
capturedDescriptors.push(

0 commit comments

Comments
 (0)