CLI: keep root help plugin descriptors non-activating (#57294)

Merged via squash.

Prepared head SHA: c8da48f689
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Co-authored-by: gumadeiras <5599352+gumadeiras@users.noreply.github.com>
Reviewed-by: @gumadeiras
This commit is contained in:
Gustavo Madeira Santana 2026-03-29 18:49:57 -04:00 committed by GitHub
parent 1efef8205c
commit e5dac0c39e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1031 additions and 125 deletions

View File

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

View File

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

View File

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

View File

@ -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(/* ... */);
}
```

View File

@ -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<string, unknown>` | Plugin-specific config from `plugins.entries.<id>.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<string, unknown>` | Plugin-specific config from `plugins.entries.<id>.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

View File

@ -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<string, unknown>` | Plugin-specific config from `plugins.entries.<id>.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<string, unknown>` | Plugin-specific config from `plugins.entries.<id>.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

View File

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

View File

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

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;
@ -290,11 +292,16 @@ export function defineChannelPluginEntry<TPlugin>({
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);
},
};

View File

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

View File

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

View File

@ -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<PluginLoadOptions, "pluginSdkResolution">,
) {
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<PluginLoadOptions, "pluginSdkResolution">,
) {
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<PluginLoadOptions, "pluginSdkResolution">,
) {
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<OpenClawPluginCliCommandDescriptor[]> {
try {
const { registry } = loadPluginCliRegistry(cfg, env);
const { registry } = await loadPluginCliMetadataRegistry(cfg, env);
const seen = new Set<string>();
const descriptors: OpenClawPluginCliCommandDescriptor[] = [];
for (const entry of registry.cliRegistrars) {
@ -91,7 +120,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 } = loadPluginCliCommandRegistry(
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,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({

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 {
@ -145,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,
@ -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<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;
@ -1407,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 (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

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

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 = {