feat(plugins): add manifest activation and setup descriptors (#64780)

This commit is contained in:
Vincent Koc
2026-04-11 12:35:59 +01:00
committed by GitHub
parent d7479dc61a
commit 79c3dbecd1
6 changed files with 311 additions and 0 deletions

View File

@@ -519,10 +519,15 @@ The manifest is the control-plane source of truth. OpenClaw uses it to:
- validate `plugins.entries.<id>.config`
- augment Control UI labels/placeholders
- show install/catalog metadata
- preserve cheap activation and setup descriptors without loading plugin runtime
For native plugins, the runtime module is the data-plane part. It registers
actual behavior such as hooks, tools, commands, or provider flows.
Optional manifest `activation` and `setup` blocks stay on the control plane.
They are metadata-only descriptors for activation planning and setup discovery;
they do not replace runtime registration, `register(...)`, or `setupEntry`.
### What the loader caches
OpenClaw keeps short in-process caches for:

View File

@@ -47,6 +47,10 @@ Use it for:
- config validation
- auth and onboarding metadata that should be available without booting plugin
runtime
- cheap activation hints that control-plane surfaces can inspect before runtime
loads
- cheap setup descriptors that setup/onboarding surfaces can inspect before
runtime loads
- alias and auto-enable metadata that should resolve before plugin runtime loads
- shorthand model-family ownership metadata that should auto-activate the
plugin before runtime loads
@@ -152,6 +156,8 @@ Those belong in your plugin code and `package.json`.
| `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 hints for 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. |
| `contracts` | No | `object` | Static bundled capability snapshot for speech, realtime transcription, realtime voice, media-understanding, image-generation, music-generation, video-generation, web-fetch, web search, and tool ownership. |
| `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. |
@@ -208,6 +214,77 @@ uses this metadata for diagnostics without importing plugin runtime code.
| `kind` | No | `"runtime-slash"` | Marks the alias as a chat slash command rather than a root CLI command. |
| `cliCommand` | No | `string` | Related root CLI command to suggest for CLI operations, if one exists. |
## activation reference
Use `activation` when the plugin can cheaply declare which control-plane events
should activate it later.
This block is metadata only. It does not register runtime behavior, and it does
not replace `register(...)`, `setupEntry`, or other runtime/plugin entrypoints.
```json
{
"activation": {
"onProviders": ["openai"],
"onCommands": ["models"],
"onChannels": ["web"],
"onRoutes": ["gateway-webhook"],
"onCapabilities": ["provider", "tool"]
}
}
```
| 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. |
## setup reference
Use `setup` when setup and onboarding surfaces need cheap plugin-owned metadata
before runtime loads.
```json
{
"setup": {
"providers": [
{
"id": "openai",
"authMethods": ["api-key"],
"envVars": ["OPENAI_API_KEY"]
}
],
"cliBackends": ["openai-cli"],
"configMigrations": ["legacy-openai-auth"],
"requiresRuntime": false
}
}
```
Top-level `cliBackends` stays valid and continues to describe CLI inference
backends. `setup.cliBackends` is the setup-specific descriptor surface for
control-plane/setup flows that should stay metadata-only.
### setup.providers reference
| Field | Required | Type | What it means |
| ------------- | -------- | ---------- | ---------------------------------------------------------------------------------- |
| `id` | Yes | `string` | Provider id exposed during setup or onboarding. |
| `authMethods` | No | `string[]` | Setup/auth method ids this provider supports without loading full runtime. |
| `envVars` | No | `string[]` | Env vars that generic setup/status surfaces can check before plugin runtime loads. |
### setup fields
| Field | Required | Type | What it means |
| ------------------ | -------- | ---------- | --------------------------------------------------------------------------- |
| `providers` | No | `object[]` | Provider setup descriptors exposed during setup and onboarding. |
| `cliBackends` | No | `string[]` | Setup-time backend ids available without full runtime activation. |
| `configMigrations` | No | `string[]` | Config migration ids owned by this plugin's setup surface. |
| `requiresRuntime` | No | `boolean` | Whether setup still needs plugin runtime execution after descriptor lookup. |
## uiHints reference
`uiHints` is a map from config field names to small rendering hints.

View File

@@ -423,6 +423,60 @@ describe("loadPluginManifestRegistry", () => {
]);
});
it("preserves activation and setup descriptors from plugin manifests", () => {
const dir = makeTempDir();
writeManifest(dir, {
id: "openai",
providers: ["openai"],
activation: {
onProviders: ["openai"],
onCommands: ["models"],
onChannels: ["web"],
onRoutes: ["gateway-webhook"],
onCapabilities: ["provider", "tool"],
},
setup: {
providers: [
{
id: "openai",
authMethods: ["api-key"],
envVars: ["OPENAI_API_KEY"],
},
],
cliBackends: ["openai-cli"],
configMigrations: ["legacy-openai-auth"],
requiresRuntime: false,
},
configSchema: { type: "object" },
});
const registry = loadSingleCandidateRegistry({
idHint: "openai",
rootDir: dir,
origin: "bundled",
});
expect(registry.plugins[0]?.activation).toEqual({
onProviders: ["openai"],
onCommands: ["models"],
onChannels: ["web"],
onRoutes: ["gateway-webhook"],
onCapabilities: ["provider", "tool"],
});
expect(registry.plugins[0]?.setup).toEqual({
providers: [
{
id: "openai",
authMethods: ["api-key"],
envVars: ["OPENAI_API_KEY"],
},
],
cliBackends: ["openai-cli"],
configMigrations: ["legacy-openai-auth"],
requiresRuntime: false,
});
});
it("preserves channel env metadata from plugin manifests", () => {
const dir = makeTempDir();
writeManifest(dir, {

View File

@@ -18,11 +18,13 @@ import type { PluginManifestCommandAlias } from "./manifest-command-aliases.js";
import {
loadPluginManifest,
type OpenClawPackageManifest,
type PluginManifestActivation,
type PluginManifestConfigContracts,
type PluginManifest,
type PluginManifestChannelConfig,
type PluginManifestContracts,
type PluginManifestModelSupport,
type PluginManifestSetup,
} from "./manifest.js";
import { checkMinHostVersion } from "./min-host-version.js";
import { isPathInside, safeRealpathSync } from "./path-safety.js";
@@ -84,6 +86,8 @@ export type PluginManifestRecord = {
providerAuthAliases?: Record<string, string>;
channelEnvVars?: Record<string, string[]>;
providerAuthChoices?: PluginManifest["providerAuthChoices"];
activation?: PluginManifestActivation;
setup?: PluginManifestSetup;
skills: string[];
settingsFiles?: string[];
hooks: string[];
@@ -322,6 +326,8 @@ function buildRecord(params: {
providerAuthAliases: params.manifest.providerAuthAliases,
channelEnvVars: params.manifest.channelEnvVars,
providerAuthChoices: params.manifest.providerAuthChoices,
activation: params.manifest.activation,
setup: params.manifest.setup,
skills: params.manifest.skills ?? [],
settingsFiles: [],
hooks: [],

View File

@@ -102,6 +102,54 @@ describe("loadPluginManifest JSON5 tolerance", () => {
}
});
it("normalizes activation and setup descriptor metadata from the manifest", () => {
const dir = makeTempDir();
const json5Content = `{
id: "openai",
activation: {
onProviders: ["openai", "", "openai-codex"],
onCommands: ["models", ""],
onChannels: ["web", ""],
onRoutes: ["gateway-webhook", ""],
onCapabilities: ["provider", "tool", "wat"]
},
setup: {
providers: [
{ id: "openai", authMethods: ["api-key", ""], envVars: ["OPENAI_API_KEY", ""] },
{ id: "", authMethods: ["oauth"] }
],
cliBackends: ["openai-cli", ""],
configMigrations: ["legacy-openai-auth", ""],
requiresRuntime: false
},
configSchema: { type: "object" }
}`;
fs.writeFileSync(path.join(dir, "openclaw.plugin.json"), json5Content, "utf-8");
const result = loadPluginManifest(dir, false);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.manifest.activation).toEqual({
onProviders: ["openai", "openai-codex"],
onCommands: ["models"],
onChannels: ["web"],
onRoutes: ["gateway-webhook"],
onCapabilities: ["provider", "tool"],
});
expect(result.manifest.setup).toEqual({
providers: [
{
id: "openai",
authMethods: ["api-key"],
envVars: ["OPENAI_API_KEY"],
},
],
cliBackends: ["openai-cli"],
configMigrations: ["legacy-openai-auth"],
requiresRuntime: false,
});
}
});
it("still rejects completely invalid syntax", () => {
const dir = makeTempDir();
fs.writeFileSync(path.join(dir, "openclaw.plugin.json"), "not json at all {{{}}", "utf-8");

View File

@@ -38,6 +38,47 @@ export type PluginManifestModelSupport = {
modelPatterns?: string[];
};
export type PluginManifestActivationCapability = "provider" | "channel" | "tool" | "hook";
export type PluginManifestActivation = {
/**
* Provider ids that should activate this plugin when explicitly requested.
* This is metadata only; runtime loading still happens through the loader.
*/
onProviders?: string[];
/** Command ids that should activate this plugin. */
onCommands?: string[];
/** Channel ids that should activate this plugin. */
onChannels?: string[];
/** Route kinds that should activate this plugin. */
onRoutes?: string[];
/** Cheap capability hints used by future activation planning. */
onCapabilities?: PluginManifestActivationCapability[];
};
export type PluginManifestSetupProvider = {
/** Provider id surfaced during setup/onboarding. */
id: string;
/** Setup/auth methods that this provider supports. */
authMethods?: string[];
/** Environment variables that can satisfy setup without runtime loading. */
envVars?: string[];
};
export type PluginManifestSetup = {
/** Cheap provider setup metadata exposed before runtime loads. */
providers?: PluginManifestSetupProvider[];
/** Setup-time backend ids available without full runtime activation. */
cliBackends?: string[];
/** Config migration ids owned by this plugin's setup surface. */
configMigrations?: string[];
/**
* Whether setup still needs plugin runtime execution after descriptor lookup.
* Defaults to false when omitted.
*/
requiresRuntime?: boolean;
};
export type PluginManifestConfigLiteral = string | number | boolean | null;
export type PluginManifestDangerousConfigFlag = {
@@ -128,6 +169,10 @@ export type PluginManifest = {
* and non-runtime auth-choice routing before provider runtime loads.
*/
providerAuthChoices?: PluginManifestProviderAuthChoice[];
/** Cheap activation hints exposed before plugin runtime loads. */
activation?: PluginManifestActivation;
/** Cheap setup/onboarding metadata exposed before plugin runtime loads. */
setup?: PluginManifestSetup;
skills?: string[];
name?: string;
description?: string;
@@ -366,6 +411,78 @@ function normalizeManifestModelSupport(value: unknown): PluginManifestModelSuppo
return Object.keys(modelSupport).length > 0 ? modelSupport : undefined;
}
function normalizeManifestActivation(value: unknown): PluginManifestActivation | undefined {
if (!isRecord(value)) {
return undefined;
}
const onProviders = normalizeTrimmedStringList(value.onProviders);
const onCommands = normalizeTrimmedStringList(value.onCommands);
const onChannels = normalizeTrimmedStringList(value.onChannels);
const onRoutes = normalizeTrimmedStringList(value.onRoutes);
const onCapabilities = normalizeTrimmedStringList(value.onCapabilities).filter(
(capability): capability is PluginManifestActivationCapability =>
capability === "provider" ||
capability === "channel" ||
capability === "tool" ||
capability === "hook",
);
const activation = {
...(onProviders.length > 0 ? { onProviders } : {}),
...(onCommands.length > 0 ? { onCommands } : {}),
...(onChannels.length > 0 ? { onChannels } : {}),
...(onRoutes.length > 0 ? { onRoutes } : {}),
...(onCapabilities.length > 0 ? { onCapabilities } : {}),
} satisfies PluginManifestActivation;
return Object.keys(activation).length > 0 ? activation : undefined;
}
function normalizeManifestSetupProviders(
value: unknown,
): PluginManifestSetupProvider[] | undefined {
if (!Array.isArray(value)) {
return undefined;
}
const normalized: PluginManifestSetupProvider[] = [];
for (const entry of value) {
if (!isRecord(entry)) {
continue;
}
const id = normalizeOptionalString(entry.id) ?? "";
if (!id) {
continue;
}
const authMethods = normalizeTrimmedStringList(entry.authMethods);
const envVars = normalizeTrimmedStringList(entry.envVars);
normalized.push({
id,
...(authMethods.length > 0 ? { authMethods } : {}),
...(envVars.length > 0 ? { envVars } : {}),
});
}
return normalized.length > 0 ? normalized : undefined;
}
function normalizeManifestSetup(value: unknown): PluginManifestSetup | undefined {
if (!isRecord(value)) {
return undefined;
}
const providers = normalizeManifestSetupProviders(value.providers);
const cliBackends = normalizeTrimmedStringList(value.cliBackends);
const configMigrations = normalizeTrimmedStringList(value.configMigrations);
const requiresRuntime =
typeof value.requiresRuntime === "boolean" ? value.requiresRuntime : undefined;
const setup = {
...(providers ? { providers } : {}),
...(cliBackends.length > 0 ? { cliBackends } : {}),
...(configMigrations.length > 0 ? { configMigrations } : {}),
...(requiresRuntime !== undefined ? { requiresRuntime } : {}),
} satisfies PluginManifestSetup;
return Object.keys(setup).length > 0 ? setup : undefined;
}
function normalizeProviderAuthChoices(
value: unknown,
): PluginManifestProviderAuthChoice[] | undefined {
@@ -553,6 +670,8 @@ export function loadPluginManifest(
const providerAuthAliases = normalizeStringRecord(raw.providerAuthAliases);
const channelEnvVars = normalizeStringListRecord(raw.channelEnvVars);
const providerAuthChoices = normalizeProviderAuthChoices(raw.providerAuthChoices);
const activation = normalizeManifestActivation(raw.activation);
const setup = normalizeManifestSetup(raw.setup);
const skills = normalizeTrimmedStringList(raw.skills);
const contracts = normalizeManifestContracts(raw.contracts);
const configContracts = normalizeManifestConfigContracts(raw.configContracts);
@@ -584,6 +703,8 @@ export function loadPluginManifest(
providerAuthAliases,
channelEnvVars,
providerAuthChoices,
activation,
setup,
skills,
name,
description,