mirror of https://github.com/openclaw/openclaw.git
CLI: make root help metadata non-activating
This commit is contained in:
parent
bb86b1c7dc
commit
ebbe7f642d
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
12
src/entry.ts
12
src/entry.ts
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
Loading…
Reference in New Issue