From ebbe7f642d389c09509a62bdccb1d05c0ea6b1d2 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Sun, 29 Mar 2026 18:16:30 -0400 Subject: [PATCH] CLI: make root help metadata non-activating --- CHANGELOG.md | 2 +- extensions/matrix/index.ts | 20 +- 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 | 6 + src/plugins/cli.test.ts | 26 +- src/plugins/cli.ts | 27 +- src/plugins/loader.test.ts | 155 ++++++----- src/plugins/loader.ts | 411 +++++++++++++++++++++++++----- src/plugins/types.ts | 2 +- 13 files changed, 483 insertions(+), 201 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6730f09d2fb..fd65f569b4d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,7 +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: keep root-help plugin descriptor loads non-activating while reusing loader-side plugin config validation and channel entry selection semantics. (#57294) 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. (#57294) thanks @gumadeiras. ## 2026.3.28 diff --git a/extensions/matrix/index.ts b/extensions/matrix/index.ts index 60363e7028d..fdbeaa2bc61 100644 --- a/extensions/matrix/index.ts +++ b/extensions/matrix/index.ts @@ -1,25 +1,17 @@ import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core"; -import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry"; import { matrixPlugin } from "./src/channel.js"; import { setMatrixRuntime } from "./src/runtime.js"; export { matrixPlugin } from "./src/channel.js"; export { setMatrixRuntime } from "./src/runtime.js"; -const matrixEntry = defineChannelPluginEntry({ +export default defineChannelPluginEntry({ id: "matrix", name: "Matrix", description: "Matrix channel plugin (matrix-js-sdk)", plugin: matrixPlugin, setRuntime: setMatrixRuntime, -}); - -export default { - ...matrixEntry, - register(api: OpenClawPluginApi) { - matrixEntry.register(api); - // Expose Matrix CLI metadata during descriptor capture without crossing - // into the full runtime bootstrap path. + registerCliMetadata(api) { api.registerCli( async ({ program }) => { const { registerMatrixCli } = await import("./src/cli.js"); @@ -35,10 +27,8 @@ export default { ], }, ); - if (api.registrationMode !== "full") { - return; - } - + }, + registerFull(api) { void import("./src/plugin-entry.runtime.js") .then(({ ensureMatrixCryptoRuntime }) => ensureMatrixCryptoRuntime({ log: api.logger.info }).catch((err: unknown) => { @@ -66,4 +56,4 @@ export default { await handleVerificationStatus(ctx); }); }, -}; +}); 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..c4b3be4b90c 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; @@ -291,6 +293,10 @@ export function defineChannelPluginEntry({ configSchema: resolvedConfigSchema, register(api: OpenClawPluginApi) { setRuntime?.(api.runtime); + if (api.registrationMode === "cli-metadata") { + registerCliMetadata?.(api); + return; + } api.registerChannel({ plugin: plugin as ChannelPlugin }); if (api.registrationMode !== "full") { return; diff --git a/src/plugins/cli.test.ts b/src/plugins/cli.test.ts index 760f21b2b70..b12e8db11d8 100644 --- a/src/plugins/cli.test.ts +++ b/src/plugins/cli.test.ts @@ -6,12 +6,13 @@ const mocks = vi.hoisted(() => ({ memoryRegister: vi.fn(), otherRegister: vi.fn(), memoryListAction: vi.fn(), - loadOpenClawPlugins: vi.fn(), + loadOpenClawPluginCliRegistry: vi.fn(), applyPluginAutoEnable: vi.fn(), })); vi.mock("./loader.js", () => ({ - loadOpenClawPlugins: (...args: unknown[]) => mocks.loadOpenClawPlugins(...args), + loadOpenClawPluginCliRegistry: (...args: unknown[]) => + mocks.loadOpenClawPluginCliRegistry(...args), })); vi.mock("../config/plugin-auto-enable.js", () => ({ @@ -63,7 +64,7 @@ function createCliRegistry(params?: { } function expectPluginLoaderConfig(config: OpenClawConfig) { - expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith( + expect(mocks.loadOpenClawPluginCliRegistry).toHaveBeenCalledWith( expect.objectContaining({ config, }), @@ -109,8 +110,8 @@ describe("registerPluginCliCommands", () => { program.command("other").description("Other commands"); }); mocks.memoryListAction.mockReset(); - mocks.loadOpenClawPlugins.mockReset(); - mocks.loadOpenClawPlugins.mockReturnValue(createCliRegistry()); + mocks.loadOpenClawPluginCliRegistry.mockReset(); + mocks.loadOpenClawPluginCliRegistry.mockResolvedValue(createCliRegistry()); mocks.applyPluginAutoEnable.mockReset(); mocks.applyPluginAutoEnable.mockImplementation(({ config }) => ({ config, changes: [] })); }); @@ -129,7 +130,7 @@ describe("registerPluginCliCommands", () => { await registerPluginCliCommands(createProgram(), {} as OpenClawConfig, env); - expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith( + expect(mocks.loadOpenClawPluginCliRegistry).toHaveBeenCalledWith( expect.objectContaining({ env, }), @@ -150,10 +151,10 @@ describe("registerPluginCliCommands", () => { ); }); - it("loads root-help descriptors through the non-activating loader path", () => { + it("loads root-help descriptors through the dedicated non-activating CLI collector", async () => { const { rawConfig, autoEnabledConfig } = createAutoEnabledCliFixture(); mocks.applyPluginAutoEnable.mockReturnValue({ config: autoEnabledConfig, changes: [] }); - mocks.loadOpenClawPlugins.mockReturnValue({ + mocks.loadOpenClawPluginCliRegistry.mockResolvedValue({ cliRegistrars: [ { pluginId: "matrix", @@ -184,19 +185,16 @@ describe("registerPluginCliCommands", () => { ], }); - expect(getPluginCliCommandDescriptors(rawConfig)).toEqual([ + await expect(getPluginCliCommandDescriptors(rawConfig)).resolves.toEqual([ { name: "matrix", description: "Matrix channel utilities", hasSubcommands: true, }, ]); - expect(mocks.loadOpenClawPlugins).toHaveBeenCalledWith( + expect(mocks.loadOpenClawPluginCliRegistry).toHaveBeenCalledWith( expect.objectContaining({ config: autoEnabledConfig, - activate: false, - cache: false, - captureCliMetadataOnly: true, }), ); }); @@ -220,7 +218,7 @@ describe("registerPluginCliCommands", () => { }); it("falls back to eager registration when descriptors do not cover every command root", async () => { - mocks.loadOpenClawPlugins.mockReturnValue( + mocks.loadOpenClawPluginCliRegistry.mockResolvedValue( createCliRegistry({ memoryCommands: ["memory", "memory-admin"], memoryDescriptors: [ diff --git a/src/plugins/cli.ts b/src/plugins/cli.ts index 80d96a8000a..51d2d142805 100644 --- a/src/plugins/cli.ts +++ b/src/plugins/cli.ts @@ -6,7 +6,7 @@ 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, type PluginLoadOptions } from "./loader.js"; import type { OpenClawPluginCliCommandDescriptor } from "./types.js"; import type { PluginLogger } from "./types.js"; @@ -30,13 +30,10 @@ function canRegisterPluginCliLazily(entry: { return entry.commands.every((command) => descriptorNames.has(command)); } -function loadPluginCliRegistry( +async function loadPluginCliRegistry( cfg?: OpenClawConfig, env?: NodeJS.ProcessEnv, - loaderOptions?: Pick< - PluginLoadOptions, - "pluginSdkResolution" | "activate" | "cache" | "captureCliMetadataOnly" - >, + loaderOptions?: Pick, ) { const config = cfg ?? loadConfig(); const resolvedConfig = applyPluginAutoEnable({ config, env: env ?? process.env }).config; @@ -54,7 +51,7 @@ function loadPluginCliRegistry( config: resolvedConfig, workspaceDir, logger, - registry: loadOpenClawPlugins({ + registry: await loadOpenClawPluginCliRegistry({ config: resolvedConfig, workspaceDir, env, @@ -64,16 +61,12 @@ function loadPluginCliRegistry( }; } -export function getPluginCliCommandDescriptors( +export async function getPluginCliCommandDescriptors( cfg?: OpenClawConfig, env?: NodeJS.ProcessEnv, -): OpenClawPluginCliCommandDescriptor[] { +): Promise { try { - const { registry } = loadPluginCliRegistry(cfg, env, { - activate: false, - cache: false, - captureCliMetadataOnly: true, - }); + const { registry } = await loadPluginCliRegistry(cfg, env); const seen = new Set(); const descriptors: OpenClawPluginCliCommandDescriptor[] = []; for (const entry of registry.cliRegistrars) { @@ -98,7 +91,11 @@ export async function registerPluginCliCommands( loaderOptions?: Pick, options?: RegisterPluginCliOptions, ) { - const { config, workspaceDir, logger, registry } = loadPluginCliRegistry(cfg, env, loaderOptions); + const { config, workspaceDir, logger, registry } = await loadPluginCliRegistry( + 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 91843c3d2d4..9827c0f14be 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,7 +2598,7 @@ module.exports = { expect(registry.channels).toHaveLength(expectedChannels); }); - it("passes validated plugin config into non-activating CLI metadata loads", () => { + it("passes validated plugin config into non-activating CLI metadata loads", async () => { useNoBundledPlugins(); const plugin = writePlugin({ id: "config-cli", @@ -2640,10 +2641,7 @@ module.exports = { "utf-8", ); - const registry = loadOpenClawPlugins({ - cache: false, - activate: false, - captureCliMetadataOnly: true, + const registry = await loadOpenClawPluginCliRegistry({ config: { plugins: { load: { paths: [plugin.file] }, @@ -2663,11 +2661,10 @@ module.exports = { expect(registry.plugins.find((entry) => entry.id === "config-cli")?.status).toBe("loaded"); }); - it("uses the real channel entry in setup-only mode for CLI metadata capture", () => { + 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 setupMarker = path.join(pluginDir, "setup-loaded.txt"); const modeMarker = path.join(pluginDir, "registration-mode.txt"); fs.writeFileSync( @@ -2675,10 +2672,7 @@ module.exports = { JSON.stringify( { name: "@openclaw/cli-metadata-channel", - openclaw: { - extensions: ["./index.cjs"], - setupEntry: "./setup-entry.cjs", - }, + openclaw: { extensions: ["./index.cjs"], setupEntry: "./setup-entry.cjs" }, }, null, 2, @@ -2700,74 +2694,59 @@ module.exports = { ); fs.writeFileSync( path.join(pluginDir, "index.cjs"), - `require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); + `const { defineChannelPluginEntry } = require("openclaw/plugin-sdk/core"); +require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); module.exports = { - id: "cli-metadata-channel", - register(api) { - require("node:fs").writeFileSync( - ${JSON.stringify(modeMarker)}, - String(api.registrationMode), - "utf-8", - ); - api.registerChannel({ - plugin: { + ...defineChannelPluginEntry({ + id: "cli-metadata-channel", + name: "CLI Metadata Channel", + description: "cli metadata channel", + plugin: { + id: "cli-metadata-channel", + meta: { 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" }, + label: "CLI Metadata Channel", + selectionLabel: "CLI Metadata Channel", + docsPath: "/channels/cli-metadata-channel", + blurb: "cli metadata channel", }, - }); - api.registerCli(() => {}, { - descriptors: [ - { - name: "cli-metadata-channel", - description: "Channel CLI metadata", - hasSubcommands: true, - }, - ], - }); - }, + 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"), - `require("node:fs").writeFileSync(${JSON.stringify(setupMarker)}, "loaded", "utf-8"); -module.exports = { - plugin: { - id: "cli-metadata-channel", - meta: { - id: "cli-metadata-channel", - label: "CLI Metadata Channel", - selectionLabel: "CLI Metadata Channel", - docsPath: "/channels/cli-metadata-channel", - blurb: "setup entry", - }, - capabilities: { chatTypes: ["direct"] }, - config: { - listAccountIds: () => [], - resolveAccount: () => ({ accountId: "default" }), - }, - outbound: { deliveryMode: "direct" }, - }, -};`, + `throw new Error("setup entry should not load during CLI metadata capture");`, "utf-8", ); - const registry = loadOpenClawPlugins({ - cache: false, - activate: false, - captureCliMetadataOnly: true, + const registry = await loadOpenClawPluginCliRegistry({ config: { plugins: { load: { paths: [pluginDir] }, @@ -2777,13 +2756,49 @@ module.exports = { }); expect(fs.existsSync(fullMarker)).toBe(true); - expect(fs.existsSync(setupMarker)).toBe(false); - expect(fs.readFileSync(modeMarker, "utf-8")).toBe("setup-only"); + expect(fs.readFileSync(modeMarker, "utf-8")).toBe("cli-metadata"); expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain( "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("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 0d3669c49e8..1f0773d8123 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 { @@ -88,15 +89,6 @@ export type PluginLoadOptions = { * via package metadata because their setup entry covers the pre-listen startup surface. */ preferSetupRuntimeForChannelPlugins?: boolean; - /** - * Capture CLI descriptors without activating plugin runtime side effects. - * - * Enabled channel plugins still load from their real entry file so - * descriptor registration can follow the normal plugin entry contract, but - * they register in setup-only mode to keep `registerFull(...)` work out of - * parse-time help paths. - */ - captureCliMetadataOnly?: boolean; activate?: boolean; throwOnLoadError?: boolean; }; @@ -154,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, @@ -211,7 +234,6 @@ function buildCacheKey(params: { onlyPluginIds?: string[]; includeSetupOnlyChannelPlugins?: boolean; preferSetupRuntimeForChannelPlugins?: boolean; - captureCliMetadataOnly?: boolean; runtimeSubagentMode?: "default" | "explicit" | "gateway-bindable"; pluginSdkResolution?: PluginSdkResolutionPreference; coreGatewayMethodNames?: string[]; @@ -241,13 +263,12 @@ function buildCacheKey(params: { const setupOnlyKey = params.includeSetupOnlyChannelPlugins === true ? "setup-only" : "runtime"; const startupChannelMode = params.preferSetupRuntimeForChannelPlugins === true ? "prefer-setup" : "full"; - const cliMetadataMode = params.captureCliMetadataOnly === true ? "cli-metadata" : "default"; const gatewayMethodsKey = JSON.stringify(params.coreGatewayMethodNames ?? []); return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({ ...params.plugins, installs, loadPaths, - })}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}::${cliMetadataMode}::${params.runtimeSubagentMode ?? "default"}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`; + })}::${scopeKey}::${setupOnlyKey}::${startupChannelMode}::${params.runtimeSubagentMode ?? "default"}::${params.pluginSdkResolution ?? "auto"}::${gatewayMethodsKey}`; } function normalizeScopedPluginIds(ids?: string[]): string[] | undefined { @@ -280,8 +301,7 @@ function hasExplicitCompatibilityInputs(options: PluginLoadOptions): boolean { options.pluginSdkResolution !== undefined || options.coreGatewayHandlers !== undefined || options.includeSetupOnlyChannelPlugins === true || - options.preferSetupRuntimeForChannelPlugins === true || - options.captureCliMetadataOnly === true, + options.preferSetupRuntimeForChannelPlugins === true, ); } @@ -292,7 +312,6 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { const onlyPluginIds = normalizeScopedPluginIds(options.onlyPluginIds); const includeSetupOnlyChannelPlugins = options.includeSetupOnlyChannelPlugins === true; const preferSetupRuntimeForChannelPlugins = options.preferSetupRuntimeForChannelPlugins === true; - const captureCliMetadataOnly = options.captureCliMetadataOnly === true; const coreGatewayMethodNames = Object.keys(options.coreGatewayHandlers ?? {}).toSorted(); const cacheKey = buildCacheKey({ workspaceDir: options.workspaceDir, @@ -302,7 +321,6 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { onlyPluginIds, includeSetupOnlyChannelPlugins, preferSetupRuntimeForChannelPlugins, - captureCliMetadataOnly, runtimeSubagentMode: resolveRuntimeSubagentMode(options.runtimeOptions), pluginSdkResolution: options.pluginSdkResolution, coreGatewayMethodNames, @@ -314,7 +332,6 @@ function resolvePluginLoadCacheContext(options: PluginLoadOptions = {}) { onlyPluginIds, includeSetupOnlyChannelPlugins, preferSetupRuntimeForChannelPlugins, - captureCliMetadataOnly, shouldActivate: options.activate !== false, runtimeSubagentMode: resolveRuntimeSubagentMode(options.runtimeOptions), cacheKey, @@ -808,7 +825,6 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi onlyPluginIds, includeSetupOnlyChannelPlugins, preferSetupRuntimeForChannelPlugins, - captureCliMetadataOnly, shouldActivate, cacheKey, runtimeSubagentMode, @@ -842,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; @@ -1082,20 +1069,18 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi }; const registrationMode = enableState.enabled - ? captureCliMetadataOnly && manifestRecord.channels.length > 0 - ? "setup-only" - : !validateOnly && - shouldLoadChannelPluginInSetupRuntime({ - manifestChannels: manifestRecord.channels, - setupSource: manifestRecord.setupSource, - startupDeferConfiguredChannelFullLoadUntilAfterListen: - manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen, - cfg, - env, - preferSetupRuntimeForChannelPlugins, - }) - ? "setup-runtime" - : "full" + ? !validateOnly && + shouldLoadChannelPluginInSetupRuntime({ + manifestChannels: manifestRecord.channels, + setupSource: manifestRecord.setupSource, + startupDeferConfiguredChannelFullLoadUntilAfterListen: + manifestRecord.startupDeferConfiguredChannelFullLoadUntilAfterListen, + cfg, + env, + preferSetupRuntimeForChannelPlugins, + }) + ? "setup-runtime" + : "full" : includeSetupOnlyChannelPlugins && !validateOnly && manifestRecord.channels.length > 0 ? "setup-only" : null; @@ -1201,10 +1186,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } const pluginRoot = safeRealpathOrResolve(candidate.rootDir); - const loadSource = captureCliMetadataOnly - ? candidate.source - : (registrationMode === "setup-only" || registrationMode === "setup-runtime") && - manifestRecord.setupSource + const loadSource = + (registrationMode === "setup-only" || registrationMode === "setup-runtime") && + manifestRecord.setupSource ? manifestRecord.setupSource : candidate.source; const opened = openBoundaryFileSync({ @@ -1240,7 +1224,6 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi } if ( - !captureCliMetadataOnly && (registrationMode === "setup-only" || registrationMode === "setup-runtime") && manifestRecord.setupSource ) { @@ -1427,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 (candidate.origin === "bundled" && 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/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 = {