Skip to content

Commit 3cf1dd9

Browse files
committed
fix: gate plugin tools from manifest availability
1 parent 854323a commit 3cf1dd9

14 files changed

Lines changed: 990 additions & 243 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ Docs: https://docs.openclaw.ai
4545
- Discord/components: consume every button or select in a non-reusable component message after the first authorized click, so single-use panels cannot fire sibling callbacks. Fixes #54227. Thanks @fujiwarakasei.
4646
- macOS/config: preserve existing `gateway.auth` and unrelated config keys during app fallback writes, so dashboard or Talk settings changes cannot strand Control UI clients by dropping persisted auth. Fixes #75631. Thanks @Fuma2013.
4747
- Control UI/TUI: keep reconnecting chat sends bound to the same backing session id and let TUI relaunches resume the last selected session, avoiding silent fresh sessions after refresh, reconnect, or terminal restart. Fixes #63195, #68162, and #73546. Thanks @bond260312-cmyk, @zhong18804784882, and @mtuwei.
48+
- Plugins/tools: let plugin manifests declare static tool availability so reply startup skips unavailable plugin tool runtimes instead of importing factories that only return `null`. Thanks @shakkernerd.
4849
- Discord/reactions: skip reaction listener registration when DMs and group DMs are disabled and every configured guild has `reactionNotifications: "off"`, avoiding needless reaction-event queue work. Fixes #47516. Thanks @x4v13r1120.
4950
- CLI sessions: preserve explicit manual-attach reuse bindings so trusted CLI sessions are not invalidated on the first turn when auth, prompt, or MCP fingerprints drift. Fixes #75849. Thanks @alfredjbclaw.
5051
- Telegram/streaming: keep partial preview streaming enabled for plain reply-to replies, disabling drafts only for real native quote excerpts that require Telegram quote parameters. Fixes #73505. Thanks @choury.

docs/plugins/manifest.md

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,7 @@ or npm install metadata. Those belong in your plugin code and `package.json`.
178178
| `imageGenerationProviderMetadata` | No | `Record<string, object>` | Cheap image-generation auth metadata for provider ids declared in `contracts.imageGenerationProviders`, including provider-owned auth aliases and base-url guards. |
179179
| `videoGenerationProviderMetadata` | No | `Record<string, object>` | Cheap video-generation auth metadata for provider ids declared in `contracts.videoGenerationProviders`, including provider-owned auth aliases and base-url guards. |
180180
| `musicGenerationProviderMetadata` | No | `Record<string, object>` | Cheap music-generation auth metadata for provider ids declared in `contracts.musicGenerationProviders`, including provider-owned auth aliases and base-url guards. |
181+
| `toolMetadata` | No | `Record<string, object>` | Cheap availability metadata for plugin-owned tools declared in `contracts.tools`. Use it when a tool should not load runtime unless config, env, or auth evidence exists. |
181182
| `channelConfigs` | No | `Record<string, object>` | Manifest-owned channel config metadata merged into discovery and validation surfaces before runtime loads. |
182183
| `skills` | No | `string[]` | Skill directories to load, relative to the plugin root. |
183184
| `name` | No | `string` | Human-readable plugin name. |
@@ -280,6 +281,45 @@ Each `providerBaseUrl` guard supports:
280281
| `defaultBaseUrl` | No | `string` | Base URL to assume when the provider config omits `baseUrl`. |
281282
| `allowedBaseUrls` | Yes | `string[]` | Allowed base URLs for this auth signal. The signal is ignored when the configured or default base URL does not match one of these normalized values. |
282283

284+
## Tool metadata reference
285+
286+
`toolMetadata` uses the same `configSignals` and `authSignals` shapes as
287+
generation provider metadata, keyed by tool name. `contracts.tools` declares
288+
ownership. `toolMetadata` declares cheap availability evidence so OpenClaw can
289+
avoid importing a plugin runtime just to have its tool factory return `null`.
290+
291+
```json
292+
{
293+
"providerAuthEnvVars": {
294+
"example": ["EXAMPLE_API_KEY"]
295+
},
296+
"contracts": {
297+
"tools": ["example_search"]
298+
},
299+
"toolMetadata": {
300+
"example_search": {
301+
"authSignals": [
302+
{
303+
"provider": "example"
304+
}
305+
],
306+
"configSignals": [
307+
{
308+
"rootPath": "plugins.entries.example.config",
309+
"overlayPath": "search",
310+
"required": ["apiKey"]
311+
}
312+
]
313+
}
314+
}
315+
}
316+
```
317+
318+
If a tool has no `toolMetadata`, OpenClaw preserves the existing behavior and
319+
loads the owning plugin when the tool contract matches policy. For hot-path
320+
tools whose factory depends on auth/config, plugin authors should declare
321+
`toolMetadata` instead of making core import runtime to ask.
322+
283323
## providerAuthChoices reference
284324

285325
Each `providerAuthChoices` entry describes one onboarding or auth choice.

extensions/xai/openclaw.plugin.json

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,44 @@
135135
}
136136
}
137137
},
138+
"toolMetadata": {
139+
"code_execution": {
140+
"authSignals": [
141+
{
142+
"provider": "xai"
143+
}
144+
],
145+
"configSignals": [
146+
{
147+
"rootPath": "plugins.entries.xai.config",
148+
"overlayPath": "webSearch",
149+
"required": ["apiKey"]
150+
},
151+
{
152+
"rootPath": "tools.web.search.grok",
153+
"required": ["apiKey"]
154+
}
155+
]
156+
},
157+
"x_search": {
158+
"authSignals": [
159+
{
160+
"provider": "xai"
161+
}
162+
],
163+
"configSignals": [
164+
{
165+
"rootPath": "plugins.entries.xai.config",
166+
"overlayPath": "webSearch",
167+
"required": ["apiKey"]
168+
},
169+
{
170+
"rootPath": "tools.web.search.grok",
171+
"required": ["apiKey"]
172+
}
173+
]
174+
}
175+
},
138176
"configContracts": {
139177
"compatibilityRuntimePaths": ["tools.web.search.apiKey"]
140178
},

src/agents/openclaw-plugin-tools.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import type { OpenClawConfig } from "../config/types.openclaw.js";
33
import { resolvePluginTools } from "../plugins/tools.js";
44
import { getActiveSecretsRuntimeSnapshot } from "../secrets/runtime.js";
55
import { normalizeDeliveryContext } from "../utils/delivery-context.js";
6+
import { listProfilesForProvider } from "./auth-profiles.js";
7+
import type { AuthProfileStore } from "./auth-profiles/types.js";
68
import {
79
resolveOpenClawPluginToolInputs,
810
type OpenClawPluginToolOptions,
@@ -23,6 +25,7 @@ type ResolveOpenClawPluginToolsOptions = OpenClawPluginToolOptions & {
2325
requireExplicitMessageTarget?: boolean;
2426
disableMessageTool?: boolean;
2527
disablePluginTools?: boolean;
28+
authProfileStore?: AuthProfileStore;
2629
};
2730

2831
export function resolveOpenClawPluginToolsForOptions(params: {
@@ -49,6 +52,7 @@ export function resolveOpenClawPluginToolsForOptions(params: {
4952
runtimeSourceConfig: currentRuntimeSnapshot?.sourceConfig,
5053
});
5154
};
55+
const authProfileStore = params.options?.authProfileStore;
5256
const pluginTools = resolvePluginTools({
5357
...resolveOpenClawPluginToolInputs({
5458
options: params.options,
@@ -59,6 +63,12 @@ export function resolveOpenClawPluginToolsForOptions(params: {
5963
existingToolNames: params.existingToolNames ?? new Set<string>(),
6064
toolAllowlist: params.options?.pluginToolAllowlist,
6165
allowGatewaySubagentBinding: params.options?.allowGatewaySubagentBinding,
66+
...(authProfileStore
67+
? {
68+
hasAuthForProvider: (providerId) =>
69+
listProfilesForProvider(authProfileStore, providerId).length > 0,
70+
}
71+
: {}),
6272
});
6373

6474
return applyPluginToolDeliveryDefaults({

src/agents/openclaw-tools.media-factory-plan.test.ts

Lines changed: 98 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -565,6 +565,93 @@ describe("optional media tool factory planning", () => {
565565
).not.toEqual(expect.arrayContaining(["image_generate", "video_generate", "music_generate"]));
566566
});
567567

568+
it("counts configured non-env SecretRef config signals without resolving secrets", () => {
569+
const config: OpenClawConfig = {
570+
plugins: {
571+
entries: {
572+
comfy: {
573+
config: {
574+
mode: "cloud",
575+
apiKey: { source: "file", provider: "vault", id: "/comfy/api-key" },
576+
workflow: { "1": { inputs: {} } },
577+
promptNodeId: "1",
578+
},
579+
},
580+
},
581+
},
582+
secrets: {
583+
providers: {
584+
vault: {
585+
source: "file",
586+
path: "/tmp/openclaw-secrets.json",
587+
mode: "json",
588+
},
589+
},
590+
},
591+
};
592+
const configSignals = [
593+
{
594+
rootPath: "plugins.entries.comfy.config",
595+
mode: {
596+
path: "mode",
597+
allowed: ["cloud"],
598+
},
599+
requiredAny: ["workflow", "workflowPath"],
600+
required: ["promptNodeId", "apiKey"],
601+
},
602+
];
603+
installSnapshot(config, [
604+
createPlugin({
605+
id: "comfy",
606+
contracts: {
607+
imageGenerationProviders: ["comfy"],
608+
videoGenerationProviders: ["comfy"],
609+
musicGenerationProviders: ["comfy"],
610+
},
611+
imageGenerationProviderMetadata: {
612+
comfy: { configSignals },
613+
},
614+
videoGenerationProviderMetadata: {
615+
comfy: { configSignals },
616+
},
617+
musicGenerationProviderMetadata: {
618+
comfy: { configSignals },
619+
},
620+
}),
621+
]);
622+
623+
expect(
624+
__testing.resolveOptionalMediaToolFactoryPlan({
625+
config,
626+
authStore: createAuthStore(),
627+
}),
628+
).toMatchObject({
629+
imageGenerate: true,
630+
videoGenerate: true,
631+
musicGenerate: true,
632+
});
633+
});
634+
635+
it("does not register the image tool without cheap vision availability evidence", () => {
636+
const config: OpenClawConfig = {};
637+
installSnapshot(config, [
638+
createPlugin({
639+
id: "media-owner",
640+
contracts: { mediaUnderstandingProviders: ["media-owner"] },
641+
setupProviders: [{ id: "media-owner", envVars: ["MEDIA_OWNER_API_KEY"] }],
642+
}),
643+
]);
644+
645+
expect(
646+
createOpenClawTools({
647+
config,
648+
agentDir: "/tmp/openclaw-agent",
649+
authProfileStore: createAuthStore(),
650+
disablePluginTools: true,
651+
}).map((tool) => tool.name),
652+
).not.toContain("image");
653+
});
654+
568655
it.each([
569656
{
570657
name: "legacy local provider config",
@@ -687,22 +774,20 @@ describe("optional media tool factory planning", () => {
687774
});
688775
});
689776

690-
it("falls back to existing factory checks when snapshot or auth store proof is missing", () => {
691-
expect(__testing.resolveOptionalMediaToolFactoryPlan({ config: {} })).toEqual({
692-
imageGenerate: true,
693-
videoGenerate: true,
694-
musicGenerate: true,
695-
pdf: true,
696-
});
697-
777+
it("does not use a generic factory plan when metadata has no availability proof", () => {
698778
const config: OpenClawConfig = {};
699779
installSnapshot(config, []);
700780

701-
expect(__testing.resolveOptionalMediaToolFactoryPlan({ config })).toEqual({
702-
imageGenerate: true,
703-
videoGenerate: true,
704-
musicGenerate: true,
705-
pdf: true,
781+
expect(
782+
__testing.resolveOptionalMediaToolFactoryPlan({
783+
config,
784+
authStore: createAuthStore(),
785+
}),
786+
).toEqual({
787+
imageGenerate: false,
788+
videoGenerate: false,
789+
musicGenerate: false,
790+
pdf: false,
706791
});
707792
});
708793
});

0 commit comments

Comments
 (0)