fix: enforce plugin tool manifest contracts

This commit is contained in:
Shakker
2026-05-01 22:23:10 +01:00
parent 7028f1b485
commit 7641783d6b
26 changed files with 585 additions and 51 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -5,6 +5,9 @@
"onStartup": true,
"onConfigPaths": ["browser"]
},
"contracts": {
"tools": ["browser"]
},
"commandAliases": [{ "name": "browser" }],
"skills": ["./skills"],
"configSchema": {

View File

@@ -5,6 +5,9 @@
},
"name": "Diffs",
"description": "Read-only diff viewer and file renderer for agents.",
"contracts": {
"tools": ["diffs"]
},
"skills": ["./skills"],
"uiHints": {
"viewerBaseUrl": {

View File

@@ -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",

View File

@@ -9,6 +9,9 @@
"onCommands": ["googlemeet"],
"onCapabilities": ["tool"]
},
"contracts": {
"tools": ["google_meet"]
},
"uiHints": {
"defaults.meeting": {
"label": "Default Meeting",

View File

@@ -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,

View File

@@ -5,6 +5,9 @@
},
"name": "Lobster",
"description": "Typed workflow tool with resumable approvals.",
"contracts": {
"tools": ["lobster"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,

View File

@@ -5,7 +5,8 @@
},
"kind": "memory",
"contracts": {
"memoryEmbeddingProviders": ["local"]
"memoryEmbeddingProviders": ["local"],
"tools": ["memory_get", "memory_search"]
},
"commandAliases": [
{

View File

@@ -4,6 +4,9 @@
"onStartup": false
},
"kind": "memory",
"contracts": {
"tools": ["memory_forget", "memory_recall", "memory_store"]
},
"uiHints": {
"embedding.apiKey": {
"label": "Embedding API Key",

View File

@@ -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": {

View File

@@ -4,6 +4,9 @@
"onStartup": false
},
"channels": ["qqbot"],
"contracts": {
"tools": ["qqbot_channel_api", "qqbot_remind"]
},
"channelEnvVars": {
"qqbot": ["QQBOT_APP_ID", "QQBOT_CLIENT_SECRET"]
},

View File

@@ -4,6 +4,9 @@
"onStartup": false
},
"channels": ["tlon"],
"contracts": {
"tools": ["tlon"]
},
"skills": ["node_modules/@tloncorp/tlon-skill"],
"configSchema": {
"type": "object",

View File

@@ -5,6 +5,9 @@
"onStartup": true,
"onCommands": ["voicecall"]
},
"contracts": {
"tools": ["voice_call"]
},
"channelEnvVars": {
"voice-call": [
"TELNYX_API_KEY",

View File

@@ -4,6 +4,9 @@
"onStartup": false
},
"channels": ["zalouser"],
"contracts": {
"tools": ["zalouser"]
},
"channelEnvVars": {
"zalouser": ["ZALOUSER_PROFILE", "ZCA_PROFILE"]
},

View File

@@ -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));

View File

@@ -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,
});

View File

@@ -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, {

View 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([]);
});
});

View File

@@ -35,6 +35,7 @@ function writeChannelToolPlugin(params: {
{
id: params.id,
channels: [params.channelId],
contracts: { tools: ["qqbot_remind"] },
...(params.enabledByDefault ? { enabledByDefault: true } : {}),
channelConfigs: {
[params.channelId]: {

View File

@@ -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();

View File

@@ -68,6 +68,7 @@ export type PluginToolRegistration = {
pluginName?: string;
factory: OpenClawPluginToolFactory;
names: string[];
declaredNames?: string[];
optional: boolean;
source: string;
rootDir?: string;

View File

@@ -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

View 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));
}

View File

@@ -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([
{

View File

@@ -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) {