CLI: make root help metadata non-activating

This commit is contained in:
Gustavo Madeira Santana 2026-03-29 18:16:30 -04:00
parent bb86b1c7dc
commit ebbe7f642d
No known key found for this signature in database
13 changed files with 483 additions and 201 deletions

View File

@ -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

View File

@ -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);
});
},
};
});

View File

@ -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");

View File

@ -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<Command> {
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<string> {
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<void> {
process.stdout.write(await renderRootHelpText());
}

View File

@ -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;
}

View File

@ -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);

View File

@ -159,7 +159,7 @@ if (
export function tryHandleRootHelpFastPath(
argv: string[],
deps: {
outputRootHelp?: () => void;
outputRootHelp?: () => void | Promise<void>;
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;

View File

@ -218,6 +218,7 @@ type DefineChannelPluginEntryOptions<TPlugin = ChannelPlugin> = {
plugin: TPlugin;
configSchema?: OpenClawPluginConfigSchema | (() => OpenClawPluginConfigSchema);
setRuntime?: (runtime: PluginRuntime) => void;
registerCliMetadata?: (api: OpenClawPluginApi) => void;
registerFull?: (api: OpenClawPluginApi) => void;
};
@ -281,6 +282,7 @@ export function defineChannelPluginEntry<TPlugin>({
plugin,
configSchema = emptyPluginConfigSchema,
setRuntime,
registerCliMetadata,
registerFull,
}: DefineChannelPluginEntryOptions<TPlugin>): DefinedChannelPluginEntry<TPlugin> {
const resolvedConfigSchema = typeof configSchema === "function" ? configSchema() : configSchema;
@ -291,6 +293,10 @@ export function defineChannelPluginEntry<TPlugin>({
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;

View File

@ -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: [

View File

@ -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<PluginLoadOptions, "pluginSdkResolution">,
) {
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<OpenClawPluginCliCommandDescriptor[]> {
try {
const { registry } = loadPluginCliRegistry(cfg, env, {
activate: false,
cache: false,
captureCliMetadataOnly: true,
});
const { registry } = await loadPluginCliRegistry(cfg, env);
const seen = new Set<string>();
const descriptors: OpenClawPluginCliCommandDescriptor[] = [];
for (const entry of registry.cliRegistrars) {
@ -98,7 +91,11 @@ export async function registerPluginCliCommands(
loaderOptions?: Pick<PluginLoadOptions, "pluginSdkResolution">,
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;

View File

@ -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({

View File

@ -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<PluginLoadOptions, "pluginSdkResolution">) {
const jitiLoaders = new Map<string, ReturnType<typeof createJiti>>();
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<string, ReturnType<typeof createJiti>>();
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<PluginRegistry> {
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<string, GatewayRequestHandler>,
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<string, PluginRecord["origin"]>();
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);

View File

@ -1674,7 +1674,7 @@ export type OpenClawPluginModule =
| OpenClawPluginDefinition
| ((api: OpenClawPluginApi) => void | Promise<void>);
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 = {