diff --git a/CHANGELOG.md b/CHANGELOG.md index bd902366bf7..af50a2060eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -170,6 +170,7 @@ Docs: https://docs.openclaw.ai - Plugins/Google Meet: report required manual actions for Chrome joins, use browser automation for Meet entry, and persist the private-WS node opt-in so paired-node realtime sessions keep their intended network policy. Thanks @steipete. - Slack: route native stream fallback replies through the normal chunked sender so long buffered Slack Connect responses are not dropped or duplicated. (#71124) Thanks @martingarramon. - WhatsApp: transcribe accepted voice notes before agent dispatch while keeping spoken transcripts out of command authorization. (#64120) Thanks @rogerdigital. +- Plugins/CLI: expose channel plugin CLI descriptors during discovery-mode plugin loads so snapshot registries keep channel commands visible without activating full runtimes. (#71309) Thanks @gumadeiras. ## 2026.4.23 diff --git a/docs/plugins/sdk-entrypoints.md b/docs/plugins/sdk-entrypoints.md index 3da9fded23d..510c4a4e27b 100644 --- a/docs/plugins/sdk-entrypoints.md +++ b/docs/plugins/sdk-entrypoints.md @@ -121,11 +121,12 @@ export default defineChannelPluginEntry({ - `setRuntime` is called during registration so you can store the runtime reference (typically via `createPluginRuntimeStore`). It is skipped during CLI metadata capture. -- `registerCliMetadata` runs during both `api.registrationMode === "cli-metadata"` - and `api.registrationMode === "full"`. +- `registerCliMetadata` runs during `api.registrationMode === "cli-metadata"`, + `api.registrationMode === "discovery"`, 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. + stays non-activating, discovery snapshots include static command metadata, and + normal CLI command registration remains compatible with full plugin loads. - `registerFull` only runs when `api.registrationMode === "full"`. It is skipped during setup-only loading. - Like `definePluginEntry`, `configSchema` can be a lazy factory and OpenClaw @@ -197,19 +198,24 @@ setter before the full channel entry loads. `api.registrationMode` tells your plugin how it was loaded: -| Mode | When | What to register | -| ----------------- | --------------------------------- | ----------------------------------------------------------------------------------------- | -| `"full"` | Normal gateway startup | Everything | -| `"setup-only"` | Disabled/unconfigured channel | Channel registration only | -| `"setup-runtime"` | Setup flow with runtime available | Channel registration plus only the lightweight runtime needed before the full entry loads | -| `"cli-metadata"` | Root help / CLI metadata capture | CLI descriptors only | +| Mode | When | What to register | +| ----------------- | --------------------------------- | ---------------------------------------------------------------------------------------------- | +| `"full"` | Normal gateway startup | Everything | +| `"discovery"` | Read-only capability discovery | Channel registration plus static CLI descriptors; skip sockets, workers, clients, and services | +| `"setup-only"` | Disabled/unconfigured channel | Channel registration only | +| `"setup-runtime"` | Setup flow with runtime available | Channel registration plus only the lightweight runtime needed before the full entry loads | +| `"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") { + if ( + api.registrationMode === "cli-metadata" || + api.registrationMode === "discovery" || + api.registrationMode === "full" + ) { api.registerCli(/* ... */); if (api.registrationMode === "cli-metadata") return; } diff --git a/extensions/matrix/index.test.ts b/extensions/matrix/index.test.ts index f1a9cc70833..c2327759679 100644 --- a/extensions/matrix/index.test.ts +++ b/extensions/matrix/index.test.ts @@ -69,6 +69,37 @@ describe("matrix plugin", () => { expect(entry.setChannelRuntime).toEqual(expect.any(Function)); }); + it("registers CLI metadata during discovery registration", () => { + const registerChannel = vi.fn(); + const registerCli = vi.fn(); + const registerGatewayMethod = vi.fn(); + const api = createTestPluginApi({ + id: "matrix", + name: "Matrix", + source: "test", + config: {}, + runtime: {} as never, + registrationMode: "discovery", + registerChannel, + registerCli, + registerGatewayMethod, + }); + + entry.register(api); + + expect(registerChannel).toHaveBeenCalledTimes(1); + expect(registerCli).toHaveBeenCalledWith(expect.any(Function), { + descriptors: [ + { + name: "matrix", + description: "Manage Matrix accounts, verification, devices, and profile state", + hasSubcommands: true, + }, + ], + }); + expect(registerGatewayMethod).not.toHaveBeenCalled(); + }); + it("registers subagent lifecycle hooks during full runtime registration", () => { const on = vi.fn(); const registerGatewayMethod = vi.fn(); diff --git a/src/plugin-sdk/channel-entry-contract.test.ts b/src/plugin-sdk/channel-entry-contract.test.ts index ea842f4f109..b8a2f11db24 100644 --- a/src/plugin-sdk/channel-entry-contract.test.ts +++ b/src/plugin-sdk/channel-entry-contract.test.ts @@ -4,7 +4,9 @@ import path from "node:path"; import { pathToFileURL } from "node:url"; import { afterEach, describe, expect, it, vi } from "vitest"; import { importFreshModule } from "../../test/helpers/import-fresh.ts"; -import { loadBundledEntryExportSync } from "./channel-entry-contract.js"; +import type { PluginRuntime } from "../plugins/runtime/types.js"; +import type { OpenClawPluginApi, PluginRegistrationMode } from "../plugins/types.js"; +import { defineBundledChannelEntry, loadBundledEntryExportSync } from "./channel-entry-contract.js"; const tempDirs: string[] = []; @@ -17,6 +19,138 @@ afterEach(() => { vi.unstubAllEnvs(); }); +function createApi(registrationMode: PluginRegistrationMode): OpenClawPluginApi { + return { + registrationMode, + runtime: { registrationMode } as unknown as PluginRuntime, + registerChannel: vi.fn(), + } as unknown as OpenClawPluginApi; +} + +function writeBundledChannelFixture(params: { + pluginRoot: string; + pluginId: string; + runtimeMarker: string; +}) { + fs.mkdirSync(params.pluginRoot, { recursive: true }); + const importerPath = path.join(params.pluginRoot, "index.js"); + fs.writeFileSync(importerPath, "export default {};\n", "utf8"); + fs.writeFileSync( + path.join(params.pluginRoot, "plugin.cjs"), + `module.exports = { + channelPlugin: { + id: ${JSON.stringify(params.pluginId)}, + meta: { + id: ${JSON.stringify(params.pluginId)}, + label: ${JSON.stringify(params.pluginId)}, + selectionLabel: ${JSON.stringify(params.pluginId)}, + docsPath: ${JSON.stringify(`/channels/${params.pluginId}`)}, + blurb: "bundled channel", + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => null, + }, + outbound: { deliveryMode: "direct" }, + }, +}; +`, + "utf8", + ); + fs.writeFileSync( + path.join(params.pluginRoot, "runtime.cjs"), + `module.exports = { + setRuntime: () => { + require("node:fs").writeFileSync(${JSON.stringify(params.runtimeMarker)}, "loaded", "utf8"); + }, +}; +`, + "utf8", + ); + return { importerPath }; +} + +function createBundledChannelEntry(params: { + importerPath: string; + pluginId: string; + registerCliMetadata?: (api: OpenClawPluginApi) => void; + registerFull?: (api: OpenClawPluginApi) => void; +}) { + return defineBundledChannelEntry({ + id: params.pluginId, + name: params.pluginId, + description: "bundled channel entry test", + importMetaUrl: pathToFileURL(params.importerPath).href, + plugin: { specifier: "./plugin.cjs", exportName: "channelPlugin" }, + runtime: { specifier: "./runtime.cjs", exportName: "setRuntime" }, + registerCliMetadata: params.registerCliMetadata, + registerFull: params.registerFull, + }); +} + +describe("defineBundledChannelEntry", () => { + it("keeps runtime sidecars out of discovery registration", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-entry-runtime-")); + tempDirs.push(tempRoot); + const runtimeMarker = path.join(tempRoot, "runtime-loaded"); + const pluginId = "bundled-discovery"; + const { importerPath } = writeBundledChannelFixture({ + pluginRoot: path.join(tempRoot, "dist", "extensions", pluginId), + pluginId, + runtimeMarker, + }); + const registerCliMetadata = vi.fn<(api: OpenClawPluginApi) => void>(); + const registerFull = vi.fn<(api: OpenClawPluginApi) => void>(); + const entry = createBundledChannelEntry({ + importerPath, + pluginId, + registerCliMetadata, + registerFull, + }); + + const api = createApi("discovery"); + entry.register(api); + + expect(api.registerChannel).toHaveBeenCalledTimes(1); + expect(registerCliMetadata).toHaveBeenCalledWith(api); + expect(registerFull).not.toHaveBeenCalled(); + expect(fs.existsSync(runtimeMarker)).toBe(false); + }); + + it("keeps setup-runtime and full registration wired to runtime sidecars", () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-entry-runtime-")); + tempDirs.push(tempRoot); + const runtimeMarker = path.join(tempRoot, "runtime-loaded"); + const pluginId = "bundled-runtime"; + const { importerPath } = writeBundledChannelFixture({ + pluginRoot: path.join(tempRoot, "dist", "extensions", pluginId), + pluginId, + runtimeMarker, + }); + const registerCliMetadata = vi.fn<(api: OpenClawPluginApi) => void>(); + const registerFull = vi.fn<(api: OpenClawPluginApi) => void>(); + const entry = createBundledChannelEntry({ + importerPath, + pluginId, + registerCliMetadata, + registerFull, + }); + + entry.register(createApi("setup-runtime")); + expect(fs.existsSync(runtimeMarker)).toBe(true); + expect(registerCliMetadata).not.toHaveBeenCalled(); + expect(registerFull).not.toHaveBeenCalled(); + + fs.rmSync(runtimeMarker, { force: true }); + const fullApi = createApi("full"); + entry.register(fullApi); + expect(fs.existsSync(runtimeMarker)).toBe(true); + expect(registerCliMetadata).toHaveBeenCalledWith(fullApi); + expect(registerFull).toHaveBeenCalledWith(fullApi); + }); +}); + async function expectBuiltArtifactNodeRequireFastPath( scope: string, artifactRoot = "dist", diff --git a/src/plugin-sdk/channel-entry-contract.ts b/src/plugin-sdk/channel-entry-contract.ts index 5f77bd4d9d9..8674d489f7b 100644 --- a/src/plugin-sdk/channel-entry-contract.ts +++ b/src/plugin-sdk/channel-entry-contract.ts @@ -483,11 +483,15 @@ export function defineBundledChannelEntry({ return; } const profile = createProfiler({ pluginId: id, source: importMetaUrl }); - profile("bundled-register:setChannelRuntime", () => setChannelRuntime?.(api.runtime)); const channelPlugin = profile("bundled-register:loadChannelPlugin", loadChannelPlugin); profile("bundled-register:registerChannel", () => api.registerChannel({ plugin: channelPlugin as ChannelPlugin }), ); + if (api.registrationMode === "discovery") { + profile("bundled-register:registerCliMetadata", () => registerCliMetadata?.(api)); + return; + } + profile("bundled-register:setChannelRuntime", () => setChannelRuntime?.(api.runtime)); if (api.registrationMode !== "full") { return; } diff --git a/src/plugin-sdk/core.test.ts b/src/plugin-sdk/core.test.ts new file mode 100644 index 00000000000..1e21133393c --- /dev/null +++ b/src/plugin-sdk/core.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it, vi } from "vitest"; +import type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; +import type { PluginRuntime } from "../plugins/runtime/types.js"; +import type { OpenClawPluginApi, PluginRegistrationMode } from "../plugins/types.js"; +import { defineChannelPluginEntry } from "./core.js"; + +function createChannelPlugin(id: string): ChannelPlugin { + return { + id, + meta: { + id, + label: id, + selectionLabel: id, + docsPath: `/channels/${id}`, + blurb: `${id} channel`, + }, + capabilities: { chatTypes: ["direct"] }, + config: { + listAccountIds: () => [], + resolveAccount: () => null, + }, + outbound: { deliveryMode: "direct" }, + }; +} + +function createApi(registrationMode: PluginRegistrationMode): OpenClawPluginApi { + return { + registrationMode, + runtime: { registrationMode } as unknown as PluginRuntime, + registerChannel: vi.fn(), + } as unknown as OpenClawPluginApi; +} + +describe("defineChannelPluginEntry", () => { + it("keeps runtime helpers out of discovery registration", () => { + const setRuntime = vi.fn<(runtime: PluginRuntime) => void>(); + const registerCliMetadata = vi.fn<(api: OpenClawPluginApi) => void>(); + const registerFull = vi.fn<(api: OpenClawPluginApi) => void>(); + const entry = defineChannelPluginEntry({ + id: "runtime-discovery", + name: "Runtime Discovery", + description: "runtime discovery test", + plugin: createChannelPlugin("runtime-discovery"), + setRuntime, + registerCliMetadata, + registerFull, + }); + + const api = createApi("discovery"); + entry.register(api); + + expect(api.registerChannel).toHaveBeenCalledTimes(1); + expect(registerCliMetadata).toHaveBeenCalledTimes(1); + expect(setRuntime).not.toHaveBeenCalled(); + expect(registerFull).not.toHaveBeenCalled(); + }); + + it("keeps setup-runtime and full registration wired to runtime helpers", () => { + const setRuntime = vi.fn<(runtime: PluginRuntime) => void>(); + const registerCliMetadata = vi.fn<(api: OpenClawPluginApi) => void>(); + const registerFull = vi.fn<(api: OpenClawPluginApi) => void>(); + const entry = defineChannelPluginEntry({ + id: "runtime-activation", + name: "Runtime Activation", + description: "runtime activation test", + plugin: createChannelPlugin("runtime-activation"), + setRuntime, + registerCliMetadata, + registerFull, + }); + + const setupApi = createApi("setup-runtime"); + entry.register(setupApi); + expect(setRuntime).toHaveBeenCalledWith(setupApi.runtime); + expect(registerCliMetadata).not.toHaveBeenCalled(); + expect(registerFull).not.toHaveBeenCalled(); + + setRuntime.mockClear(); + const fullApi = createApi("full"); + entry.register(fullApi); + expect(setRuntime).toHaveBeenCalledWith(fullApi.runtime); + expect(registerCliMetadata).toHaveBeenCalledWith(fullApi); + expect(registerFull).toHaveBeenCalledWith(fullApi); + }); +}); diff --git a/src/plugin-sdk/core.ts b/src/plugin-sdk/core.ts index 61000fb1285..6d0cd751a5e 100644 --- a/src/plugin-sdk/core.ts +++ b/src/plugin-sdk/core.ts @@ -503,8 +503,12 @@ export function defineChannelPluginEntry({ registerCliMetadata?.(api); return; } - setRuntime?.(api.runtime); api.registerChannel({ plugin: plugin as ChannelPlugin }); + if (api.registrationMode === "discovery") { + registerCliMetadata?.(api); + return; + } + setRuntime?.(api.runtime); if (api.registrationMode !== "full") { return; } diff --git a/src/plugins/loader.cli-metadata.test.ts b/src/plugins/loader.cli-metadata.test.ts index c13a705a730..ade86de32bd 100644 --- a/src/plugins/loader.cli-metadata.test.ts +++ b/src/plugins/loader.cli-metadata.test.ts @@ -549,6 +549,108 @@ module.exports = { ); }); + it("collects channel CLI metadata during discovery 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/discovery-cli-metadata-channel", + openclaw: { extensions: ["./index.cjs"] }, + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "openclaw.plugin.json"), + JSON.stringify( + { + id: "discovery-cli-metadata-channel", + configSchema: EMPTY_PLUGIN_SCHEMA, + channels: ["discovery-cli-metadata-channel"], + }, + null, + 2, + ), + "utf-8", + ); + fs.writeFileSync( + path.join(pluginDir, "index.cjs"), + `${inlineChannelPluginEntryFactorySource()} +module.exports = { + ...defineChannelPluginEntry({ + id: "discovery-cli-metadata-channel", + name: "Discovery CLI Metadata Channel", + description: "discovery cli metadata channel", + plugin: { + id: "discovery-cli-metadata-channel", + meta: { + id: "discovery-cli-metadata-channel", + label: "Discovery CLI Metadata Channel", + selectionLabel: "Discovery CLI Metadata Channel", + docsPath: "/channels/discovery-cli-metadata-channel", + blurb: "discovery 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: "discovery-cli-metadata-channel", + description: "Discovery-load channel CLI metadata", + hasSubcommands: true, + }, + ], + }); + }, + registerFull() { + require("node:fs").writeFileSync(${JSON.stringify(fullMarker)}, "loaded", "utf-8"); + }, + }), +};`, + "utf-8", + ); + + const registry = loadOpenClawPlugins({ + activate: false, + cache: false, + config: { + plugins: { + load: { paths: [pluginDir] }, + allow: ["discovery-cli-metadata-channel"], + entries: { + "discovery-cli-metadata-channel": { + enabled: true, + }, + }, + }, + }, + }); + + expect(fs.readFileSync(modeMarker, "utf-8")).toBe("discovery"); + expect(fs.existsSync(fullMarker)).toBe(false); + expect(registry.cliRegistrars.flatMap((entry) => entry.commands)).toContain( + "discovery-cli-metadata-channel", + ); + }); + it("rejects async plugin registration when collecting CLI metadata", async () => { useNoBundledPlugins(); const plugin = writePlugin({ diff --git a/src/plugins/loader.test-fixtures.ts b/src/plugins/loader.test-fixtures.ts index 49f53dbd507..27204781988 100644 --- a/src/plugins/loader.test-fixtures.ts +++ b/src/plugins/loader.test-fixtures.ts @@ -57,6 +57,10 @@ export function inlineChannelPluginEntryFactorySource(): string { } options.setRuntime?.(api.runtime); api.registerChannel({ plugin: options.plugin }); + if (api.registrationMode === "discovery") { + options.registerCliMetadata?.(api); + return; + } if (api.registrationMode !== "full") { return; }