diff --git a/docs/plugins/architecture.md b/docs/plugins/architecture.md index 551f53cc013..f833e591983 100644 --- a/docs/plugins/architecture.md +++ b/docs/plugins/architecture.md @@ -519,10 +519,15 @@ The manifest is the control-plane source of truth. OpenClaw uses it to: - validate `plugins.entries..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: diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 3a7ac52c8a0..d6818dc9bda 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -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` | 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` | 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` | 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. diff --git a/src/plugins/manifest-registry.test.ts b/src/plugins/manifest-registry.test.ts index 9eaf9a10f32..a2497b94860 100644 --- a/src/plugins/manifest-registry.test.ts +++ b/src/plugins/manifest-registry.test.ts @@ -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, { diff --git a/src/plugins/manifest-registry.ts b/src/plugins/manifest-registry.ts index 421871880c7..d0e2faba8b0 100644 --- a/src/plugins/manifest-registry.ts +++ b/src/plugins/manifest-registry.ts @@ -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; channelEnvVars?: Record; 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: [], diff --git a/src/plugins/manifest.json5-tolerance.test.ts b/src/plugins/manifest.json5-tolerance.test.ts index 8de052c10cb..3112a231306 100644 --- a/src/plugins/manifest.json5-tolerance.test.ts +++ b/src/plugins/manifest.json5-tolerance.test.ts @@ -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"); diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 68e293240b6..c034cd52ffd 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -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,