mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 05:10:44 +00:00
fix: enforce plugin tool manifest contracts
This commit is contained in:
@@ -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.
|
||||
```
|
||||
</CodeGroup>
|
||||
|
||||
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
|
||||
|
||||
@@ -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.<id>`. |
|
||||
| `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<string, string[]>` | 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<string, string>` | 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<string, string[]>` | 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<string, object>` | Cheap media-understanding defaults for provider ids declared in `contracts.mediaUnderstandingProviders`. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `channelConfigs` | No | `Record<string, object>` | 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<string, object>` | 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.<id>`. |
|
||||
| `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<string, string[]>` | 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<string, string>` | 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<string, string[]>` | 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<string, object>` | Cheap media-understanding defaults for provider ids declared in `contracts.mediaUnderstandingProviders`. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
| `channelConfigs` | No | `Record<string, object>` | 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<string, object>` | 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
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"onStartup": true,
|
||||
"onConfigPaths": ["browser"]
|
||||
},
|
||||
"contracts": {
|
||||
"tools": ["browser"]
|
||||
},
|
||||
"commandAliases": [{ "name": "browser" }],
|
||||
"skills": ["./skills"],
|
||||
"configSchema": {
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
},
|
||||
"name": "Diffs",
|
||||
"description": "Read-only diff viewer and file renderer for agents.",
|
||||
"contracts": {
|
||||
"tools": ["diffs"]
|
||||
},
|
||||
"skills": ["./skills"],
|
||||
"uiHints": {
|
||||
"viewerBaseUrl": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -9,6 +9,9 @@
|
||||
"onCommands": ["googlemeet"],
|
||||
"onCapabilities": ["tool"]
|
||||
},
|
||||
"contracts": {
|
||||
"tools": ["google_meet"]
|
||||
},
|
||||
"uiHints": {
|
||||
"defaults.meeting": {
|
||||
"label": "Default Meeting",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
},
|
||||
"name": "Lobster",
|
||||
"description": "Typed workflow tool with resumable approvals.",
|
||||
"contracts": {
|
||||
"tools": ["lobster"]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
},
|
||||
"kind": "memory",
|
||||
"contracts": {
|
||||
"memoryEmbeddingProviders": ["local"]
|
||||
"memoryEmbeddingProviders": ["local"],
|
||||
"tools": ["memory_get", "memory_search"]
|
||||
},
|
||||
"commandAliases": [
|
||||
{
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"onStartup": false
|
||||
},
|
||||
"kind": "memory",
|
||||
"contracts": {
|
||||
"tools": ["memory_forget", "memory_recall", "memory_store"]
|
||||
},
|
||||
"uiHints": {
|
||||
"embedding.apiKey": {
|
||||
"label": "Embedding API Key",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"onStartup": false
|
||||
},
|
||||
"channels": ["qqbot"],
|
||||
"contracts": {
|
||||
"tools": ["qqbot_channel_api", "qqbot_remind"]
|
||||
},
|
||||
"channelEnvVars": {
|
||||
"qqbot": ["QQBOT_APP_ID", "QQBOT_CLIENT_SECRET"]
|
||||
},
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"onStartup": false
|
||||
},
|
||||
"channels": ["tlon"],
|
||||
"contracts": {
|
||||
"tools": ["tlon"]
|
||||
},
|
||||
"skills": ["node_modules/@tloncorp/tlon-skill"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
"onStartup": true,
|
||||
"onCommands": ["voicecall"]
|
||||
},
|
||||
"contracts": {
|
||||
"tools": ["voice_call"]
|
||||
},
|
||||
"channelEnvVars": {
|
||||
"voice-call": [
|
||||
"TELNYX_API_KEY",
|
||||
|
||||
@@ -4,6 +4,9 @@
|
||||
"onStartup": false
|
||||
},
|
||||
"channels": ["zalouser"],
|
||||
"contracts": {
|
||||
"tools": ["zalouser"]
|
||||
},
|
||||
"channelEnvVars": {
|
||||
"zalouser": ["ZALOUSER_PROFILE", "ZCA_PROFILE"]
|
||||
},
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
245
src/plugins/contracts/plugin-tool-contracts.test.ts
Normal file
245
src/plugins/contracts/plugin-tool-contracts.test.ts
Normal file
@@ -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<string>();
|
||||
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<string>();
|
||||
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<string>();
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -35,6 +35,7 @@ function writeChannelToolPlugin(params: {
|
||||
{
|
||||
id: params.id,
|
||||
channels: [params.channelId],
|
||||
contracts: { tools: ["qqbot_remind"] },
|
||||
...(params.enabledByDefault ? { enabledByDefault: true } : {}),
|
||||
channelConfigs: {
|
||||
[params.channelId]: {
|
||||
|
||||
@@ -144,6 +144,12 @@ function simplePluginBody(id: string) {
|
||||
return `module.exports = { id: ${JSON.stringify(id)}, register() {} };`;
|
||||
}
|
||||
|
||||
function updatePluginManifest(plugin: Pick<TempPlugin, "dir">, patch: Record<string, unknown>) {
|
||||
const manifestPath = path.join(plugin.dir, "openclaw.plugin.json");
|
||||
const raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as Record<string, unknown>;
|
||||
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<string, unknown>)[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();
|
||||
|
||||
@@ -68,6 +68,7 @@ export type PluginToolRegistration = {
|
||||
pluginName?: string;
|
||||
factory: OpenClawPluginToolFactory;
|
||||
names: string[];
|
||||
declaredNames?: string[];
|
||||
optional: boolean;
|
||||
source: string;
|
||||
rootDir?: string;
|
||||
|
||||
@@ -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
|
||||
|
||||
26
src/plugins/tool-contracts.ts
Normal file
26
src/plugins/tool-contracts.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import type { PluginManifestContracts } from "./manifest.js";
|
||||
|
||||
export function normalizePluginToolContractNames(
|
||||
contracts: Pick<PluginManifestContracts, "tools"> | undefined,
|
||||
): string[] {
|
||||
return normalizePluginToolNames(contracts?.tools);
|
||||
}
|
||||
|
||||
export function normalizePluginToolNames(names: readonly string[] | undefined): string[] {
|
||||
const normalized = new Set<string>();
|
||||
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));
|
||||
}
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user