diff --git a/docs/plugins/building-plugins.md b/docs/plugins/building-plugins.md index b3b7bd197d5..0b43a001ec6 100644 --- a/docs/plugins/building-plugins.md +++ b/docs/plugins/building-plugins.md @@ -78,6 +78,9 @@ and provider plugins have dedicated guides linked above. "id": "my-plugin", "name": "My Plugin", "description": "Adds a custom tool to OpenClaw", + "contracts": { + "tools": ["my_tool"] + }, "activation": { "onStartup": true }, @@ -89,9 +92,10 @@ and provider plugins have dedicated guides linked above. ``` - Every plugin needs a manifest, even with no config, and every plugin should - declare `activation.onStartup` intentionally. Runtime-registered tools need - startup import, so this example sets it to `true`. See + Every plugin needs a manifest, even with no config. Runtime-registered tools + must be listed in `contracts.tools` so OpenClaw can discover the owning + plugin without loading every plugin runtime. Plugins should also declare + `activation.onStartup` intentionally. This example sets it to `true`. See [Manifest](/plugins/manifest) for the full schema. The canonical ClawHub publish snippets live in `docs/snippets/plugin-publish/`. @@ -242,6 +246,17 @@ register(api) { } ``` +Every tool registered with `api.registerTool(...)` must also be declared in the +plugin manifest: + +```json +{ + "contracts": { + "tools": ["my_tool", "workflow_tool"] + } +} +``` + Users enable optional tools in config: ```json5 diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 8718e0cbf5b..28a00e64349 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -145,45 +145,45 @@ or npm install metadata. Those belong in your plugin code and `package.json`. ## Top-level field reference -| Field | Required | Type | What it means | -| ------------------------------------ | -------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | Yes | `string` | Canonical plugin id. This is the id used in `plugins.entries.`. | -| `configSchema` | Yes | `object` | Inline JSON Schema for this plugin's config. | -| `enabledByDefault` | No | `true` | Marks a bundled plugin as enabled by default. Omit it, or set any non-`true` value, to leave the plugin disabled by default. | -| `legacyPluginIds` | No | `string[]` | Legacy ids that normalize to this canonical plugin id. | -| `autoEnableWhenConfiguredProviders` | No | `string[]` | Provider ids that should auto-enable this plugin when auth, config, or model refs mention them. | -| `kind` | No | `"memory"` \| `"context-engine"` | Declares an exclusive plugin kind used by `plugins.slots.*`. | -| `channels` | No | `string[]` | Channel ids owned by this plugin. Used for discovery and config validation. | -| `providers` | No | `string[]` | Provider ids owned by this plugin. | -| `providerDiscoveryEntry` | No | `string` | Lightweight provider-discovery module path, relative to the plugin root, for manifest-scoped provider catalog metadata that can be loaded without activating the full plugin runtime. | -| `modelSupport` | No | `object` | Manifest-owned shorthand model-family metadata used to auto-load the plugin before runtime. | -| `modelCatalog` | No | `object` | Declarative model catalog metadata for providers owned by this plugin. This is the control-plane contract for future read-only listing, onboarding, model pickers, aliases, and suppression without loading plugin runtime. | -| `modelPricing` | No | `object` | Provider-owned external pricing lookup policy. Use it to opt local/self-hosted providers out of remote pricing catalogs or map provider refs to OpenRouter/LiteLLM catalog ids without hardcoding provider ids in core. | -| `modelIdNormalization` | No | `object` | Provider-owned model-id alias/prefix cleanup that must run before provider runtime loads. | -| `providerEndpoints` | No | `object[]` | Manifest-owned endpoint host/baseUrl metadata for provider routes that core must classify before provider runtime loads. | -| `providerRequest` | No | `object` | Cheap provider-family and request-compatibility metadata used by generic request policy before provider runtime loads. | -| `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. | -| `syntheticAuthRefs` | No | `string[]` | Provider or CLI backend refs whose plugin-owned synthetic auth hook should be probed during cold model discovery before runtime loads. | -| `nonSecretAuthMarkers` | No | `string[]` | Bundled-plugin-owned placeholder API key values that represent non-secret local, OAuth, or ambient credential state. | -| `commandAliases` | No | `object[]` | Command names owned by this plugin that should produce plugin-aware config and CLI diagnostics before runtime loads. | -| `providerAuthEnvVars` | No | `Record` | Deprecated compatibility env metadata for provider auth/status lookup. Prefer `setup.providers[].envVars` for new plugins; OpenClaw still reads this during the deprecation window. | -| `providerAuthAliases` | No | `Record` | Provider ids that should reuse another provider id for auth lookup, for example a coding provider that shares the base provider API key and auth profiles. | -| `channelEnvVars` | No | `Record` | Cheap channel env metadata that OpenClaw can inspect without loading plugin code. Use this for env-driven channel setup or auth surfaces that generic startup/config helpers should see. | -| `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. | -| `activation` | No | `object` | Cheap activation planner metadata for startup, provider, command, channel, route, and capability-triggered loading. Metadata only; plugin runtime still owns actual behavior. | -| `setup` | No | `object` | Cheap setup/onboarding descriptors that discovery and setup surfaces can inspect without loading plugin runtime. | -| `qaRunners` | No | `object[]` | Cheap QA runner descriptors used by the shared `openclaw qa` host before plugin runtime loads. | -| `contracts` | No | `object` | Static bundled capability snapshot for external auth hooks, speech, realtime transcription, realtime voice, media-understanding, image-generation, music-generation, video-generation, web-fetch, web search, and tool ownership. | -| `mediaUnderstandingProviderMetadata` | No | `Record` | Cheap media-understanding defaults for provider ids declared in `contracts.mediaUnderstandingProviders`. | -| `imageGenerationProviderMetadata` | No | `Record` | Cheap image-generation auth metadata for provider ids declared in `contracts.imageGenerationProviders`, including provider-owned auth aliases and base-url guards. | -| `videoGenerationProviderMetadata` | No | `Record` | Cheap video-generation auth metadata for provider ids declared in `contracts.videoGenerationProviders`, including provider-owned auth aliases and base-url guards. | -| `musicGenerationProviderMetadata` | No | `Record` | Cheap music-generation auth metadata for provider ids declared in `contracts.musicGenerationProviders`, including provider-owned auth aliases and base-url guards. | -| `channelConfigs` | No | `Record` | Manifest-owned channel config metadata merged into discovery and validation surfaces before runtime loads. | -| `skills` | No | `string[]` | Skill directories to load, relative to the plugin root. | -| `name` | No | `string` | Human-readable plugin name. | -| `description` | No | `string` | Short summary shown in plugin surfaces. | -| `version` | No | `string` | Informational plugin version. | -| `uiHints` | No | `Record` | UI labels, placeholders, and sensitivity hints for config fields. | +| Field | Required | Type | What it means | +| ------------------------------------ | -------- | -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | Yes | `string` | Canonical plugin id. This is the id used in `plugins.entries.`. | +| `configSchema` | Yes | `object` | Inline JSON Schema for this plugin's config. | +| `enabledByDefault` | No | `true` | Marks a bundled plugin as enabled by default. Omit it, or set any non-`true` value, to leave the plugin disabled by default. | +| `legacyPluginIds` | No | `string[]` | Legacy ids that normalize to this canonical plugin id. | +| `autoEnableWhenConfiguredProviders` | No | `string[]` | Provider ids that should auto-enable this plugin when auth, config, or model refs mention them. | +| `kind` | No | `"memory"` \| `"context-engine"` | Declares an exclusive plugin kind used by `plugins.slots.*`. | +| `channels` | No | `string[]` | Channel ids owned by this plugin. Used for discovery and config validation. | +| `providers` | No | `string[]` | Provider ids owned by this plugin. | +| `providerDiscoveryEntry` | No | `string` | Lightweight provider-discovery module path, relative to the plugin root, for manifest-scoped provider catalog metadata that can be loaded without activating the full plugin runtime. | +| `modelSupport` | No | `object` | Manifest-owned shorthand model-family metadata used to auto-load the plugin before runtime. | +| `modelCatalog` | No | `object` | Declarative model catalog metadata for providers owned by this plugin. This is the control-plane contract for future read-only listing, onboarding, model pickers, aliases, and suppression without loading plugin runtime. | +| `modelPricing` | No | `object` | Provider-owned external pricing lookup policy. Use it to opt local/self-hosted providers out of remote pricing catalogs or map provider refs to OpenRouter/LiteLLM catalog ids without hardcoding provider ids in core. | +| `modelIdNormalization` | No | `object` | Provider-owned model-id alias/prefix cleanup that must run before provider runtime loads. | +| `providerEndpoints` | No | `object[]` | Manifest-owned endpoint host/baseUrl metadata for provider routes that core must classify before provider runtime loads. | +| `providerRequest` | No | `object` | Cheap provider-family and request-compatibility metadata used by generic request policy before provider runtime loads. | +| `cliBackends` | No | `string[]` | CLI inference backend ids owned by this plugin. Used for startup auto-activation from explicit config refs. | +| `syntheticAuthRefs` | No | `string[]` | Provider or CLI backend refs whose plugin-owned synthetic auth hook should be probed during cold model discovery before runtime loads. | +| `nonSecretAuthMarkers` | No | `string[]` | Bundled-plugin-owned placeholder API key values that represent non-secret local, OAuth, or ambient credential state. | +| `commandAliases` | No | `object[]` | Command names owned by this plugin that should produce plugin-aware config and CLI diagnostics before runtime loads. | +| `providerAuthEnvVars` | No | `Record` | Deprecated compatibility env metadata for provider auth/status lookup. Prefer `setup.providers[].envVars` for new plugins; OpenClaw still reads this during the deprecation window. | +| `providerAuthAliases` | No | `Record` | Provider ids that should reuse another provider id for auth lookup, for example a coding provider that shares the base provider API key and auth profiles. | +| `channelEnvVars` | No | `Record` | Cheap channel env metadata that OpenClaw can inspect without loading plugin code. Use this for env-driven channel setup or auth surfaces that generic startup/config helpers should see. | +| `providerAuthChoices` | No | `object[]` | Cheap auth-choice metadata for onboarding pickers, preferred-provider resolution, and simple CLI flag wiring. | +| `activation` | No | `object` | Cheap activation planner metadata for startup, provider, command, channel, route, and capability-triggered loading. Metadata only; plugin runtime still owns actual behavior. | +| `setup` | No | `object` | Cheap setup/onboarding descriptors that discovery and setup surfaces can inspect without loading plugin runtime. | +| `qaRunners` | No | `object[]` | Cheap QA runner descriptors used by the shared `openclaw qa` host before plugin runtime loads. | +| `contracts` | No | `object` | Static capability ownership snapshot for external auth hooks, speech, realtime transcription, realtime voice, media-understanding, image-generation, music-generation, video-generation, web-fetch, web search, and tool ownership. | +| `mediaUnderstandingProviderMetadata` | No | `Record` | Cheap media-understanding defaults for provider ids declared in `contracts.mediaUnderstandingProviders`. | +| `imageGenerationProviderMetadata` | No | `Record` | Cheap image-generation auth metadata for provider ids declared in `contracts.imageGenerationProviders`, including provider-owned auth aliases and base-url guards. | +| `videoGenerationProviderMetadata` | No | `Record` | Cheap video-generation auth metadata for provider ids declared in `contracts.videoGenerationProviders`, including provider-owned auth aliases and base-url guards. | +| `musicGenerationProviderMetadata` | No | `Record` | Cheap music-generation auth metadata for provider ids declared in `contracts.musicGenerationProviders`, including provider-owned auth aliases and base-url guards. | +| `channelConfigs` | No | `Record` | Manifest-owned channel config metadata merged into discovery and validation surfaces before runtime loads. | +| `skills` | No | `string[]` | Skill directories to load, relative to the plugin root. | +| `name` | No | `string` | Human-readable plugin name. | +| `description` | No | `string` | Short summary shown in plugin surfaces. | +| `version` | No | `string` | Informational plugin version. | +| `uiHints` | No | `Record` | UI labels, placeholders, and sensitivity hints for config fields. | ## Generation provider metadata reference @@ -609,7 +609,7 @@ Each list is optional: | `webFetchProviders` | `string[]` | Web-fetch provider ids this plugin owns. | | `webSearchProviders` | `string[]` | Web-search provider ids this plugin owns. | | `migrationProviders` | `string[]` | Import provider ids this plugin owns for `openclaw migrate`. | -| `tools` | `string[]` | Agent tool names this plugin owns for bundled contract checks. | +| `tools` | `string[]` | Agent tool names this plugin owns. | `contracts.embeddedExtensionFactories` is retained for bundled Codex app-server-only extension factories. Bundled tool-result transforms should @@ -618,6 +618,10 @@ declare `contracts.agentToolResultMiddleware` and register with register tool-result middleware because the seam can rewrite high-trust tool output before the model sees it. +Runtime `api.registerTool(...)` registrations must match `contracts.tools`. +Tool discovery uses this list to load only the plugin runtimes that can own the +requested tools. + Provider plugins that implement `resolveExternalAuthProfiles` should declare `contracts.externalAuthProviders`. Plugins without the declaration still run through a deprecated compatibility fallback, but that fallback is slower and diff --git a/extensions/browser/openclaw.plugin.json b/extensions/browser/openclaw.plugin.json index 49d53e3a7cf..4a68181ff74 100644 --- a/extensions/browser/openclaw.plugin.json +++ b/extensions/browser/openclaw.plugin.json @@ -5,6 +5,9 @@ "onStartup": true, "onConfigPaths": ["browser"] }, + "contracts": { + "tools": ["browser"] + }, "commandAliases": [{ "name": "browser" }], "skills": ["./skills"], "configSchema": { diff --git a/extensions/diffs/openclaw.plugin.json b/extensions/diffs/openclaw.plugin.json index 24a6eb5972d..5ead0d91509 100644 --- a/extensions/diffs/openclaw.plugin.json +++ b/extensions/diffs/openclaw.plugin.json @@ -5,6 +5,9 @@ }, "name": "Diffs", "description": "Read-only diff viewer and file renderer for agents.", + "contracts": { + "tools": ["diffs"] + }, "skills": ["./skills"], "uiHints": { "viewerBaseUrl": { diff --git a/extensions/feishu/openclaw.plugin.json b/extensions/feishu/openclaw.plugin.json index 0ae097cbb3c..cfe2ed3bb06 100644 --- a/extensions/feishu/openclaw.plugin.json +++ b/extensions/feishu/openclaw.plugin.json @@ -4,6 +4,24 @@ "onStartup": false }, "channels": ["feishu"], + "contracts": { + "tools": [ + "feishu_app_scopes", + "feishu_bitable_create_app", + "feishu_bitable_create_field", + "feishu_bitable_create_record", + "feishu_bitable_get_meta", + "feishu_bitable_get_record", + "feishu_bitable_list_fields", + "feishu_bitable_list_records", + "feishu_bitable_update_record", + "feishu_chat", + "feishu_doc", + "feishu_drive", + "feishu_perm", + "feishu_wiki" + ] + }, "channelEnvVars": { "feishu": [ "FEISHU_APP_ID", diff --git a/extensions/google-meet/openclaw.plugin.json b/extensions/google-meet/openclaw.plugin.json index 67fcf9a1a58..755ca2b8d4f 100644 --- a/extensions/google-meet/openclaw.plugin.json +++ b/extensions/google-meet/openclaw.plugin.json @@ -9,6 +9,9 @@ "onCommands": ["googlemeet"], "onCapabilities": ["tool"] }, + "contracts": { + "tools": ["google_meet"] + }, "uiHints": { "defaults.meeting": { "label": "Default Meeting", diff --git a/extensions/llm-task/openclaw.plugin.json b/extensions/llm-task/openclaw.plugin.json index 6f5b4c33446..d9418559b4a 100644 --- a/extensions/llm-task/openclaw.plugin.json +++ b/extensions/llm-task/openclaw.plugin.json @@ -5,6 +5,9 @@ }, "name": "LLM Task", "description": "Generic JSON-only LLM tool for structured tasks callable from workflows.", + "contracts": { + "tools": ["llm-task"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/lobster/openclaw.plugin.json b/extensions/lobster/openclaw.plugin.json index f687f880d25..4f53ac619e3 100644 --- a/extensions/lobster/openclaw.plugin.json +++ b/extensions/lobster/openclaw.plugin.json @@ -5,6 +5,9 @@ }, "name": "Lobster", "description": "Typed workflow tool with resumable approvals.", + "contracts": { + "tools": ["lobster"] + }, "configSchema": { "type": "object", "additionalProperties": false, diff --git a/extensions/memory-core/openclaw.plugin.json b/extensions/memory-core/openclaw.plugin.json index c7ef0f6cfb2..f5446818739 100644 --- a/extensions/memory-core/openclaw.plugin.json +++ b/extensions/memory-core/openclaw.plugin.json @@ -5,7 +5,8 @@ }, "kind": "memory", "contracts": { - "memoryEmbeddingProviders": ["local"] + "memoryEmbeddingProviders": ["local"], + "tools": ["memory_get", "memory_search"] }, "commandAliases": [ { diff --git a/extensions/memory-lancedb/openclaw.plugin.json b/extensions/memory-lancedb/openclaw.plugin.json index cf5fee434c9..051ce1edb7b 100644 --- a/extensions/memory-lancedb/openclaw.plugin.json +++ b/extensions/memory-lancedb/openclaw.plugin.json @@ -4,6 +4,9 @@ "onStartup": false }, "kind": "memory", + "contracts": { + "tools": ["memory_forget", "memory_recall", "memory_store"] + }, "uiHints": { "embedding.apiKey": { "label": "Embedding API Key", diff --git a/extensions/memory-wiki/openclaw.plugin.json b/extensions/memory-wiki/openclaw.plugin.json index 44548563657..6f394d58b31 100644 --- a/extensions/memory-wiki/openclaw.plugin.json +++ b/extensions/memory-wiki/openclaw.plugin.json @@ -5,6 +5,9 @@ }, "name": "Memory Wiki", "description": "Persistent wiki compiler and Obsidian-friendly knowledge vault for OpenClaw.", + "contracts": { + "tools": ["wiki_apply", "wiki_get", "wiki_lint", "wiki_search", "wiki_status"] + }, "skills": ["./skills"], "uiHints": { "vaultMode": { diff --git a/extensions/qqbot/openclaw.plugin.json b/extensions/qqbot/openclaw.plugin.json index 5190d361330..77c03e16227 100644 --- a/extensions/qqbot/openclaw.plugin.json +++ b/extensions/qqbot/openclaw.plugin.json @@ -4,6 +4,9 @@ "onStartup": false }, "channels": ["qqbot"], + "contracts": { + "tools": ["qqbot_channel_api", "qqbot_remind"] + }, "channelEnvVars": { "qqbot": ["QQBOT_APP_ID", "QQBOT_CLIENT_SECRET"] }, diff --git a/extensions/tlon/openclaw.plugin.json b/extensions/tlon/openclaw.plugin.json index 01d11f49d28..40dd5f6390e 100644 --- a/extensions/tlon/openclaw.plugin.json +++ b/extensions/tlon/openclaw.plugin.json @@ -4,6 +4,9 @@ "onStartup": false }, "channels": ["tlon"], + "contracts": { + "tools": ["tlon"] + }, "skills": ["node_modules/@tloncorp/tlon-skill"], "configSchema": { "type": "object", diff --git a/extensions/voice-call/openclaw.plugin.json b/extensions/voice-call/openclaw.plugin.json index 36281d076b9..ebe29b4577a 100644 --- a/extensions/voice-call/openclaw.plugin.json +++ b/extensions/voice-call/openclaw.plugin.json @@ -5,6 +5,9 @@ "onStartup": true, "onCommands": ["voicecall"] }, + "contracts": { + "tools": ["voice_call"] + }, "channelEnvVars": { "voice-call": [ "TELNYX_API_KEY", diff --git a/extensions/zalouser/openclaw.plugin.json b/extensions/zalouser/openclaw.plugin.json index bd6177dd7be..f81b7712fdd 100644 --- a/extensions/zalouser/openclaw.plugin.json +++ b/extensions/zalouser/openclaw.plugin.json @@ -4,6 +4,9 @@ "onStartup": false }, "channels": ["zalouser"], + "contracts": { + "tools": ["zalouser"] + }, "channelEnvVars": { "zalouser": ["ZALOUSER_PROFILE", "ZCA_PROFILE"] }, diff --git a/src/plugins/bundled-capability-runtime.ts b/src/plugins/bundled-capability-runtime.ts index 381888c96a0..1f45149d5f1 100644 --- a/src/plugins/bundled-capability-runtime.ts +++ b/src/plugins/bundled-capability-runtime.ts @@ -24,6 +24,10 @@ import { shouldPreferNativeModuleLoad, type PluginSdkResolutionPreference, } from "./sdk-alias.js"; +import { + findUndeclaredPluginToolNames, + normalizePluginToolContractNames, +} from "./tool-contracts.js"; import type { OpenClawPluginDefinition, OpenClawPluginModule } from "./types.js"; const log = createSubsystemLogger("plugins"); @@ -477,17 +481,32 @@ export function loadBundledCapabilityRuntimeRegistry(params: { rootDir: record.rootDir, })), ); - registry.tools.push( - ...captured.tools.map((tool) => ({ + const declaredToolNames = normalizePluginToolContractNames(record.contracts); + for (const tool of captured.tools) { + const undeclared = findUndeclaredPluginToolNames({ + declaredNames: declaredToolNames, + toolNames: [tool.name], + }); + if (undeclared.length > 0) { + registry.diagnostics.push({ + level: "error", + pluginId: record.id, + source: record.source, + message: `plugin must declare contracts.tools for: ${undeclared.join(", ")}`, + }); + continue; + } + registry.tools.push({ pluginId: record.id, pluginName: record.name, factory: () => tool, names: [tool.name], + declaredNames: declaredToolNames, optional: false, source: record.source, rootDir: record.rootDir, - })), - ); + }); + } registry.plugins.push(record); } catch (error) { recordCapabilityLoadError(registry, record, String(error)); diff --git a/src/plugins/contracts/host-hooks.contract.test.ts b/src/plugins/contracts/host-hooks.contract.test.ts index 16e9f3ba4f8..76a239b0a9a 100644 --- a/src/plugins/contracts/host-hooks.contract.test.ts +++ b/src/plugins/contracts/host-hooks.contract.test.ts @@ -66,6 +66,7 @@ describe("host-hook fixture plugin contract", () => { id: "host-hook-fixture", name: "Host Hook Fixture", origin: "workspace", + contracts: { tools: ["approval_fixture_tool"] }, }), register: registerHostHookFixture, }); diff --git a/src/plugins/contracts/memory-embedding-provider.contract.test.ts b/src/plugins/contracts/memory-embedding-provider.contract.test.ts index d8ed7f382af..b27a7c1e6dc 100644 --- a/src/plugins/contracts/memory-embedding-provider.contract.test.ts +++ b/src/plugins/contracts/memory-embedding-provider.contract.test.ts @@ -89,6 +89,7 @@ describe("memory embedding provider registration", () => { id: "tool-discovery-memory", name: "Tool Discovery Memory", kind: "memory", + contracts: { tools: ["memory_recall"] }, }); registry.registry.plugins.push(record); const api = registry.createApi(record, { diff --git a/src/plugins/contracts/plugin-tool-contracts.test.ts b/src/plugins/contracts/plugin-tool-contracts.test.ts new file mode 100644 index 00000000000..0b3e0e8ceec --- /dev/null +++ b/src/plugins/contracts/plugin-tool-contracts.test.ts @@ -0,0 +1,245 @@ +import fs from "node:fs"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; + +type PluginManifestFile = { + id?: unknown; + contracts?: { + tools?: unknown; + }; +}; + +function walkFiles(dir: string): string[] { + const files: string[] = []; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + if (entry.name === "node_modules" || entry.name === "dist" || entry.name.startsWith(".")) { + continue; + } + const entryPath = path.join(dir, entry.name); + if (entry.isDirectory()) { + files.push(...walkFiles(entryPath)); + continue; + } + files.push(entryPath); + } + return files; +} + +function isProductionSource(filePath: string): boolean { + if (!/\.(?:cjs|mjs|js|ts|tsx)$/.test(filePath)) { + return false; + } + const normalized = filePath.split(path.sep).join("/"); + return !/(\.test\.|\.spec\.|\/__tests__\/|\/test-support\/)/.test(normalized); +} + +function readBalancedCallArguments(source: string, openParenIndex: number): string | undefined { + let depth = 0; + let quote: '"' | "'" | "`" | undefined; + let escaped = false; + for (let index = openParenIndex; index < source.length; index += 1) { + const char = source[index]; + if (!char) { + continue; + } + if (quote) { + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === quote) { + quote = undefined; + } + continue; + } + if (char === '"' || char === "'" || char === "`") { + quote = char; + continue; + } + if (char === "(" || char === "{" || char === "[") { + depth += 1; + continue; + } + if (char === ")" || char === "}" || char === "]") { + depth -= 1; + if (depth === 0 && char === ")") { + return source.slice(openParenIndex + 1, index); + } + } + } + return undefined; +} + +function listRegisterToolCalls(source: string): string[] { + const calls: string[] = []; + const pattern = /\bregisterTool\s*\(/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(source))) { + const openParenIndex = source.indexOf("(", match.index); + const args = readBalancedCallArguments(source, openParenIndex); + if (args !== undefined) { + calls.push(args); + } + } + return calls; +} + +function splitTopLevelArgs(args: string): string[] { + const parts: string[] = []; + let depth = 0; + let quote: '"' | "'" | "`" | undefined; + let escaped = false; + let start = 0; + for (let index = 0; index < args.length; index += 1) { + const char = args[index]; + if (!char) { + continue; + } + if (quote) { + if (escaped) { + escaped = false; + continue; + } + if (char === "\\") { + escaped = true; + continue; + } + if (char === quote) { + quote = undefined; + } + continue; + } + if (char === '"' || char === "'" || char === "`") { + quote = char; + continue; + } + if (char === "(" || char === "{" || char === "[") { + depth += 1; + continue; + } + if (char === ")" || char === "}" || char === "]") { + depth -= 1; + continue; + } + if (char === "," && depth === 0) { + parts.push(args.slice(start, index).trim()); + start = index + 1; + } + } + parts.push(args.slice(start).trim()); + return parts.filter(Boolean); +} + +function extractStringLiterals(source: string): string[] { + const names: string[] = []; + const pattern = /["']([^"']+)["']/g; + let match: RegExpExecArray | null; + while ((match = pattern.exec(source))) { + if (match[1]) { + names.push(match[1]); + } + } + return names; +} + +function extractStaticRegisteredToolNamesFromObject(source: string): string[] { + const names = new Set(); + const namesPattern = /\bnames\s*:\s*\[([\s\S]*?)\]/g; + let namesMatch: RegExpExecArray | null; + while ((namesMatch = namesPattern.exec(source))) { + for (const name of extractStringLiterals(namesMatch[1] ?? "")) { + names.add(name); + } + } + + const namePattern = /\bname\s*:\s*["']([^"']+)["']/g; + let nameMatch: RegExpExecArray | null; + while ((nameMatch = namePattern.exec(source))) { + if (nameMatch[1]) { + names.add(nameMatch[1]); + } + } + return [...names]; +} + +function extractStaticRegisteredToolNames(callArgs: string): string[] { + const args = splitTopLevelArgs(callArgs); + const names = new Set(); + const firstArg = args[0]?.trim() ?? ""; + const optionsArg = args[1]?.trim() ?? ""; + if (firstArg.startsWith("{")) { + for (const name of extractStaticRegisteredToolNamesFromObject(firstArg)) { + names.add(name); + } + } + if (optionsArg.startsWith("{")) { + for (const name of extractStaticRegisteredToolNamesFromObject(optionsArg)) { + names.add(name); + } + } + return [...names]; +} + +function readManifest(manifestPath: string): PluginManifestFile { + return JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as PluginManifestFile; +} + +function normalizeManifestTools(value: unknown): string[] { + if (!Array.isArray(value)) { + return []; + } + return value.filter((item): item is string => typeof item === "string" && item.trim() !== ""); +} + +describe("bundled plugin tool manifest contracts", () => { + it("declares every production registerTool owner in contracts.tools", () => { + const extensionsDir = path.join(process.cwd(), "extensions"); + const failures: string[] = []; + + for (const entry of fs.readdirSync(extensionsDir, { withFileTypes: true })) { + if (!entry.isDirectory() || entry.name.startsWith(".")) { + continue; + } + const pluginDir = path.join(extensionsDir, entry.name); + const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); + if (!fs.existsSync(manifestPath)) { + continue; + } + + const manifest = readManifest(manifestPath); + const pluginId = typeof manifest.id === "string" ? manifest.id : entry.name; + const declaredTools = new Set(normalizeManifestTools(manifest.contracts?.tools)); + const registeredNames = new Set(); + let registerCallCount = 0; + + for (const filePath of walkFiles(pluginDir).filter(isProductionSource)) { + const source = fs.readFileSync(filePath, "utf-8"); + for (const call of listRegisterToolCalls(source)) { + registerCallCount += 1; + for (const name of extractStaticRegisteredToolNames(call)) { + registeredNames.add(name); + } + } + } + + if (registerCallCount === 0) { + continue; + } + if (declaredTools.size === 0) { + failures.push(`${pluginId}: registers agent tools but has no contracts.tools`); + continue; + } + + const missing = [...registeredNames].filter((name) => !declaredTools.has(name)).toSorted(); + if (missing.length > 0) { + failures.push(`${pluginId}: missing contracts.tools for ${missing.join(", ")}`); + } + } + + expect(failures).toEqual([]); + }); +}); diff --git a/src/plugins/loader.prefer-over.test.ts b/src/plugins/loader.prefer-over.test.ts index 8563143c922..113ed1070bb 100644 --- a/src/plugins/loader.prefer-over.test.ts +++ b/src/plugins/loader.prefer-over.test.ts @@ -35,6 +35,7 @@ function writeChannelToolPlugin(params: { { id: params.id, channels: [params.channelId], + contracts: { tools: ["qqbot_remind"] }, ...(params.enabledByDefault ? { enabledByDefault: true } : {}), channelConfigs: { [params.channelId]: { diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index dc40f6aa9ca..a6a7598d0c9 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -144,6 +144,12 @@ function simplePluginBody(id: string) { return `module.exports = { id: ${JSON.stringify(id)}, register() {} };`; } +function updatePluginManifest(plugin: Pick, patch: Record) { + const manifestPath = path.join(plugin.dir, "openclaw.plugin.json"); + const raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as Record; + fs.writeFileSync(manifestPath, JSON.stringify({ ...raw, ...patch }, null, 2), "utf-8"); +} + function memoryPluginBody(id: string) { return `module.exports = { id: ${JSON.stringify(id)}, kind: "memory", register() {} };`; } @@ -2907,6 +2913,7 @@ module.exports = { id: "throws-after-import", register() {} };`, }, };`, }); + updatePluginManifest(plugin, { contracts: { tools: ["discovery_tool"] } }); const config = { plugins: { load: { paths: [plugin.file] }, @@ -2933,6 +2940,89 @@ module.exports = { id: "throws-after-import", register() {} };`, delete (globalThis as Record)[marker]; }); + it("rejects plugin tool registration without manifest tool ownership", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "undeclared-tool-owner", + filename: "undeclared-tool-owner.cjs", + body: `module.exports = { + id: "undeclared-tool-owner", + register(api) { + api.registerTool({ + name: "undeclared_tool", + description: "Undeclared tool", + parameters: {}, + execute: async () => ({ content: [{ type: "text", text: "ok" }] }), + }); + }, + };`, + }); + + const registry = loadOpenClawPlugins({ + activate: false, + cache: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["undeclared-tool-owner"], + }, + }, + }); + + expect(registry.tools).toEqual([]); + expect(registry.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId: "undeclared-tool-owner", + message: "plugin must declare contracts.tools before registering agent tools", + }), + ]), + ); + }); + + it("rejects plugin tool names outside the manifest tool contract", () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "wrong-tool-owner", + filename: "wrong-tool-owner.cjs", + body: `module.exports = { + id: "wrong-tool-owner", + register(api) { + api.registerTool({ + name: "runtime_tool", + description: "Runtime tool", + parameters: {}, + execute: async () => ({ content: [{ type: "text", text: "ok" }] }), + }); + }, + };`, + }); + updatePluginManifest(plugin, { contracts: { tools: ["manifest_tool"] } }); + + const registry = loadOpenClawPlugins({ + activate: false, + cache: false, + workspaceDir: plugin.dir, + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["wrong-tool-owner"], + }, + }, + }); + + expect(registry.tools).toEqual([]); + expect(registry.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + pluginId: "wrong-tool-owner", + message: "plugin must declare contracts.tools for: runtime_tool", + }), + ]), + ); + }); + it("caches non-activating snapshots without restoring global side effects", () => { useNoBundledPlugins(); clearPluginCommands(); diff --git a/src/plugins/registry-types.ts b/src/plugins/registry-types.ts index 3e6c8895815..f29dcefc2a7 100644 --- a/src/plugins/registry-types.ts +++ b/src/plugins/registry-types.ts @@ -68,6 +68,7 @@ export type PluginToolRegistration = { pluginName?: string; factory: OpenClawPluginToolFactory; names: string[]; + declaredNames?: string[]; optional: boolean; source: string; rootDir?: string; diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index df748ccb8d0..3d69ab7aa82 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -126,6 +126,11 @@ import type { import { withPluginRuntimePluginIdScope } from "./runtime/gateway-request-scope.js"; import type { PluginRuntime } from "./runtime/types.js"; import { defaultSlotIdForKey, hasKind } from "./slots.js"; +import { + findUndeclaredPluginToolNames, + normalizePluginToolContractNames, + normalizePluginToolNames, +} from "./tool-contracts.js"; import { isConversationHookName, isPluginHookName, @@ -448,7 +453,17 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { if (pluginsWithChannelRegistrationConflict.has(record.id)) { return; } - const names = opts?.names ?? (opts?.name ? [opts.name] : []); + const declaredNames = normalizePluginToolContractNames(record.contracts); + if (declaredNames.length === 0) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: "plugin must declare contracts.tools before registering agent tools", + }); + return; + } + const names = [...(opts?.names ?? []), ...(opts?.name ? [opts.name] : [])]; const optional = opts?.optional === true; const factory: OpenClawPluginToolFactory = typeof tool === "function" ? tool : (_ctx: OpenClawPluginToolContext) => tool; @@ -457,7 +472,20 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { names.push(tool.name); } - const normalized = names.map((name) => name.trim()).filter(Boolean); + const normalized = normalizePluginToolNames(names); + const undeclared = findUndeclaredPluginToolNames({ + declaredNames, + toolNames: normalized, + }); + if (undeclared.length > 0) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `plugin must declare contracts.tools for: ${undeclared.join(", ")}`, + }); + return; + } if (normalized.length > 0) { record.toolNames.push(...normalized); } @@ -466,6 +494,7 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { pluginName: record.name, factory, names: normalized, + declaredNames, optional, source: record.source, rootDir: record.rootDir, @@ -1628,6 +1657,20 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { }); return; } + const declaredNames = normalizePluginToolContractNames(record.contracts); + const undeclared = findUndeclaredPluginToolNames({ + declaredNames, + toolNames: [toolName], + }); + if (undeclared.length > 0) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `plugin must declare contracts.tools for tool metadata: ${undeclared.join(", ")}`, + }); + return; + } // Uniqueness is scoped to (pluginId + toolName): different plugins may each // register metadata under the same toolName for their own tools, but a given // plugin may not register the same toolName twice. At projection time diff --git a/src/plugins/tool-contracts.ts b/src/plugins/tool-contracts.ts new file mode 100644 index 00000000000..226cd7296c9 --- /dev/null +++ b/src/plugins/tool-contracts.ts @@ -0,0 +1,26 @@ +import type { PluginManifestContracts } from "./manifest.js"; + +export function normalizePluginToolContractNames( + contracts: Pick | undefined, +): string[] { + return normalizePluginToolNames(contracts?.tools); +} + +export function normalizePluginToolNames(names: readonly string[] | undefined): string[] { + const normalized = new Set(); + for (const name of names ?? []) { + const trimmed = name.trim(); + if (trimmed) { + normalized.add(trimmed); + } + } + return [...normalized]; +} + +export function findUndeclaredPluginToolNames(params: { + declaredNames: readonly string[]; + toolNames: readonly string[]; +}): string[] { + const declared = new Set(normalizePluginToolNames(params.declaredNames)); + return normalizePluginToolNames(params.toolNames).filter((name) => !declared.has(name)); +} diff --git a/src/plugins/tools.optional.test.ts b/src/plugins/tools.optional.test.ts index 2e38bf4d647..38beedb9b90 100644 --- a/src/plugins/tools.optional.test.ts +++ b/src/plugins/tools.optional.test.ts @@ -4,10 +4,10 @@ import { loggingState } from "../logging/state.js"; type MockRegistryToolEntry = { pluginId: string; - names?: string[]; optional: boolean; source: string; names: string[]; + declaredNames?: string[]; factory: (ctx: unknown) => unknown; }; @@ -483,6 +483,24 @@ describe("resolvePluginTools optional tools", () => { expect(warnSpy).not.toHaveBeenCalled(); }); + it("skips factory-returned tools outside the manifest tool contract", () => { + const registry = setRegistry([ + { + pluginId: "dynamic-owner", + optional: false, + source: "/tmp/dynamic-owner.js", + names: ["declared_tool"], + declaredNames: ["declared_tool"], + factory: () => [makeTool("declared_tool"), makeTool("rogue_tool")], + }, + ]); + + const tools = resolvePluginTools(createResolveToolsParams()); + + expectResolvedToolNames(tools, ["declared_tool"]); + expectSingleDiagnosticMessage(registry.diagnostics, "plugin tool is undeclared"); + }); + it("skips allowlisted optional malformed plugin tools", () => { const registry = setRegistry([ { diff --git a/src/plugins/tools.ts b/src/plugins/tools.ts index eeb4cda97fb..1c20fb9279e 100644 --- a/src/plugins/tools.ts +++ b/src/plugins/tools.ts @@ -17,6 +17,7 @@ import { buildPluginRuntimeLoadOptions, resolvePluginRuntimeLoadContext, } from "./runtime/load-context.js"; +import { findUndeclaredPluginToolNames } from "./tool-contracts.js"; import type { OpenClawPluginToolContext } from "./types.js"; export type PluginToolMeta = { @@ -470,6 +471,23 @@ export function resolvePluginTools(params: { continue; } const tool = toolRaw as AnyAgentTool; + const undeclared = entry.declaredNames + ? findUndeclaredPluginToolNames({ + declaredNames: entry.declaredNames, + toolNames: [tool.name], + }) + : []; + if (undeclared.length > 0) { + const message = `plugin tool is undeclared (${entry.pluginId}): ${undeclared.join(", ")}`; + context.logger.error(message); + registry.diagnostics.push({ + level: "error", + pluginId: entry.pluginId, + source: entry.source, + message, + }); + continue; + } if (nameSet.has(tool.name) || existing.has(tool.name)) { const message = `plugin tool name conflict (${entry.pluginId}): ${tool.name}`; if (!params.suppressNameConflicts) {