mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-30 14:02:56 +08:00
fix(plugins): expose channel CLI metadata in discovery (#71309)
Merged via squash.
Prepared head SHA: ce6bb35812
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:
committed by
GitHub
parent
7ef4ecf499
commit
a5db42862d
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -483,11 +483,15 @@ export function defineBundledChannelEntry<TPlugin = ChannelPlugin>({
|
||||
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;
|
||||
}
|
||||
|
||||
85
src/plugin-sdk/core.test.ts
Normal file
85
src/plugin-sdk/core.test.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -503,8 +503,12 @@ export function defineChannelPluginEntry<TPlugin>({
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user