refactor(plugins): track activation compat hints

This commit is contained in:
Vincent Koc
2026-04-23 20:59:07 -07:00
parent 76a4c167f7
commit b1d0c14d38
8 changed files with 374 additions and 106 deletions

View File

@@ -119,22 +119,31 @@ signals also appear in `openclaw status --all` and `openclaw plugins doctor`.
## Architecture overview
OpenClaw's plugin system has four layers:
OpenClaw's plugin system is split into four planes:
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.
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.
For plugin CLI specifically, root command discovery is split in two phases:

View File

@@ -239,11 +239,15 @@ 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 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.
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.
```json
{
@@ -257,23 +261,24 @@ change correctness while legacy manifest ownership fallbacks still exist.
}
```
| 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. |
| 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. |
Current live consumers:
- 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
- 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`
## setup reference

View File

@@ -25,12 +25,14 @@ 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 the next
major release removes them.
plugins must not use them, and existing plugins should migrate before an
approved breaking release removes them.
<Warning>
The backwards-compatibility layer will be removed in a future major release.
Plugins that still import from these surfaces will break when that happens.
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.
</Warning>
## Why this changed
@@ -374,13 +376,15 @@ check the source at `src/plugin-sdk/` or ask in Discord.
## Removal timeline
| When | What happens |
| ---------------------- | ----------------------------------------------------------------------- |
| **Now** | Deprecated surfaces emit runtime warnings |
| **Next major release** | Deprecated surfaces will be removed; plugins still using them will fail |
| 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. |
All core plugins have already been migrated. External plugins should migrate
before the next major release.
All core plugins have already been migrated. External plugins should migrate,
but documented external plugins should not break without the compatibility path
above.
## Suppressing the warnings temporarily

View File

@@ -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 temporarily while migrating. Migration guide: https://docs.openclaw.ai/plugins/sdk-migration",
"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",
},
);
}

View File

@@ -9,10 +9,14 @@ 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 } = await import("./activation-planner.js"));
({ resolveManifestActivationPluginIds, resolveManifestActivationPlan } =
await import("./activation-planner.js"));
({ PLUGIN_COMPAT_REASON } = await import("./compat-reasons.js"));
});
beforeEach(() => {
@@ -70,6 +74,20 @@ 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: [],
});
@@ -168,7 +186,7 @@ describe("resolveManifestActivationPluginIds", () => {
capability: "tool",
},
}),
).toEqual(["demo-channel"]);
).toEqual(["demo-channel", "legacy-activation-only"]);
expect(
resolveManifestActivationPluginIds({
@@ -191,4 +209,96 @@ 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],
},
});
});
});

View File

@@ -1,6 +1,7 @@
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";
@@ -14,6 +15,18 @@ 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<string, PluginCompatReason[]>;
};
export function resolveManifestActivationPluginIds(params: {
trigger: PluginActivationPlannerTrigger;
config?: OpenClawConfig;
@@ -23,101 +36,218 @@ export function resolveManifestActivationPluginIds(params: {
origin?: PluginOrigin;
onlyPluginIds?: readonly string[];
}): string[] {
const onlyPluginIdSet = createPluginIdScopeSet(normalizePluginIdScope(params.onlyPluginIds));
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));
return resolveManifestActivationPlan(params).pluginIds;
}
function matchesManifestActivationTrigger(
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]] : [],
),
),
};
}
type ManifestActivationMatch = {
reason: string;
compatReason?: PluginCompatReason;
};
function matchManifestActivationTrigger(
plugin: PluginManifestRecord,
trigger: PluginActivationPlannerTrigger,
): boolean {
): ManifestActivationMatch | null {
switch (trigger.kind) {
case "command":
return listActivationCommandIds(plugin).includes(normalizeCommandId(trigger.command));
return matchActivationCommand(plugin, trigger.command);
case "provider":
return listActivationProviderIds(plugin).includes(normalizeProviderId(trigger.provider));
return matchActivationProvider(plugin, trigger.provider);
case "agentHarness":
return listActivationAgentHarnessIds(plugin).includes(normalizeCommandId(trigger.runtime));
return matchLegacyActivationList(
listActivationAgentHarnessIds(plugin),
normalizeCommandId(trigger.runtime),
"agent-harness",
);
case "channel":
return listActivationChannelIds(plugin).includes(normalizeCommandId(trigger.channel));
return matchActivationChannel(plugin, trigger.channel);
case "route":
return listActivationRouteIds(plugin).includes(normalizeCommandId(trigger.route));
return matchLegacyActivationList(
listActivationRouteIds(plugin),
normalizeCommandId(trigger.route),
"route",
);
case "capability":
return hasActivationCapability(plugin, trigger.capability);
return matchActivationCapability(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 listActivationCommandIds(plugin: PluginManifestRecord): string[] {
return [
...(plugin.activation?.onCommands ?? []),
...(plugin.commandAliases ?? []).flatMap((alias) => alias.cliCommand ?? alias.name),
]
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)
.map(normalizeCommandId)
.filter(Boolean);
if (commandAliases.includes(normalizedCommand)) {
return { reason: `command:${normalizedCommand}` };
}
return matchLegacyActivationList(
(plugin.activation?.onCommands ?? []).map(normalizeCommandId).filter(Boolean),
normalizedCommand,
"command",
);
}
function listActivationProviderIds(plugin: PluginManifestRecord): string[] {
return [
...(plugin.activation?.onProviders ?? []),
function matchActivationProvider(
plugin: PluginManifestRecord,
provider: string,
): ManifestActivationMatch | null {
const normalizedProvider = normalizeProviderId(provider);
if (!normalizedProvider) {
return null;
}
const stableProviderIds = [
...plugin.providers,
...(plugin.setup?.providers?.map((provider) => provider.id) ?? []),
...(plugin.setup?.providers?.map((entry) => entry.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 listActivationChannelIds(plugin: PluginManifestRecord): string[] {
return [...(plugin.activation?.onChannels ?? []), ...plugin.channels]
.map(normalizeCommandId)
.filter(Boolean);
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 listActivationRouteIds(plugin: PluginManifestRecord): string[] {
return (plugin.activation?.onRoutes ?? []).map(normalizeCommandId).filter(Boolean);
}
function hasActivationCapability(
function matchActivationCapability(
plugin: PluginManifestRecord,
capability: PluginManifestActivationCapability,
): boolean {
if (plugin.activation?.onCapabilities?.includes(capability)) {
return true;
}
): ManifestActivationMatch | null {
switch (capability) {
case "provider":
return listActivationProviderIds(plugin).length > 0;
case "provider": {
const hasProviderOwnership =
plugin.providers.length > 0 || (plugin.setup?.providers?.length ?? 0) > 0;
if (hasProviderOwnership) {
return { reason: "capability:provider" };
}
break;
}
case "channel":
return listActivationChannelIds(plugin).length > 0;
if (plugin.channels.length > 0) {
return { reason: "capability:channel" };
}
break;
case "tool":
return (plugin.contracts?.tools?.length ?? 0) > 0;
if ((plugin.contracts?.tools?.length ?? 0) > 0) {
return { reason: "capability:tool" };
}
break;
case "hook":
return plugin.hooks.length > 0;
if (plugin.hooks.length > 0) {
return { reason: "capability:hook" };
}
break;
}
const unreachableCapability: never = capability;
return unreachableCapability;
if (plugin.activation?.onCapabilities?.includes(capability)) {
return {
reason: `capability:${capability}`,
compatReason: PLUGIN_COMPAT_REASON.legacyActivationField,
};
}
return null;
}
function normalizeCommandId(value: string | undefined): string {

View File

@@ -0,0 +1,10 @@
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];

View File

@@ -57,19 +57,19 @@ export type PluginManifestActivationCapability = "provider" | "channel" | "tool"
export type PluginManifestActivation = {
/**
* Provider ids that should activate this plugin when explicitly requested.
* This is metadata only; runtime loading still happens through the loader.
* Legacy provider activation hints. Prefer top-level `providers` and setup
* provider ownership for new manifests.
*/
onProviders?: string[];
/** Agent harness runtime ids that should activate this plugin. */
/** Legacy agent harness runtime activation hints. */
onAgentHarnesses?: string[];
/** Command ids that should activate this plugin. */
/** Legacy command activation hints. Prefer command aliases or CLI descriptors. */
onCommands?: string[];
/** Channel ids that should activate this plugin. */
/** Legacy channel activation hints. Prefer top-level `channels`. */
onChannels?: string[];
/** Route kinds that should activate this plugin. */
/** Legacy route activation hints. */
onRoutes?: string[];
/** Cheap capability hints used by future activation planning. */
/** Legacy broad capability hints. Do not add new uses. */
onCapabilities?: PluginManifestActivationCapability[];
};