From e5dac0c39e9171be29af4a9ea2df97276a672ba0 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 29 Mar 2026 18:49:57 -0400 Subject: [PATCH] CLI: keep root help plugin descriptors non-activating (#57294) Merged via squash. Prepared head SHA: c8da48f689080b2991670df90f95ce856e112d97 Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com> Reviewed-by: @gumadeiras --- CHANGELOG.md | 1 + .../2026-03-29-root-help-cli-descriptors.md | 46 +++ docs/plugins/sdk-channel-plugins.md | 12 +- docs/plugins/sdk-entrypoints.md | 46 ++- docs/plugins/sdk-overview.md | 28 +- docs/plugins/sdk-runtime.md | 18 +- extensions/matrix/index.test.ts | 24 ++ extensions/matrix/index.ts | 33 +- src/cli/program/root-help.test.ts | 6 +- src/cli/program/root-help.ts | 12 +- src/cli/run-main.ts | 2 +- src/entry.test.ts | 3 +- src/entry.ts | 12 +- src/plugin-sdk/core.ts | 7 + src/plugins/captured-registration.ts | 43 ++- src/plugins/cli.test.ts | 83 +++- src/plugins/cli.ts | 61 ++- src/plugins/loader.test.ts | 356 +++++++++++++++++ src/plugins/loader.ts | 357 ++++++++++++++++-- src/plugins/registry.ts | 4 +- src/plugins/types.ts | 2 +- 21 files changed, 1031 insertions(+), 125 deletions(-) create mode 100644 docs/internal/gumadeiras/2026-03-29-root-help-cli-descriptors.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d634dd47bd..dc3b896fddd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Docs: https://docs.openclaw.ai - iOS/Live Activities: mark the `ActivityKit` import in `LiveActivityManager.swift` as `@preconcurrency` so Xcode 26.4 / Swift 6 builds stop failing on strict concurrency checks. (#57180) Thanks @ngutman. - Plugins/Matrix: mirror the Matrix crypto WASM runtime dependency into the root packaged install and enforce root/plugin dependency parity so bundled Matrix E2EE crypto resolves correctly in shipped builds. (#57163) Thanks @gumadeiras. - Plugins/CLI: add descriptor-backed lazy plugin CLI registration so Matrix can keep its CLI module lazy-loaded without dropping `openclaw matrix ...` from parse-time command registration. (#57165) Thanks @gumadeiras. +- Plugins/CLI: collect root-help plugin descriptors through a dedicated non-activating CLI metadata path so enabled plugins keep validated config semantics without triggering runtime-only plugin registration work, while preserving runtime CLI command registration for legacy channel plugins that still wire commands from full registration. (#57294) thanks @gumadeiras. ## 2026.3.28 diff --git a/docs/internal/gumadeiras/2026-03-29-root-help-cli-descriptors.md b/docs/internal/gumadeiras/2026-03-29-root-help-cli-descriptors.md new file mode 100644 index 00000000000..c5dd585217f --- /dev/null +++ b/docs/internal/gumadeiras/2026-03-29-root-help-cli-descriptors.md @@ -0,0 +1,46 @@ +--- +title: "Root Help CLI Descriptor Loader Note" +summary: "Collect root-help plugin CLI descriptors through a dedicated non-activating loader path with validated config, awaited registration, and plugin-owned channel metadata." +author: "Gustavo Madeira Santana" +github_username: "gumadeiras" +created: "2026-03-29" +status: "implemented" +--- + +This note covers the final implementation on PR #57294 after review found two remaining gaps in the earlier branch state: + +- root help still depended on an activating plugin loader path +- async `register()` implementations were still ignored during descriptor capture + +Decision: + +- Root help should be non-activating, not semantically different. +- That means `openclaw --help` should keep loader semantics for enable-state, per-plugin config, duplicate precedence, config validation, and memory-slot gating. +- Help should use a dedicated async CLI metadata collector instead of piggybacking on the general activating registry loader. +- Channel plugins should keep ownership of their own root-help metadata wherever possible. + +Implementation shape: + +- Add `loadOpenClawPluginCliRegistry()` in `src/plugins/loader.ts`. +- The collector reuses plugin discovery, manifest loading, duplicate precedence, enable-state resolution, config validation, and memory-slot gating. +- The collector always runs with `activate: false` and `cache: false`. +- The collector awaits `register(api)` so async plugin registration contributes CLI metadata. +- The collector only exposes `registerCli(...)` to plugin code; it does not activate services, tools, providers, or gateway handlers. +- `getPluginCliCommandDescriptors()` and root-help rendering are now async and route through the dedicated collector. +- `defineChannelPluginEntry(...)` gained an additive `registerCliMetadata(api)` seam so channel plugins can register root-help metadata without entering `registerFull(...)`, while full loads still collect the same CLI descriptors. +- `extensions/matrix/index.ts` moved its CLI descriptor registration onto that seam. +- `defineChannelPluginEntry(...)` now skips `setRuntime(...)` in `cli-metadata` mode so help rendering does not poison channel runtime stores with a fake runtime object. +- `registerPluginCliCommands()` still uses the normal full plugin loader so legacy channel plugins that wired CLI commands inside `registerFull(...)` keep working until they adopt `registerCliMetadata(...)`. + +Why this replaced the earlier approach: + +- The original manual import loop in `src/plugins/cli.ts` dropped `api.pluginConfig`, which broke config-dependent CLI plugins. +- The intermediate loader-flag approach still tied descriptor capture to the sync general loader path and left async `register()` unsupported. +- The dedicated collector keeps the special behavior narrow and explicit instead of broadening the general loader contract further. + +Regression coverage added: + +- A loader test that proves CLI metadata loads still receive validated `pluginConfig`. +- A loader test that proves channel CLI metadata capture uses the real channel entry, reports `registrationMode: "cli-metadata"`, and does not load `setupEntry`. +- A loader test that proves async plugin `register()` contributes CLI descriptors during metadata collection. +- A loader test that proves `cli-metadata` mode does not call `setRuntime(...)` for channel plugins. diff --git a/docs/plugins/sdk-channel-plugins.md b/docs/plugins/sdk-channel-plugins.md index 794eca94642..53ff0e11b49 100644 --- a/docs/plugins/sdk-channel-plugins.md +++ b/docs/plugins/sdk-channel-plugins.md @@ -214,7 +214,7 @@ dispatch. name: "Acme Chat", description: "Acme Chat channel plugin", plugin: acmeChatPlugin, - registerFull(api) { + registerCliMetadata(api) { api.registerCli( ({ program }) => { program @@ -232,11 +232,17 @@ dispatch. }, ); }, + registerFull(api) { + api.registerGatewayMethod(/* ... */); + }, }); ``` - `defineChannelPluginEntry` handles the setup/full registration split - automatically. See + Put channel-owned CLI descriptors in `registerCliMetadata(...)` so OpenClaw + can show them in root help without activating the full channel runtime, + while normal full loads still pick up the same descriptors for real command + registration. Keep `registerFull(...)` for runtime-only work. + `defineChannelPluginEntry` handles the registration-mode split automatically. See [Entry Points](/plugins/sdk-entrypoints#definechannelpluginentry) for all options. diff --git a/docs/plugins/sdk-entrypoints.md b/docs/plugins/sdk-entrypoints.md index dd7471a50a4..fda04585e56 100644 --- a/docs/plugins/sdk-entrypoints.md +++ b/docs/plugins/sdk-entrypoints.md @@ -4,7 +4,7 @@ sidebarTitle: "Entry Points" summary: "Reference for definePluginEntry, defineChannelPluginEntry, and defineSetupPluginEntry" read_when: - You need the exact type signature of definePluginEntry or defineChannelPluginEntry - - You want to understand registration mode (full vs setup) + - You want to understand registration mode (full vs setup vs CLI metadata) - You are looking up entry point options --- @@ -61,7 +61,8 @@ export default definePluginEntry({ **Import:** `openclaw/plugin-sdk/core` Wraps `definePluginEntry` with channel-specific wiring. Automatically calls -`api.registerChannel({ plugin })` and gates `registerFull` on registration mode. +`api.registerChannel({ plugin })`, exposes an optional root-help CLI metadata +seam, and gates `registerFull` on registration mode. ```typescript import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; @@ -72,30 +73,40 @@ export default defineChannelPluginEntry({ description: "Short summary", plugin: myChannelPlugin, setRuntime: setMyRuntime, - registerFull(api) { + registerCliMetadata(api) { api.registerCli(/* ... */); + }, + registerFull(api) { api.registerGatewayMethod(/* ... */); }, }); ``` -| Field | Type | Required | Default | -| -------------- | ---------------------------------------------------------------- | -------- | ------------------- | -| `id` | `string` | Yes | — | -| `name` | `string` | Yes | — | -| `description` | `string` | Yes | — | -| `plugin` | `ChannelPlugin` | Yes | — | -| `configSchema` | `OpenClawPluginConfigSchema \| () => OpenClawPluginConfigSchema` | No | Empty object schema | -| `setRuntime` | `(runtime: PluginRuntime) => void` | No | — | -| `registerFull` | `(api: OpenClawPluginApi) => void` | No | — | +| Field | Type | Required | Default | +| --------------------- | ---------------------------------------------------------------- | -------- | ------------------- | +| `id` | `string` | Yes | — | +| `name` | `string` | Yes | — | +| `description` | `string` | Yes | — | +| `plugin` | `ChannelPlugin` | Yes | — | +| `configSchema` | `OpenClawPluginConfigSchema \| () => OpenClawPluginConfigSchema` | No | Empty object schema | +| `setRuntime` | `(runtime: PluginRuntime) => void` | No | — | +| `registerCliMetadata` | `(api: OpenClawPluginApi) => void` | No | — | +| `registerFull` | `(api: OpenClawPluginApi) => void` | No | — | - `setRuntime` is called during registration so you can store the runtime reference - (typically via `createPluginRuntimeStore`). + (typically via `createPluginRuntimeStore`). It is skipped during CLI metadata + capture. +- `registerCliMetadata` runs during both `api.registrationMode === "cli-metadata"` + and `api.registrationMode === "full"`. + Use it as the canonical place for channel-owned CLI descriptors so root help + stays non-activating while normal CLI command registration remains compatible + with full plugin loads. - `registerFull` only runs when `api.registrationMode === "full"`. It is skipped during setup-only loading. - For plugin-owned root CLI commands, prefer `api.registerCli(..., { descriptors: [...] })` when you want the command to stay lazy-loaded without disappearing from the - root CLI parse tree. + root CLI parse tree. For channel plugins, prefer registering those descriptors + from `registerCliMetadata(...)` and keep `registerFull(...)` focused on runtime-only work. ## `defineSetupPluginEntry` @@ -123,17 +134,22 @@ unconfigured, or when deferred loading is enabled. See | `"full"` | Normal gateway startup | Everything | | `"setup-only"` | Disabled/unconfigured channel | Channel registration only | | `"setup-runtime"` | Setup flow with runtime available | Channel + lightweight runtime | +| `"cli-metadata"` | Root help / CLI metadata capture | CLI descriptors only | `defineChannelPluginEntry` handles this split automatically. If you use `definePluginEntry` directly for a channel, check mode yourself: ```typescript register(api) { + if (api.registrationMode === "cli-metadata" || api.registrationMode === "full") { + api.registerCli(/* ... */); + if (api.registrationMode === "cli-metadata") return; + } + api.registerChannel({ plugin: myPlugin }); if (api.registrationMode !== "full") return; // Heavy runtime-only registrations - api.registerCli(/* ... */); api.registerService(/* ... */); } ``` diff --git a/docs/plugins/sdk-overview.md b/docs/plugins/sdk-overview.md index eb5f05c3c59..4e05ef942cf 100644 --- a/docs/plugins/sdk-overview.md +++ b/docs/plugins/sdk-overview.md @@ -237,20 +237,20 @@ AI CLI backend such as `claude-cli` or `codex-cli`. ### API object fields -| Field | Type | Description | -| ------------------------ | ------------------------- | --------------------------------------------------------- | -| `api.id` | `string` | Plugin id | -| `api.name` | `string` | Display name | -| `api.version` | `string?` | Plugin version (optional) | -| `api.description` | `string?` | Plugin description (optional) | -| `api.source` | `string` | Plugin source path | -| `api.rootDir` | `string?` | Plugin root directory (optional) | -| `api.config` | `OpenClawConfig` | Current config snapshot | -| `api.pluginConfig` | `Record` | Plugin-specific config from `plugins.entries..config` | -| `api.runtime` | `PluginRuntime` | [Runtime helpers](/plugins/sdk-runtime) | -| `api.logger` | `PluginLogger` | Scoped logger (`debug`, `info`, `warn`, `error`) | -| `api.registrationMode` | `PluginRegistrationMode` | `"full"`, `"setup-only"`, or `"setup-runtime"` | -| `api.resolvePath(input)` | `(string) => string` | Resolve path relative to plugin root | +| Field | Type | Description | +| ------------------------ | ------------------------- | ---------------------------------------------------------------- | +| `api.id` | `string` | Plugin id | +| `api.name` | `string` | Display name | +| `api.version` | `string?` | Plugin version (optional) | +| `api.description` | `string?` | Plugin description (optional) | +| `api.source` | `string` | Plugin source path | +| `api.rootDir` | `string?` | Plugin root directory (optional) | +| `api.config` | `OpenClawConfig` | Current config snapshot | +| `api.pluginConfig` | `Record` | Plugin-specific config from `plugins.entries..config` | +| `api.runtime` | `PluginRuntime` | [Runtime helpers](/plugins/sdk-runtime) | +| `api.logger` | `PluginLogger` | Scoped logger (`debug`, `info`, `warn`, `error`) | +| `api.registrationMode` | `PluginRegistrationMode` | `"full"`, `"setup-only"`, `"setup-runtime"`, or `"cli-metadata"` | +| `api.resolvePath(input)` | `(string) => string` | Resolve path relative to plugin root | ## Internal module convention diff --git a/docs/plugins/sdk-runtime.md b/docs/plugins/sdk-runtime.md index b8c6081cba6..607b6d74101 100644 --- a/docs/plugins/sdk-runtime.md +++ b/docs/plugins/sdk-runtime.md @@ -330,15 +330,15 @@ export function tryGetRuntime() { Beyond `api.runtime`, the API object also provides: -| Field | Type | Description | -| ------------------------ | ------------------------- | --------------------------------------------------------- | -| `api.id` | `string` | Plugin id | -| `api.name` | `string` | Plugin display name | -| `api.config` | `OpenClawConfig` | Current config snapshot | -| `api.pluginConfig` | `Record` | Plugin-specific config from `plugins.entries..config` | -| `api.logger` | `PluginLogger` | Scoped logger (`debug`, `info`, `warn`, `error`) | -| `api.registrationMode` | `PluginRegistrationMode` | `"full"`, `"setup-only"`, or `"setup-runtime"` | -| `api.resolvePath(input)` | `(string) => string` | Resolve a path relative to the plugin root | +| Field | Type | Description | +| ------------------------ | ------------------------- | ---------------------------------------------------------------- | +| `api.id` | `string` | Plugin id | +| `api.name` | `string` | Plugin display name | +| `api.config` | `OpenClawConfig` | Current config snapshot | +| `api.pluginConfig` | `Record` | Plugin-specific config from `plugins.entries..config` | +| `api.logger` | `PluginLogger` | Scoped logger (`debug`, `info`, `warn`, `error`) | +| `api.registrationMode` | `PluginRegistrationMode` | `"full"`, `"setup-only"`, `"setup-runtime"`, or `"cli-metadata"` | +| `api.resolvePath(input)` | `(string) => string` | Resolve a path relative to the plugin root | ## Related diff --git a/extensions/matrix/index.test.ts b/extensions/matrix/index.test.ts index 0c28643a901..eb846e189a0 100644 --- a/extensions/matrix/index.test.ts +++ b/extensions/matrix/index.test.ts @@ -18,13 +18,16 @@ import matrixPlugin from "./index.js"; describe("matrix plugin", () => { it("registers matrix CLI through a descriptor-backed lazy registrar", async () => { const registerCli = vi.fn(); + const registerGatewayMethod = vi.fn(); const api = createTestPluginApi({ id: "matrix", name: "Matrix", source: "test", config: {}, runtime: {} as never, + registrationMode: "cli-metadata", registerCli, + registerGatewayMethod, }); matrixPlugin.register(api); @@ -47,5 +50,26 @@ describe("matrix plugin", () => { await result; expect(cliMocks.registerMatrixCli).toHaveBeenCalledWith({ program }); + expect(registerGatewayMethod).not.toHaveBeenCalled(); + }); + + it("keeps runtime bootstrap and CLI metadata out of setup-only registration", () => { + const registerCli = vi.fn(); + const registerGatewayMethod = vi.fn(); + const api = createTestPluginApi({ + id: "matrix", + name: "Matrix", + source: "test", + config: {}, + runtime: {} as never, + registrationMode: "setup-only", + registerCli, + registerGatewayMethod, + }); + + matrixPlugin.register(api); + + expect(registerCli).not.toHaveBeenCalled(); + expect(registerGatewayMethod).not.toHaveBeenCalled(); }); }); diff --git a/extensions/matrix/index.ts b/extensions/matrix/index.ts index 776ed1ef2a0..fdbeaa2bc61 100644 --- a/extensions/matrix/index.ts +++ b/extensions/matrix/index.ts @@ -11,6 +11,23 @@ export default defineChannelPluginEntry({ description: "Matrix channel plugin (matrix-js-sdk)", plugin: matrixPlugin, setRuntime: setMatrixRuntime, + registerCliMetadata(api) { + api.registerCli( + async ({ program }) => { + const { registerMatrixCli } = await import("./src/cli.js"); + registerMatrixCli({ program }); + }, + { + descriptors: [ + { + name: "matrix", + description: "Manage Matrix accounts, verification, devices, and profile state", + hasSubcommands: true, + }, + ], + }, + ); + }, registerFull(api) { void import("./src/plugin-entry.runtime.js") .then(({ ensureMatrixCryptoRuntime }) => @@ -38,21 +55,5 @@ export default defineChannelPluginEntry({ const { handleVerificationStatus } = await import("./src/plugin-entry.runtime.js"); await handleVerificationStatus(ctx); }); - - api.registerCli( - async ({ program }) => { - const { registerMatrixCli } = await import("./src/cli.js"); - registerMatrixCli({ program }); - }, - { - descriptors: [ - { - name: "matrix", - description: "Manage Matrix accounts, verification, devices, and profile state", - hasSubcommands: true, - }, - ], - }, - ); }, }); diff --git a/src/cli/program/root-help.test.ts b/src/cli/program/root-help.test.ts index 9f931cbea4e..237d4d7d25b 100644 --- a/src/cli/program/root-help.test.ts +++ b/src/cli/program/root-help.test.ts @@ -23,7 +23,7 @@ vi.mock("./subcli-descriptors.js", () => ({ })); vi.mock("../../plugins/cli.js", () => ({ - getPluginCliCommandDescriptors: () => [ + getPluginCliCommandDescriptors: async () => [ { name: "matrix", description: "Matrix channel utilities", @@ -35,8 +35,8 @@ vi.mock("../../plugins/cli.js", () => ({ const { renderRootHelpText } = await import("./root-help.js"); describe("root help", () => { - it("includes plugin CLI descriptors alongside core and sub-CLI commands", () => { - const text = renderRootHelpText(); + it("includes plugin CLI descriptors alongside core and sub-CLI commands", async () => { + const text = await renderRootHelpText(); expect(text).toContain("status"); expect(text).toContain("config"); diff --git a/src/cli/program/root-help.ts b/src/cli/program/root-help.ts index 83a2ca87218..3c6504c7039 100644 --- a/src/cli/program/root-help.ts +++ b/src/cli/program/root-help.ts @@ -5,7 +5,7 @@ import { getCoreCliCommandDescriptors } from "./core-command-descriptors.js"; import { configureProgramHelp } from "./help.js"; import { getSubCliEntries } from "./subcli-descriptors.js"; -function buildRootHelpProgram(): Command { +async function buildRootHelpProgram(): Promise { const program = new Command(); configureProgramHelp(program, { programVersion: VERSION, @@ -26,7 +26,7 @@ function buildRootHelpProgram(): Command { program.command(command.name).description(command.description); existingCommands.add(command.name); } - for (const command of getPluginCliCommandDescriptors()) { + for (const command of await getPluginCliCommandDescriptors()) { if (existingCommands.has(command.name)) { continue; } @@ -37,8 +37,8 @@ function buildRootHelpProgram(): Command { return program; } -export function renderRootHelpText(): string { - const program = buildRootHelpProgram(); +export async function renderRootHelpText(): Promise { + const program = await buildRootHelpProgram(); let output = ""; const originalWrite = process.stdout.write.bind(process.stdout); const captureWrite: typeof process.stdout.write = ((chunk: string | Uint8Array) => { @@ -54,6 +54,6 @@ export function renderRootHelpText(): string { return output; } -export function outputRootHelp(): void { - process.stdout.write(renderRootHelpText()); +export async function outputRootHelp(): Promise { + process.stdout.write(await renderRootHelpText()); } diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index d52ab2ff33c..08cd7c4e9cf 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -160,7 +160,7 @@ export async function runCli(argv: string[] = process.argv) { try { if (shouldUseRootHelpFastPath(normalizedArgv)) { const { outputRootHelp } = await import("./program/root-help.js"); - outputRootHelp(); + await outputRootHelp(); return; } diff --git a/src/entry.test.ts b/src/entry.test.ts index 820d537f209..f02b119c627 100644 --- a/src/entry.test.ts +++ b/src/entry.test.ts @@ -2,13 +2,14 @@ import { describe, expect, it, vi } from "vitest"; import { tryHandleRootHelpFastPath } from "./entry.js"; describe("entry root help fast path", () => { - it("renders root help without importing the full program", () => { + it("renders root help without importing the full program", async () => { const outputRootHelpMock = vi.fn(); const handled = tryHandleRootHelpFastPath(["node", "openclaw", "--help"], { outputRootHelp: outputRootHelpMock, env: {}, }); + await Promise.resolve(); expect(handled).toBe(true); expect(outputRootHelpMock).toHaveBeenCalledTimes(1); diff --git a/src/entry.ts b/src/entry.ts index d1c0a232b26..156e6fcf5fb 100644 --- a/src/entry.ts +++ b/src/entry.ts @@ -159,7 +159,7 @@ if ( export function tryHandleRootHelpFastPath( argv: string[], deps: { - outputRootHelp?: () => void; + outputRootHelp?: () => void | Promise; onError?: (error: unknown) => void; env?: NodeJS.ProcessEnv; } = {}, @@ -180,16 +180,14 @@ export function tryHandleRootHelpFastPath( process.exitCode = 1; }); if (deps.outputRootHelp) { - try { - deps.outputRootHelp(); - } catch (error) { - handleError(error); - } + Promise.resolve() + .then(() => deps.outputRootHelp?.()) + .catch(handleError); return true; } import("./cli/program/root-help.js") .then(({ outputRootHelp }) => { - outputRootHelp(); + return outputRootHelp(); }) .catch(handleError); return true; diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index a464e9e33fb..3a971bab13a 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -218,6 +218,7 @@ type DefineChannelPluginEntryOptions = { plugin: TPlugin; configSchema?: OpenClawPluginConfigSchema | (() => OpenClawPluginConfigSchema); setRuntime?: (runtime: PluginRuntime) => void; + registerCliMetadata?: (api: OpenClawPluginApi) => void; registerFull?: (api: OpenClawPluginApi) => void; }; @@ -281,6 +282,7 @@ export function defineChannelPluginEntry({ plugin, configSchema = emptyPluginConfigSchema, setRuntime, + registerCliMetadata, registerFull, }: DefineChannelPluginEntryOptions): DefinedChannelPluginEntry { const resolvedConfigSchema = typeof configSchema === "function" ? configSchema() : configSchema; @@ -290,11 +292,16 @@ export function defineChannelPluginEntry({ description, configSchema: resolvedConfigSchema, register(api: OpenClawPluginApi) { + if (api.registrationMode === "cli-metadata") { + registerCliMetadata?.(api); + return; + } setRuntime?.(api.runtime); api.registerChannel({ plugin: plugin as ChannelPlugin }); if (api.registrationMode !== "full") { return; } + registerCliMetadata?.(api); registerFull?.(api); }, }; diff --git a/src/plugins/captured-registration.ts b/src/plugins/captured-registration.ts index 0c780e8c5a0..bd9677404cf 100644 --- a/src/plugins/captured-registration.ts +++ b/src/plugins/captured-registration.ts @@ -7,14 +7,23 @@ import type { ImageGenerationProviderPlugin, MediaUnderstandingProviderPlugin, OpenClawPluginApi, + OpenClawPluginCliCommandDescriptor, + OpenClawPluginCliRegistrar, ProviderPlugin, SpeechProviderPlugin, WebSearchProviderPlugin, } from "./types.js"; +type CapturedPluginCliRegistration = { + register: OpenClawPluginCliRegistrar; + commands: string[]; + descriptors: OpenClawPluginCliCommandDescriptor[]; +}; + export type CapturedPluginRegistration = { api: OpenClawPluginApi; providers: ProviderPlugin[]; + cliRegistrars: CapturedPluginCliRegistration[]; cliBackends: CliBackendPlugin[]; speechProviders: SpeechProviderPlugin[]; mediaUnderstandingProviders: MediaUnderstandingProviderPlugin[]; @@ -23,8 +32,12 @@ export type CapturedPluginRegistration = { tools: AnyAgentTool[]; }; -export function createCapturedPluginRegistration(): CapturedPluginRegistration { +export function createCapturedPluginRegistration(params?: { + config?: OpenClawConfig; + registrationMode?: OpenClawPluginApi["registrationMode"]; +}): CapturedPluginRegistration { const providers: ProviderPlugin[] = []; + const cliRegistrars: CapturedPluginCliRegistration[] = []; const cliBackends: CliBackendPlugin[] = []; const speechProviders: SpeechProviderPlugin[] = []; const mediaUnderstandingProviders: MediaUnderstandingProviderPlugin[] = []; @@ -40,6 +53,7 @@ export function createCapturedPluginRegistration(): CapturedPluginRegistration { return { providers, + cliRegistrars, cliBackends, speechProviders, mediaUnderstandingProviders, @@ -50,12 +64,35 @@ export function createCapturedPluginRegistration(): CapturedPluginRegistration { id: "captured-plugin-registration", name: "Captured Plugin Registration", source: "captured-plugin-registration", - registrationMode: "full", - config: {} as OpenClawConfig, + registrationMode: params?.registrationMode ?? "full", + config: params?.config ?? ({} as OpenClawConfig), runtime: {} as PluginRuntime, logger: noopLogger, resolvePath: (input) => input, handlers: { + registerCli(registrar, opts) { + const descriptors = (opts?.descriptors ?? []) + .map((descriptor) => ({ + name: descriptor.name.trim(), + description: descriptor.description.trim(), + hasSubcommands: descriptor.hasSubcommands, + })) + .filter((descriptor) => descriptor.name && descriptor.description); + const commands = [ + ...(opts?.commands ?? []), + ...descriptors.map((descriptor) => descriptor.name), + ] + .map((command) => command.trim()) + .filter(Boolean); + if (commands.length === 0) { + return; + } + cliRegistrars.push({ + register: registrar, + commands, + descriptors, + }); + }, registerProvider(provider: ProviderPlugin) { providers.push(provider); }, diff --git a/src/plugins/cli.test.ts b/src/plugins/cli.test.ts index 7de34581216..dd1295171db 100644 --- a/src/plugins/cli.test.ts +++ b/src/plugins/cli.test.ts @@ -6,11 +6,14 @@ const mocks = vi.hoisted(() => ({ memoryRegister: vi.fn(), otherRegister: vi.fn(), memoryListAction: vi.fn(), + loadOpenClawPluginCliRegistry: vi.fn(), loadOpenClawPlugins: vi.fn(), applyPluginAutoEnable: vi.fn(), })); vi.mock("./loader.js", () => ({ + loadOpenClawPluginCliRegistry: (...args: unknown[]) => + mocks.loadOpenClawPluginCliRegistry(...args), loadOpenClawPlugins: (...args: unknown[]) => mocks.loadOpenClawPlugins(...args), })); @@ -18,7 +21,7 @@ vi.mock("../config/plugin-auto-enable.js", () => ({ applyPluginAutoEnable: (...args: unknown[]) => mocks.applyPluginAutoEnable(...args), })); -import { registerPluginCliCommands } from "./cli.js"; +import { getPluginCliCommandDescriptors, registerPluginCliCommands } from "./cli.js"; function createProgram(existingCommandName?: string) { const program = new Command(); @@ -109,6 +112,8 @@ describe("registerPluginCliCommands", () => { program.command("other").description("Other commands"); }); mocks.memoryListAction.mockReset(); + mocks.loadOpenClawPluginCliRegistry.mockReset(); + mocks.loadOpenClawPluginCliRegistry.mockResolvedValue(createCliRegistry()); mocks.loadOpenClawPlugins.mockReset(); mocks.loadOpenClawPlugins.mockReturnValue(createCliRegistry()); mocks.applyPluginAutoEnable.mockReset(); @@ -150,6 +155,82 @@ describe("registerPluginCliCommands", () => { ); }); + it("loads root-help descriptors through the dedicated non-activating CLI collector", async () => { + const { rawConfig, autoEnabledConfig } = createAutoEnabledCliFixture(); + mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] }); + mocks.loadOpenClawPluginCliRegistry.mockResolvedValue({ + cliRegistrars: [ + { + pluginId: "matrix", + register: vi.fn(), + commands: ["matrix"], + descriptors: [ + { + name: "matrix", + description: "Matrix channel utilities", + hasSubcommands: true, + }, + ], + source: "bundled", + }, + { + pluginId: "duplicate-matrix", + register: vi.fn(), + commands: ["matrix"], + descriptors: [ + { + name: "matrix", + description: "Duplicate Matrix channel utilities", + hasSubcommands: true, + }, + ], + source: "bundled", + }, + ], + }); + + await expect(getPluginCliCommandDescriptors(rawConfig)).resolves.toEqual([ + { + name: "matrix", + description: "Matrix channel utilities", + hasSubcommands: true, + }, + ]); + expect(mocks.loadOpenClawPluginCliRegistry).toHaveBeenCalledWith( + expect.objectContaining({ + config: autoEnabledConfig, + }), + ); + }); + + it("keeps runtime CLI command registration on the full plugin loader for legacy channel plugins", async () => { + const { rawConfig, autoEnabledConfig } = createAutoEnabledCliFixture(); + mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] }); + mocks.loadOpenClawPlugins.mockReturnValue( + createCliRegistry({ + memoryCommands: ["legacy-channel"], + memoryDescriptors: [ + { + name: "legacy-channel", + description: "Legacy channel commands", + hasSubcommands: true, + }, + ], + }), + ); + + await registerPluginCliCommands(createProgram(), rawConfig, undefined, undefined, { + mode: "lazy", + }); + + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith( + expect.objectContaining({ + config: autoEnabledConfig, + }), + ); + expect(mocks.loadOpenClawPluginCliRegistry).not.toHaveBeenCalled(); + }); + it("lazy-registers descriptor-backed plugin commands on first invocation", async () => { const program = createProgram(); program.exitOverride(); diff --git a/src/plugins/cli.ts b/src/plugins/cli.ts index d2188078e97..5b8b779ff1a 100644 --- a/src/plugins/cli.ts +++ b/src/plugins/cli.ts @@ -6,7 +6,11 @@ import type { OpenClawConfig } from "../config/config.js"; import { loadConfig } from "../config/config.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { loadOpenClawPlugins, type PluginLoadOptions } from "./loader.js"; +import { + loadOpenClawPluginCliRegistry, + loadOpenClawPlugins, + type PluginLoadOptions, +} from "./loader.js"; import type { OpenClawPluginCliCommandDescriptor } from "./types.js"; import type { PluginLogger } from "./types.js"; @@ -30,11 +34,7 @@ function canRegisterPluginCliLazily(entry: { return entry.commands.every((command) => descriptorNames.has(command)); } -function loadPluginCliRegistry( - cfg?: OpenClawConfig, - env?: NodeJS.ProcessEnv, - loaderOptions?: Pick, -) { +function resolvePluginCliLoadContext(cfg?: OpenClawConfig, env?: NodeJS.ProcessEnv) { const config = cfg ?? loadConfig(); const resolvedConfig = applyPluginAutoEnable({ config, env: env ?? process.env }).config; const workspaceDir = resolveAgentWorkspaceDir( @@ -51,22 +51,51 @@ function loadPluginCliRegistry( config: resolvedConfig, workspaceDir, logger, - registry: loadOpenClawPlugins({ - config: resolvedConfig, - workspaceDir, + }; +} + +async function loadPluginCliMetadataRegistry( + cfg?: OpenClawConfig, + env?: NodeJS.ProcessEnv, + loaderOptions?: Pick, +) { + const context = resolvePluginCliLoadContext(cfg, env); + return { + ...context, + registry: await loadOpenClawPluginCliRegistry({ + config: context.config, + workspaceDir: context.workspaceDir, env, - logger, + logger: context.logger, ...loaderOptions, }), }; } -export function getPluginCliCommandDescriptors( +function loadPluginCliCommandRegistry( cfg?: OpenClawConfig, env?: NodeJS.ProcessEnv, -): OpenClawPluginCliCommandDescriptor[] { + loaderOptions?: Pick, +) { + const context = resolvePluginCliLoadContext(cfg, env); + return { + ...context, + registry: loadOpenClawPlugins({ + config: context.config, + workspaceDir: context.workspaceDir, + env, + logger: context.logger, + ...loaderOptions, + }), + }; +} + +export async function getPluginCliCommandDescriptors( + cfg?: OpenClawConfig, + env?: NodeJS.ProcessEnv, +): Promise { try { - const { registry } = loadPluginCliRegistry(cfg, env); + const { registry } = await loadPluginCliMetadataRegistry(cfg, env); const seen = new Set(); const descriptors: OpenClawPluginCliCommandDescriptor[] = []; for (const entry of registry.cliRegistrars) { @@ -91,7 +120,11 @@ export async function registerPluginCliCommands( loaderOptions?: Pick, options?: RegisterPluginCliOptions, ) { - const { config, workspaceDir, logger, registry } = loadPluginCliRegistry(cfg, env, loaderOptions); + const { config, workspaceDir, logger, registry } = loadPluginCliCommandRegistry( + cfg, + env, + loaderOptions, + ); const mode = options?.mode ?? "eager"; const primary = options?.primary ?? null; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index efa3d25168c..a6b966f15ed 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -11,6 +11,7 @@ import { createHookRunner } from "./hooks.js"; import { __testing, clearPluginLoaderCache, + loadOpenClawPluginCliRegistry, loadOpenClawPlugins, resolveRuntimePluginRegistry, } from "./loader.js"; @@ -2597,6 +2598,361 @@ module.exports = { expect(registry.channels).toHaveLength(expectedChannels); }); + it("passes validated plugin config into non-activating CLI metadata loads", async () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "config-cli", + filename: "config-cli.cjs", + body: `module.exports = { + id: "config-cli", + register(api) { + if (!api.pluginConfig || api.pluginConfig.token !== "ok") { + throw new Error("missing plugin config"); + } + api.registerCli(() => {}, { + descriptors: [ + { + name: "cfg", + description: "Config-backed CLI command", + hasSubcommands: true, + }, + ], + }); + }, +};`, + }); + fs.writeFileSync( + path.join(plugin.dir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "config-cli", + configSchema: { + type: "object", + additionalProperties: false, + properties: { + token: { type: "string" }, + }, + required: ["token"], + }, + }, + null, + 2, + ), + "utf-8", + ); + + const registry = await loadOpenClawPluginCliRegistry({ + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["config-cli"], + entries: { + "config-cli": { + config: { + token: "ok", + }, + }, + }, + }, + }, + }); + + expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain("cfg"); + expect(registry.plugins.find((entry) => entry.id === "config-cli")?.status).toBe("loaded"); + }); + + it("uses the real channel entry in cli-metadata mode for CLI metadata capture", async () => { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + const modeMarker = path.join(pluginDir, "registration-mode.txt"); + const runtimeMarker = path.join(pluginDir, "runtime-set.txt"); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/cli-metadata-channel", + openclaw: { extensions: ["./index.cjs"], setupEntry: "./setup-entry.cjs" }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "cli-metadata-channel", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["cli-metadata-channel"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `const { defineChannelPluginEntry } = require("openclaw/plugin-sdk/core"); +require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); +module.exports = { + ...defineChannelPluginEntry({ + id: "cli-metadata-channel", + name: "CLI Metadata Channel", + description: "cli metadata channel", + setRuntime() { + require("node:fs").writeFileSync(${JSON.stringify(runtimeMarker)}, "loaded", "utf-8"); + }, + plugin: { + id: "cli-metadata-channel", + meta: { + id: "cli-metadata-channel", + label: "CLI Metadata Channel", + selectionLabel: "CLI Metadata Channel", + docsPath: "/channels/cli-metadata-channel", + blurb: "cli metadata channel", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, + registerCliMetadata(api) { + require("node:fs").writeFileSync( + ${JSON.stringify(modeMarker)}, + String(api.registrationMode), + "utf-8", + ); + api.registerCli(() => {}, { + descriptors: [ + { + name: "cli-metadata-channel", + description: "Channel CLI metadata", + hasSubcommands: true, + }, + ], + }); + }, + registerFull() { + throw new Error("full channel entry should not run during CLI metadata capture"); + }, + }), +};`, + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "setup-entry.cjs"), + `throw new Error("setup entry should not load during CLI metadata capture");`, + "utf-8", + ); + + const registry = await loadOpenClawPluginCliRegistry({ + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["cli-metadata-channel"], + }, + }, + }); + + expect(fs.existsSync(fullMarker)).toBe(true); + expect(fs.existsSync(runtimeMarker)).toBe(false); + expect(fs.readFileSync(modeMarker, "utf-8")).toBe("cli-metadata"); + expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain( + "cli-metadata-channel", + ); + }); + + it("collects channel CLI metadata during full plugin loads", () => { + useNoBundledPlugins(); + const pluginDir = makeTempDir(); + const modeMarker = path.join(pluginDir, "registration-mode.txt"); + const fullMarker = path.join(pluginDir, "full-loaded.txt"); + + fs.writeFileSync( + path.join(pluginDir, "package.json"), + JSON.stringify( + { + name: "@openclaw/full-cli-metadata-channel", + openclaw: { extensions: ["./index.cjs"] }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "full-cli-metadata-channel", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["full-cli-metadata-channel"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `const { defineChannelPluginEntry } = require("openclaw/plugin-sdk/core"); +module.exports = { + ...defineChannelPluginEntry({ + id: "full-cli-metadata-channel", + name: "Full CLI Metadata Channel", + description: "full cli metadata channel", + plugin: { + id: "full-cli-metadata-channel", + meta: { + id: "full-cli-metadata-channel", + label: "Full CLI Metadata Channel", + selectionLabel: "Full CLI Metadata Channel", + docsPath: "/channels/full-cli-metadata-channel", + blurb: "full cli metadata channel", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({ accountId: "default" }), + }, + outbound: { deliveryMode: "direct" }, + }, + registerCliMetadata(api) { + require("node:fs").writeFileSync( + ${JSON.stringify(modeMarker)}, + String(api.registrationMode), + "utf-8", + ); + api.registerCli(() => {}, { + descriptors: [ + { + name: "full-cli-metadata-channel", + description: "Full-load channel CLI metadata", + hasSubcommands: true, + }, + ], + }); + }, + registerFull() { + require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); + }, + }), +};`, + "utf-8", + ); + + const registry = loadOpenClawPlugins({ + cache: false, + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["full-cli-metadata-channel"], + }, + }, + }); + + expect(fs.readFileSync(modeMarker, "utf-8")).toBe("full"); + expect(fs.existsSync(fullMarker)).toBe(true); + expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain( + "full-cli-metadata-channel", + ); + }); + + it("awaits async plugin registration when collecting CLI metadata", async () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "async-cli", + filename: "async-cli.cjs", + body: `module.exports = { + id: "async-cli", + async register(api) { + await Promise.resolve(); + api.registerCli(() => {}, { + descriptors: [ + { + name: "async-cli", + description: "Async CLI metadata", + hasSubcommands: true, + }, + ], + }); + }, +};`, + }); + + const registry = await loadOpenClawPluginCliRegistry({ + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["async-cli"], + }, + }, + }); + + expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain("async-cli"); + expect( + registry.diagnostics.some((entry) => entry.message.includes("async registration is ignored")), + ).toBe(false); + }); + + it("applies memory slot gating to non-bundled CLI metadata loads", async () => { + useNoBundledPlugins(); + const plugin = writePlugin({ + id: "memory-external", + filename: "memory-external.cjs", + body: `module.exports = { + id: "memory-external", + kind: "memory", + register(api) { + api.registerCli(() => {}, { + descriptors: [ + { + name: "memory-external", + description: "External memory CLI metadata", + hasSubcommands: true, + }, + ], + }); + }, +};`, + }); + fs.writeFileSync( + path.join(plugin.dir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "memory-external", + kind: "memory", + configSchema: EMPTY_PLUGIN_SCHEMA, + }, + null, + 2, + ), + "utf-8", + ); + + const registry = await loadOpenClawPluginCliRegistry({ + config: { + plugins: { + load: { paths: [plugin.file] }, + allow: ["memory-external"], + slots: { memory: "memory-other" }, + }, + }, + }); + + expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).not.toContain( + "memory-external", + ); + const memory = registry.plugins.find((entry) => entry.id === "memory-external"); + expect(memory?.status).toBe("disabled"); + expect(String(memory?.error ?? "")).toContain('memory slot set to "memory-other"'); + }); + it("blocks before_prompt_build but preserves legacy model overrides when prompt injection is disabled", async () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index 2c0c188747a..0b4936dfd25 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -9,6 +9,7 @@ import type { GatewayRequestHandler } from "../gateway/server-methods/types.js"; import { openBoundaryFileSync } from "../infra/boundary-file-read.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { resolveUserPath } from "../utils.js"; +import { buildPluginApi } from "./api-builder.js"; import { inspectBundleMcpRuntimeSupport } from "./bundle-mcp.js"; import { clearPluginCommands } from "./command-registry-state.js"; import { @@ -145,6 +146,37 @@ export function clearPluginLoaderCache(): void { const defaultLogger = () => createSubsystemLogger("plugins"); +function createPluginJitiLoader(options: Pick) { + const jitiLoaders = new Map>(); + return (modulePath: string) => { + const tryNative = shouldPreferNativeJiti(modulePath); + const aliasMap = buildPluginLoaderAliasMap( + modulePath, + process.argv[1], + import.meta.url, + options.pluginSdkResolution, + ); + const cacheKey = JSON.stringify({ + tryNative, + aliasMap: Object.entries(aliasMap).toSorted(([left], [right]) => left.localeCompare(right)), + }); + const cached = jitiLoaders.get(cacheKey); + if (cached) { + return cached; + } + const loader = createJiti(import.meta.url, { + ...buildPluginLoaderJitiOptions(aliasMap), + // Source .ts runtime shims import sibling ".js" specifiers that only exist + // after build. Disable native loading for source entries so Jiti rewrites + // those imports against the source graph, while keeping native dist/*.js + // loading for the canonical built module graph. + tryNative, + }); + jitiLoaders.set(cacheKey, loader); + return loader; + }; +} + export const __testing = { buildPluginLoaderJitiOptions, buildPluginLoaderAliasMap, @@ -826,36 +858,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } // Lazy: avoid creating the Jiti loader when all plugins are disabled (common in unit tests). - const jitiLoaders = new Map>(); - const getJiti = (modulePath: string) => { - const tryNative = shouldPreferNativeJiti(modulePath); - // Pass loader's moduleUrl so the openclaw root can always be resolved even when - // loading external plugins from outside the managed install directory. - const aliasMap = buildPluginLoaderAliasMap( - modulePath, - process.argv[1], - import.meta.url, - options.pluginSdkResolution, - ); - const cacheKey = JSON.stringify({ - tryNative, - aliasMap: Object.entries(aliasMap).toSorted(([left], [right]) => left.localeCompare(right)), - }); - const cached = jitiLoaders.get(cacheKey); - if (cached) { - return cached; - } - const loader = createJiti(import.meta.url, { - ...buildPluginLoaderJitiOptions(aliasMap), - // Source .ts runtime shims import sibling ".js" specifiers that only exist - // after build. Disable native loading for source entries so Jiti rewrites - // those imports against the source graph, while keeping native dist/*.js - // loading for the canonical built module graph. - tryNative, - }); - jitiLoaders.set(cacheKey, loader); - return loader; - }; + const getJiti = createPluginJitiLoader(options); let createPluginRuntimeFactory: ((options?: CreatePluginRuntimeOptions) => PluginRuntime) | null = null; @@ -1407,6 +1410,300 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi return registry; } +export async function loadOpenClawPluginCliRegistry( + options: PluginLoadOptions = {}, +): Promise { + const { env, cfg, normalized, onlyPluginIds, cacheKey } = resolvePluginLoadCacheContext({ + ...options, + activate: false, + cache: false, + }); + const logger = options.logger ?? defaultLogger(); + const onlyPluginIdSet = onlyPluginIds ? new Set(onlyPluginIds) : null; + const getJiti = createPluginJitiLoader(options); + const { registry, registerCli } = createPluginRegistry({ + logger, + runtime: {} as PluginRuntime, + coreGatewayHandlers: options.coreGatewayHandlers as Record, + suppressGlobalCommands: true, + }); + + const discovery = discoverOpenClawPlugins({ + workspaceDir: options.workspaceDir, + extraPaths: normalized.loadPaths, + cache: false, + env, + }); + const manifestRegistry = loadPluginManifestRegistry({ + config: cfg, + workspaceDir: options.workspaceDir, + cache: false, + env, + candidates: discovery.candidates, + diagnostics: discovery.diagnostics, + }); + pushDiagnostics(registry.diagnostics, manifestRegistry.diagnostics); + warnWhenAllowlistIsOpen({ + logger, + pluginsEnabled: normalized.enabled, + allow: normalized.allow, + warningCacheKey: `${cacheKey}::cli-metadata`, + discoverablePlugins: manifestRegistry.plugins + .filter((plugin) => !onlyPluginIdSet || onlyPluginIdSet.has(plugin.id)) + .map((plugin) => ({ + id: plugin.id, + source: plugin.source, + origin: plugin.origin, + })), + }); + const provenance = buildProvenanceIndex({ + config: cfg, + normalizedLoadPaths: normalized.loadPaths, + env, + }); + const manifestByRoot = new Map( + manifestRegistry.plugins.map((record) => [record.rootDir, record]), + ); + const orderedCandidates = [...discovery.candidates].toSorted((left, right) => { + return compareDuplicateCandidateOrder({ + left, + right, + manifestByRoot, + provenance, + env, + }); + }); + + const seenIds = new Map(); + const memorySlot = normalized.slots.memory; + let selectedMemoryPluginId: string | null = null; + + for (const candidate of orderedCandidates) { + const manifestRecord = manifestByRoot.get(candidate.rootDir); + if (!manifestRecord) { + continue; + } + const pluginId = manifestRecord.id; + if (onlyPluginIdSet && !onlyPluginIdSet.has(pluginId)) { + continue; + } + const existingOrigin = seenIds.get(pluginId); + if (existingOrigin) { + const record = createPluginRecord({ + id: pluginId, + name: manifestRecord.name ?? pluginId, + description: manifestRecord.description, + version: manifestRecord.version, + format: manifestRecord.format, + bundleFormat: manifestRecord.bundleFormat, + bundleCapabilities: manifestRecord.bundleCapabilities, + source: candidate.source, + rootDir: candidate.rootDir, + origin: candidate.origin, + workspaceDir: candidate.workspaceDir, + enabled: false, + configSchema: Boolean(manifestRecord.configSchema), + }); + record.status = "disabled"; + record.error = `overridden by ${existingOrigin} plugin`; + registry.plugins.push(record); + continue; + } + + const enableState = resolveEffectiveEnableState({ + id: pluginId, + origin: candidate.origin, + config: normalized, + rootConfig: cfg, + enabledByDefault: manifestRecord.enabledByDefault, + }); + const entry = normalized.entries[pluginId]; + const record = createPluginRecord({ + id: pluginId, + name: manifestRecord.name ?? pluginId, + description: manifestRecord.description, + version: manifestRecord.version, + format: manifestRecord.format, + bundleFormat: manifestRecord.bundleFormat, + bundleCapabilities: manifestRecord.bundleCapabilities, + source: candidate.source, + rootDir: candidate.rootDir, + origin: candidate.origin, + workspaceDir: candidate.workspaceDir, + enabled: enableState.enabled, + configSchema: Boolean(manifestRecord.configSchema), + }); + record.kind = manifestRecord.kind; + record.configUiHints = manifestRecord.configUiHints; + record.configJsonSchema = manifestRecord.configSchema; + const pushPluginLoadError = (message: string) => { + record.status = "error"; + record.error = message; + registry.plugins.push(record); + seenIds.set(pluginId, candidate.origin); + registry.diagnostics.push({ + level: "error", + pluginId: record.id, + source: record.source, + message: record.error, + }); + }; + + if (!enableState.enabled) { + record.status = "disabled"; + record.error = enableState.reason; + registry.plugins.push(record); + seenIds.set(pluginId, candidate.origin); + continue; + } + + if (record.format === "bundle") { + registry.plugins.push(record); + seenIds.set(pluginId, candidate.origin); + continue; + } + + if (manifestRecord.kind === "memory") { + const memoryDecision = resolveMemorySlotDecision({ + id: record.id, + kind: "memory", + slot: memorySlot, + selectedId: selectedMemoryPluginId, + }); + if (!memoryDecision.enabled) { + record.enabled = false; + record.status = "disabled"; + record.error = memoryDecision.reason; + registry.plugins.push(record); + seenIds.set(pluginId, candidate.origin); + continue; + } + if (memoryDecision.selected) { + selectedMemoryPluginId = record.id; + } + } + + if (!manifestRecord.configSchema) { + pushPluginLoadError("missing config schema"); + continue; + } + + const validatedConfig = validatePluginConfig({ + schema: manifestRecord.configSchema, + cacheKey: manifestRecord.schemaCacheKey, + value: entry?.config, + }); + if (!validatedConfig.ok) { + logger.error(`[plugins] ${record.id} invalid config: ${validatedConfig.errors?.join(", ")}`); + pushPluginLoadError(`invalid config: ${validatedConfig.errors?.join(", ")}`); + continue; + } + + const pluginRoot = safeRealpathOrResolve(candidate.rootDir); + const opened = openBoundaryFileSync({ + absolutePath: candidate.source, + rootPath: pluginRoot, + boundaryLabel: "plugin root", + rejectHardlinks: candidate.origin !== "bundled", + skipLexicalRootCheck: true, + }); + if (!opened.ok) { + pushPluginLoadError("plugin entry path escapes plugin root or fails alias checks"); + continue; + } + const safeSource = opened.path; + fs.closeSync(opened.fd); + + let mod: OpenClawPluginModule | null = null; + try { + mod = getJiti(safeSource)(safeSource) as OpenClawPluginModule; + } catch (err) { + recordPluginError({ + logger, + registry, + record, + seenIds, + pluginId, + origin: candidate.origin, + error: err, + logPrefix: `[plugins] ${record.id} failed to load from ${record.source}: `, + diagnosticMessagePrefix: "failed to load plugin: ", + }); + continue; + } + + const resolved = resolvePluginModuleExport(mod); + const definition = resolved.definition; + const register = resolved.register; + + if (definition?.id && definition.id !== record.id) { + pushPluginLoadError( + `plugin id mismatch (config uses "${record.id}", export uses "${definition.id}")`, + ); + continue; + } + + record.name = definition?.name ?? record.name; + record.description = definition?.description ?? record.description; + record.version = definition?.version ?? record.version; + const manifestKind = record.kind as string | undefined; + const exportKind = definition?.kind as string | undefined; + if (manifestKind && exportKind && exportKind !== manifestKind) { + registry.diagnostics.push({ + level: "warn", + pluginId: record.id, + source: record.source, + message: `plugin kind mismatch (manifest uses "${manifestKind}", export uses "${exportKind}")`, + }); + } + record.kind = definition?.kind ?? record.kind; + + if (typeof register !== "function") { + logger.error(`[plugins] ${record.id} missing register/activate export`); + pushPluginLoadError("plugin export missing register/activate"); + continue; + } + + const api = buildPluginApi({ + id: record.id, + name: record.name, + version: record.version, + description: record.description, + source: record.source, + rootDir: record.rootDir, + registrationMode: "cli-metadata", + config: cfg, + pluginConfig: validatedConfig.value, + runtime: {} as PluginRuntime, + logger, + resolvePath: (input) => resolveUserPath(input), + handlers: { + registerCli: (registrar, opts) => registerCli(record, registrar, opts), + }, + }); + + try { + await register(api); + registry.plugins.push(record); + seenIds.set(pluginId, candidate.origin); + } catch (err) { + recordPluginError({ + logger, + registry, + record, + seenIds, + pluginId, + origin: candidate.origin, + error: err, + logPrefix: `[plugins] ${record.id} failed during register from ${record.source}: `, + diagnosticMessagePrefix: "plugin failed during register: ", + }); + } + } + + return registry; +} + function safeRealpathOrResolve(value: string): string { try { return fs.realpathSync(value); diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 20a5d6d9731..45a21056c80 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -988,7 +988,6 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerWebSearchProvider: (provider) => registerWebSearchProvider(record, provider), registerGatewayMethod: (method, handler, opts) => registerGatewayMethod(record, method, handler, opts), - registerCli: (registrar, opts) => registerCli(record, registrar, opts), registerService: (service) => registerService(record, service), registerCliBackend: (backend) => registerCliBackend(record, backend), registerInteractiveHandler: (registration) => { @@ -1097,6 +1096,9 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { registerTypedHook(record, hookName, handler, opts, params.hookPolicy), } : {}), + // Allow setup-only/setup-runtime paths to surface parse-time CLI metadata + // without opting into the wider full-registration surface. + registerCli: (registrar, opts) => registerCli(record, registrar, opts), registerChannel: (registration) => registerChannel(record, registration, registrationMode), }, }); diff --git a/src/plugins/types.ts b/src/plugins/types.ts index f79a42d110e..edfc7f9e965 100644 --- a/src/plugins/types.ts +++ b/src/plugins/types.ts @@ -1674,7 +1674,7 @@ export type OpenClawPluginModule = | OpenClawPluginDefinition | ((api: OpenClawPluginApi) => void | Promise); -export type PluginRegistrationMode = "full" | "setup-only" | "setup-runtime"; +export type PluginRegistrationMode = "full" | "setup-only" | "setup-runtime" | "cli-metadata"; /** Main registration API injected into native plugin entry files. */ export type OpenClawPluginApi = {