mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-12 01:31:08 +00:00
feat(plugins): add manifest activation and setup descriptors (#64780)
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user