diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index 9d886a97390..4de8c088eef 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -119,31 +119,22 @@ signals also appear in `openclaw status --all` and `openclaw plugins doctor`. ## Architecture overview -OpenClaw's plugin system is split into four planes: +OpenClaw's plugin system has four layers: -1. **Source plane** - OpenClaw decides where a plugin comes from and how it can be installed. This - includes bundled catalogs, official external catalogs, ClawHub/npm specs, - local source paths, minimum host version, expected npm integrity, and install - policy checks. -2. **Control plane** - OpenClaw reads package and manifest metadata before runtime code executes. - This includes discovery, config schemas, provider/channel ownership, - setup/onboarding hints, contracts, auth choices, and enablement policy. -3. **Load plane** - OpenClaw builds deterministic plans for concrete needs such as a provider, - channel, command, hook stage, or contract. Legacy `activation.*` fields are - compatibility hints in this plane, not the preferred public contract. -4. **Runtime plane** - OpenClaw imports plugin code only for actual execution. Native plugins - register capabilities into scoped or compatibility registries; compatible - bundles can still normalize into registry records without importing runtime - code. - -The important compatibility rule: documented external plugins and existing -bundled plugins must keep working while contracts migrate. Breaking changes need -a replacement contract, compatibility adapter, diagnostics, tests, docs, and an -approved deprecation window before removal. +1. **Manifest + discovery** + OpenClaw finds candidate plugins from configured paths, workspace roots, + global plugin roots, and bundled plugins. Discovery reads native + `openclaw.plugin.json` manifests plus supported bundle manifests first. +2. **Enablement + validation** + Core decides whether a discovered plugin is enabled, disabled, blocked, or + selected for an exclusive slot such as memory. +3. **Runtime loading** + Native OpenClaw plugins are loaded in-process via jiti and register + capabilities into a central registry. Compatible bundles are normalized into + registry records without importing runtime code. +4. **Surface consumption** + The rest of OpenClaw reads the registry to expose tools, channels, provider + setup, hooks, HTTP routes, CLI commands, and services. For plugin CLI specifically, root command discovery is split in two phases: diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index fecdeaa36da..7788b0d2b82 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -239,15 +239,11 @@ runtime still owns actual CLI registration through a lightweight | `commandName` | Yes | `string` | Subcommand mounted beneath `openclaw qa`, for example `matrix`. | | `description` | No | `string` | Fallback help text used when the shared host needs a stub command. | -This block is legacy hint metadata. It does not register runtime behavior, and -it does not replace `register(...)`, `setupEntry`, or other runtime/plugin -entrypoints. Existing plugins may keep these fields, but new manifests should -prefer explicit ownership fields such as `providers`, `channels`, `contracts`, -`commandAliases`, and `setup`. - -Current consumers still parse `activation` through the compatibility layer so -existing bundled and external plugins keep working. New code should treat these -fields as fallback hints for load planning, not as the primary plugin contract. +This block is metadata only. It does not register runtime behavior, and it does +not replace `register(...)`, `setupEntry`, or other runtime/plugin entrypoints. +Current consumers use it as a narrowing hint before broader plugin loading, so +missing activation metadata usually only costs performance; it should not +change correctness while legacy manifest ownership fallbacks still exist. ```json { @@ -261,24 +257,23 @@ fields as fallback hints for load planning, not as the primary plugin contract. } ``` -| Field | Required | Type | What it means | -| ---------------- | -------- | ---------------------------------------------------- | ----------------------------------------------------------------------------- | -| `onProviders` | No | `string[]` | Legacy provider load hint. Prefer top-level `providers`. | -| `onCommands` | No | `string[]` | Legacy command load hint. Prefer command aliases or CLI descriptors. | -| `onChannels` | No | `string[]` | Legacy channel load hint. Prefer top-level `channels`. | -| `onRoutes` | No | `string[]` | Legacy route load hint. Keep only when no narrower route metadata exists yet. | -| `onCapabilities` | No | `Array<"provider" \| "channel" \| "tool" \| "hook">` | Legacy broad capability hint. Do not add new uses. | +| Field | Required | Type | What it means | +| ---------------- | -------- | ---------------------------------------------------- | ----------------------------------------------------------------- | +| `onProviders` | No | `string[]` | Provider ids that should activate this plugin when requested. | +| `onCommands` | No | `string[]` | Command ids that should activate this plugin. | +| `onChannels` | No | `string[]` | Channel ids that should activate this plugin. | +| `onRoutes` | No | `string[]` | Route kinds that should activate this plugin. | +| `onCapabilities` | No | `Array<"provider" \| "channel" \| "tool" \| "hook">` | Broad capability hints used by control-plane activation planning. | Current live consumers: -- command-triggered CLI planning prefers `commandAliases[].cliCommand` or - `commandAliases[].name` before legacy `activation.onCommands` -- channel-triggered setup/channel planning prefers `channels[]` before legacy - `activation.onChannels` -- provider-triggered setup/runtime planning prefers `providers[]` and - `setup.providers[]` before legacy `activation.onProviders` -- broad capability planning prefers explicit ownership metadata before legacy - `activation.onCapabilities` +- command-triggered CLI planning falls back to legacy + `commandAliases[].cliCommand` or `commandAliases[].name` +- channel-triggered setup/channel planning falls back to legacy `channels[]` + ownership when explicit channel activation metadata is missing +- provider-triggered setup/runtime planning falls back to legacy + `providers[]` and top-level `cliBackends[]` ownership when explicit provider + activation metadata is missing ## setup reference diff --git a/docs/plugins/sdk-migration.md b/docs/plugins/sdk-migration.md index fd726846b22..f6d18fc4475 100644 --- a/docs/plugins/sdk-migration.md +++ b/docs/plugins/sdk-migration.md @@ -25,14 +25,12 @@ anything they needed from a single entry point: host-side helpers like the embedded agent runner. Both surfaces are now **deprecated**. They still work at runtime, but new -plugins must not use them, and existing plugins should migrate before an -approved breaking release removes them. +plugins must not use them, and existing plugins should migrate before the next +major release removes them. - The backwards-compatibility layer remains supported during the migration - window. Any removal must go through a documented deprecation path first: - replacement contract, compatibility adapter, diagnostics, tests, docs, and an - explicitly approved breaking release. + The backwards-compatibility layer will be removed in a future major release. + Plugins that still import from these surfaces will break when that happens. ## Why this changed @@ -376,15 +374,13 @@ check the source at `src/plugin-sdk/` or ask in Discord. ## Removal timeline -| When | What happens | -| ---------------------------------- | ---------------------------------------------------------------------------- | -| **Now** | Deprecated surfaces emit runtime warnings and keep working through adapters. | -| **Migration window** | Replacement contracts, diagnostics, tests, and docs stay available together. | -| **Approved breaking release only** | Deprecated surfaces may be removed after the migration window. | +| When | What happens | +| ---------------------- | ----------------------------------------------------------------------- | +| **Now** | Deprecated surfaces emit runtime warnings | +| **Next major release** | Deprecated surfaces will be removed; plugins still using them will fail | -All core plugins have already been migrated. External plugins should migrate, -but documented external plugins should not break without the compatibility path -above. +All core plugins have already been migrated. External plugins should migrate +before the next major release. ## Suppressing the warnings temporarily diff --git a/src/plugin-sdk/compat.ts b/src/plugin-sdk/compat.ts index 5a13f5089ff..5ad6e71f26a 100644 --- a/src/plugin-sdk/compat.ts +++ b/src/plugin-sdk/compat.ts @@ -12,7 +12,7 @@ if (shouldWarnCompatImport) { { code: "OPENCLAW_PLUGIN_SDK_COMPAT_DEPRECATED", detail: - "Bundled plugins must use scoped plugin-sdk subpaths. External plugins may keep compat during the documented migration window. Migration guide: https://docs.openclaw.ai/plugins/sdk-migration", + "Bundled plugins must use scoped plugin-sdk subpaths. External plugins may keep compat temporarily while migrating. Migration guide: https://docs.openclaw.ai/plugins/sdk-migration", }, ); } diff --git a/src/plugins/activation-planner.test.ts b/src/plugins/activation-planner.test.ts index 92e33182f9f..083a0bbdd5f 100644 --- a/src/plugins/activation-planner.test.ts +++ b/src/plugins/activation-planner.test.ts @@ -9,14 +9,10 @@ vi.mock("./manifest-registry.js", () => ({ })); let resolveManifestActivationPluginIds: typeof import("./activation-planner.js").resolveManifestActivationPluginIds; -let resolveManifestActivationPlan: typeof import("./activation-planner.js").resolveManifestActivationPlan; -let PLUGIN_COMPAT_REASON: typeof import("./compat-reasons.js").PLUGIN_COMPAT_REASON; describe("resolveManifestActivationPluginIds", () => { beforeAll(async () => { - ({ resolveManifestActivationPluginIds, resolveManifestActivationPlan } = - await import("./activation-planner.js")); - ({ PLUGIN_COMPAT_REASON } = await import("./compat-reasons.js")); + ({ resolveManifestActivationPluginIds } = await import("./activation-planner.js")); }); beforeEach(() => { @@ -74,20 +70,6 @@ describe("resolveManifestActivationPluginIds", () => { }, origin: "workspace", }, - { - id: "legacy-activation-only", - providers: [], - activation: { - onProviders: ["legacy-provider"], - onChannels: ["legacy-channel"], - onCapabilities: ["tool"], - }, - channels: [], - cliBackends: [], - skills: [], - hooks: [], - origin: "workspace", - }, ], diagnostics: [], }); @@ -186,7 +168,7 @@ describe("resolveManifestActivationPluginIds", () => { capability: "tool", }, }), - ).toEqual(["demo-channel", "legacy-activation-only"]); + ).toEqual(["demo-channel"]); expect( resolveManifestActivationPluginIds({ @@ -209,96 +191,4 @@ describe("resolveManifestActivationPluginIds", () => { }), ).toEqual([]); }); - - it("reports legacy activation field compat reasons without changing plugin-id resolution", () => { - expect( - resolveManifestActivationPlan({ - trigger: { - kind: "command", - command: "demo-tools", - }, - }), - ).toEqual({ - pluginIds: ["demo-channel"], - entries: [ - { - pluginId: "demo-channel", - reasons: ["command:demo-tools"], - compatReasons: [PLUGIN_COMPAT_REASON.legacyActivationField], - }, - ], - compatReasons: { - "demo-channel": [PLUGIN_COMPAT_REASON.legacyActivationField], - }, - }); - - expect( - resolveManifestActivationPlan({ - trigger: { - kind: "provider", - provider: "legacy-provider", - }, - }).compatReasons, - ).toEqual({ - "legacy-activation-only": [PLUGIN_COMPAT_REASON.legacyActivationField], - }); - - expect( - resolveManifestActivationPluginIds({ - trigger: { - kind: "provider", - provider: "legacy-provider", - }, - }), - ).toEqual(["legacy-activation-only"]); - }); - - it("does not report compat reasons for stable ownership metadata", () => { - expect( - resolveManifestActivationPlan({ - trigger: { - kind: "provider", - provider: "openai", - }, - }), - ).toEqual({ - pluginIds: ["openai"], - entries: [ - { - pluginId: "openai", - reasons: ["provider:openai"], - compatReasons: [], - }, - ], - compatReasons: {}, - }); - }); - - it("reports legacy activation capability hints separately from stable capabilities", () => { - expect( - resolveManifestActivationPlan({ - trigger: { - kind: "capability", - capability: "tool", - }, - }), - ).toEqual({ - pluginIds: ["demo-channel", "legacy-activation-only"], - entries: [ - { - pluginId: "demo-channel", - reasons: ["capability:tool"], - compatReasons: [], - }, - { - pluginId: "legacy-activation-only", - reasons: ["capability:tool"], - compatReasons: [PLUGIN_COMPAT_REASON.legacyActivationField], - }, - ], - compatReasons: { - "legacy-activation-only": [PLUGIN_COMPAT_REASON.legacyActivationField], - }, - }); - }); }); diff --git a/src/plugins/activation-planner.ts b/src/plugins/activation-planner.ts index 0c27be58c9a..2206c689a7e 100644 --- a/src/plugins/activation-planner.ts +++ b/src/plugins/activation-planner.ts @@ -1,7 +1,6 @@ import { normalizeProviderId } from "../agents/provider-id.js"; import type { OpenClawConfig } from "../config/types.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; -import { PLUGIN_COMPAT_REASON, type PluginCompatReason } from "./compat-reasons.js"; import { loadPluginManifestRegistry, type PluginManifestRecord } from "./manifest-registry.js"; import type { PluginManifestActivationCapability } from "./manifest.js"; import type { PluginOrigin } from "./plugin-origin.types.js"; @@ -15,18 +14,6 @@ export type PluginActivationPlannerTrigger = | { kind: "route"; route: string } | { kind: "capability"; capability: PluginManifestActivationCapability }; -export type PluginActivationPlanEntry = { - pluginId: string; - reasons: string[]; - compatReasons: PluginCompatReason[]; -}; - -export type PluginActivationPlan = { - pluginIds: string[]; - entries: PluginActivationPlanEntry[]; - compatReasons: Record; -}; - export function resolveManifestActivationPluginIds(params: { trigger: PluginActivationPlannerTrigger; config?: OpenClawConfig; @@ -36,218 +23,101 @@ export function resolveManifestActivationPluginIds(params: { origin?: PluginOrigin; onlyPluginIds?: readonly string[]; }): string[] { - return resolveManifestActivationPlan(params).pluginIds; -} - -export function resolveManifestActivationPlan(params: { - trigger: PluginActivationPlannerTrigger; - config?: OpenClawConfig; - workspaceDir?: string; - env?: NodeJS.ProcessEnv; - cache?: boolean; - origin?: PluginOrigin; - onlyPluginIds?: readonly string[]; -}): PluginActivationPlan { const onlyPluginIdSet = createPluginIdScopeSet(normalizePluginIdScope(params.onlyPluginIds)); - const entries = loadPluginManifestRegistry({ - config: params.config, - workspaceDir: params.workspaceDir, - env: params.env, - cache: params.cache, - }) - .plugins.flatMap((plugin): PluginActivationPlanEntry[] => { - if ( - (params.origin && plugin.origin !== params.origin) || - (onlyPluginIdSet && !onlyPluginIdSet.has(plugin.id)) - ) { - return []; - } - const match = matchManifestActivationTrigger(plugin, params.trigger); - if (!match) { - return []; - } - return [ - { - pluginId: plugin.id, - reasons: [match.reason], - compatReasons: match.compatReason ? [match.compatReason] : [], - }, - ]; - }) - .toSorted((left, right) => left.pluginId.localeCompare(right.pluginId)); - - return { - pluginIds: entries.map((entry) => entry.pluginId), - entries, - compatReasons: Object.fromEntries( - entries.flatMap((entry) => - entry.compatReasons.length > 0 ? [[entry.pluginId, entry.compatReasons]] : [], - ), + return [ + ...new Set( + loadPluginManifestRegistry({ + config: params.config, + workspaceDir: params.workspaceDir, + env: params.env, + cache: params.cache, + }) + .plugins.filter( + (plugin) => + (!params.origin || plugin.origin === params.origin) && + (!onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)) && + matchesManifestActivationTrigger(plugin, params.trigger), + ) + .map((plugin) => plugin.id), ), - }; + ].toSorted((left, right) => left.localeCompare(right)); } -type ManifestActivationMatch = { - reason: string; - compatReason?: PluginCompatReason; -}; - -function matchManifestActivationTrigger( +function matchesManifestActivationTrigger( plugin: PluginManifestRecord, trigger: PluginActivationPlannerTrigger, -): ManifestActivationMatch | null { +): boolean { switch (trigger.kind) { case "command": - return matchActivationCommand(plugin, trigger.command); + return listActivationCommandIds(plugin).includes(normalizeCommandId(trigger.command)); case "provider": - return matchActivationProvider(plugin, trigger.provider); + return listActivationProviderIds(plugin).includes(normalizeProviderId(trigger.provider)); case "agentHarness": - return matchLegacyActivationList( - listActivationAgentHarnessIds(plugin), - normalizeCommandId(trigger.runtime), - "agent-harness", - ); + return listActivationAgentHarnessIds(plugin).includes(normalizeCommandId(trigger.runtime)); case "channel": - return matchActivationChannel(plugin, trigger.channel); + return listActivationChannelIds(plugin).includes(normalizeCommandId(trigger.channel)); case "route": - return matchLegacyActivationList( - listActivationRouteIds(plugin), - normalizeCommandId(trigger.route), - "route", - ); + return listActivationRouteIds(plugin).includes(normalizeCommandId(trigger.route)); case "capability": - return matchActivationCapability(plugin, trigger.capability); + return hasActivationCapability(plugin, trigger.capability); } const unreachableTrigger: never = trigger; return unreachableTrigger; } -function matchLegacyActivationList( - ids: readonly string[], - normalizedId: string, - reasonPrefix: string, -): ManifestActivationMatch | null { - if (!normalizedId || !ids.includes(normalizedId)) { - return null; - } - return { - reason: `${reasonPrefix}:${normalizedId}`, - compatReason: PLUGIN_COMPAT_REASON.legacyActivationField, - }; -} - function listActivationAgentHarnessIds(plugin: PluginManifestRecord): string[] { return [...(plugin.activation?.onAgentHarnesses ?? [])].map(normalizeCommandId).filter(Boolean); } -function matchActivationCommand( - plugin: PluginManifestRecord, - command: string, -): ManifestActivationMatch | null { - const normalizedCommand = normalizeCommandId(command); - if (!normalizedCommand) { - return null; - } - const commandAliases = (plugin.commandAliases ?? []) - .flatMap((alias) => alias.cliCommand ?? alias.name) +function listActivationCommandIds(plugin: PluginManifestRecord): string[] { + return [ + ...(plugin.activation?.onCommands ?? []), + ...(plugin.commandAliases ?? []).flatMap((alias) => alias.cliCommand ?? alias.name), + ] .map(normalizeCommandId) .filter(Boolean); - if (commandAliases.includes(normalizedCommand)) { - return { reason: `command:${normalizedCommand}` }; - } - return matchLegacyActivationList( - (plugin.activation?.onCommands ?? []).map(normalizeCommandId).filter(Boolean), - normalizedCommand, - "command", - ); } -function matchActivationProvider( - plugin: PluginManifestRecord, - provider: string, -): ManifestActivationMatch | null { - const normalizedProvider = normalizeProviderId(provider); - if (!normalizedProvider) { - return null; - } - const stableProviderIds = [ +function listActivationProviderIds(plugin: PluginManifestRecord): string[] { + return [ + ...(plugin.activation?.onProviders ?? []), ...plugin.providers, - ...(plugin.setup?.providers?.map((entry) => entry.id) ?? []), + ...(plugin.setup?.providers?.map((provider) => provider.id) ?? []), ] .map((value) => normalizeProviderId(value)) .filter(Boolean); - if (stableProviderIds.includes(normalizedProvider)) { - return { reason: `provider:${normalizedProvider}` }; - } - return matchLegacyActivationList( - (plugin.activation?.onProviders ?? []) - .map((value) => normalizeProviderId(value)) - .filter(Boolean), - normalizedProvider, - "provider", - ); } -function matchActivationChannel( - plugin: PluginManifestRecord, - channel: string, -): ManifestActivationMatch | null { - const normalizedChannel = normalizeCommandId(channel); - if (!normalizedChannel) { - return null; - } - const stableChannelIds = plugin.channels.map(normalizeCommandId).filter(Boolean); - if (stableChannelIds.includes(normalizedChannel)) { - return { reason: `channel:${normalizedChannel}` }; - } - return matchLegacyActivationList( - (plugin.activation?.onChannels ?? []).map(normalizeCommandId).filter(Boolean), - normalizedChannel, - "channel", - ); +function listActivationChannelIds(plugin: PluginManifestRecord): string[] { + return [...(plugin.activation?.onChannels ?? []), ...plugin.channels] + .map(normalizeCommandId) + .filter(Boolean); } function listActivationRouteIds(plugin: PluginManifestRecord): string[] { return (plugin.activation?.onRoutes ?? []).map(normalizeCommandId).filter(Boolean); } -function matchActivationCapability( +function hasActivationCapability( plugin: PluginManifestRecord, capability: PluginManifestActivationCapability, -): ManifestActivationMatch | null { - switch (capability) { - case "provider": { - const hasProviderOwnership = - plugin.providers.length > 0 || (plugin.setup?.providers?.length ?? 0) > 0; - if (hasProviderOwnership) { - return { reason: "capability:provider" }; - } - break; - } - case "channel": - if (plugin.channels.length > 0) { - return { reason: "capability:channel" }; - } - break; - case "tool": - if ((plugin.contracts?.tools?.length ?? 0) > 0) { - return { reason: "capability:tool" }; - } - break; - case "hook": - if (plugin.hooks.length > 0) { - return { reason: "capability:hook" }; - } - break; - } +): boolean { if (plugin.activation?.onCapabilities?.includes(capability)) { - return { - reason: `capability:${capability}`, - compatReason: PLUGIN_COMPAT_REASON.legacyActivationField, - }; + return true; } - return null; + switch (capability) { + case "provider": + return listActivationProviderIds(plugin).length > 0; + case "channel": + return listActivationChannelIds(plugin).length > 0; + case "tool": + return (plugin.contracts?.tools?.length ?? 0) > 0; + case "hook": + return plugin.hooks.length > 0; + } + const unreachableCapability: never = capability; + return unreachableCapability; } function normalizeCommandId(value: string | undefined): string { diff --git a/src/plugins/compat-reasons.ts b/src/plugins/compat-reasons.ts deleted file mode 100644 index 03d4c56b533..00000000000 --- a/src/plugins/compat-reasons.ts +++ /dev/null @@ -1,10 +0,0 @@ -export const PLUGIN_COMPAT_REASON = { - legacyActivationField: "legacy-activation-field", - legacySetupApi: "legacy-setup-api", - legacyRootSdkImport: "legacy-root-sdk-import", - legacyGlobalRegistry: "legacy-global-registry", - legacyManifestOwnerFallback: "legacy-manifest-owner-fallback", - legacyHookStage: "legacy-hook-stage", -} as const; - -export type PluginCompatReason = (typeof PLUGIN_COMPAT_REASON)[keyof typeof PLUGIN_COMPAT_REASON]; diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index c391b8ef9ec..eaaa01eed5c 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -57,19 +57,19 @@ export type PluginManifestActivationCapability = "provider" | "channel" | "tool" export type PluginManifestActivation = { /** - * Legacy provider activation hints. Prefer top-level `providers` and setup - * provider ownership for new manifests. + * Provider ids that should activate this plugin when explicitly requested. + * This is metadata only; runtime loading still happens through the loader. */ onProviders?: string[]; - /** Legacy agent harness runtime activation hints. */ + /** Agent harness runtime ids that should activate this plugin. */ onAgentHarnesses?: string[]; - /** Legacy command activation hints. Prefer command aliases or CLI descriptors. */ + /** Command ids that should activate this plugin. */ onCommands?: string[]; - /** Legacy channel activation hints. Prefer top-level `channels`. */ + /** Channel ids that should activate this plugin. */ onChannels?: string[]; - /** Legacy route activation hints. */ + /** Route kinds that should activate this plugin. */ onRoutes?: string[]; - /** Legacy broad capability hints. Do not add new uses. */ + /** Cheap capability hints used by future activation planning. */ onCapabilities?: PluginManifestActivationCapability[]; };