mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-17 04:50:51 +00:00
fix(onboarding): use scoped plugin snapshots to prevent OOM on low-memory hosts (#46763)
* fix(onboarding): use scoped plugin snapshots to prevent OOM on low-memory hosts Onboarding and channel-add flows previously loaded the full plugin registry, which caused OOM crashes on memory-constrained hosts. This patch introduces scoped, non-activating plugin registry snapshots that load only the selected channel plugin without replacing the running gateway's global state. Key changes: - Add onlyPluginIds and activate options to loadOpenClawPlugins for scoped loads - Add suppressGlobalCommands to plugin registry to avoid leaking commands - Replace full registry reloads in onboarding with per-channel scoped snapshots - Validate command definitions in snapshot loads without writing global registry - Preload configured external plugins via scoped discovery during onboarding Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> * fix(test): add return type annotation to hoisted mock to resolve TS2322 * fix(plugins): enforce cache:false invariant for non-activating snapshot loads * Channels: preserve lazy scoped snapshot import after rebase * Onboarding: scope channel snapshots by plugin id * Catalog: trust manifest ids for channel plugin mapping * Onboarding: preserve scoped setup channel loading * Onboarding: restore built-in adapter fallback --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
@@ -2,6 +2,7 @@ import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { MANIFEST_KEY } from "../../compat/legacy-names.js";
|
||||
import { discoverOpenClawPlugins } from "../../plugins/discovery.js";
|
||||
import { loadPluginManifest } from "../../plugins/manifest.js";
|
||||
import type { OpenClawPackageManifest } from "../../plugins/manifest.js";
|
||||
import type { PluginOrigin } from "../../plugins/types.js";
|
||||
import { isRecord, resolveConfigDir, resolveUserPath } from "../../utils.js";
|
||||
@@ -25,6 +26,7 @@ export type ChannelUiCatalog = {
|
||||
|
||||
export type ChannelPluginCatalogEntry = {
|
||||
id: string;
|
||||
pluginId?: string;
|
||||
meta: ChannelMeta;
|
||||
install: {
|
||||
npmSpec: string;
|
||||
@@ -196,9 +198,26 @@ function resolveInstallInfo(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCatalogPluginId(params: {
|
||||
packageDir?: string;
|
||||
rootDir?: string;
|
||||
origin?: PluginOrigin;
|
||||
}): string | undefined {
|
||||
const manifestDir = params.packageDir ?? params.rootDir;
|
||||
if (manifestDir) {
|
||||
const manifest = loadPluginManifest(manifestDir, params.origin !== "bundled");
|
||||
if (manifest.ok) {
|
||||
return manifest.manifest.id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function buildCatalogEntry(candidate: {
|
||||
packageName?: string;
|
||||
packageDir?: string;
|
||||
rootDir?: string;
|
||||
origin?: PluginOrigin;
|
||||
workspaceDir?: string;
|
||||
packageManifest?: OpenClawPackageManifest;
|
||||
}): ChannelPluginCatalogEntry | null {
|
||||
@@ -223,7 +242,17 @@ function buildCatalogEntry(candidate: {
|
||||
if (!install) {
|
||||
return null;
|
||||
}
|
||||
return { id, meta, install };
|
||||
const pluginId = resolveCatalogPluginId({
|
||||
packageDir: candidate.packageDir,
|
||||
rootDir: candidate.rootDir,
|
||||
origin: candidate.origin,
|
||||
});
|
||||
return {
|
||||
id,
|
||||
...(pluginId ? { pluginId } : {}),
|
||||
meta,
|
||||
install,
|
||||
};
|
||||
}
|
||||
|
||||
function buildExternalCatalogEntry(entry: ExternalCatalogEntry): ChannelPluginCatalogEntry | null {
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { DmPolicy } from "../../config/types.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import type { WizardPrompter } from "../../wizard/prompts.js";
|
||||
import type { ChannelId } from "./types.js";
|
||||
import type { ChannelId, ChannelPlugin } from "./types.js";
|
||||
|
||||
export type SetupChannelsOptions = {
|
||||
allowDisable?: boolean;
|
||||
@@ -10,6 +10,7 @@ export type SetupChannelsOptions = {
|
||||
onSelection?: (selection: ChannelId[]) => void;
|
||||
accountIds?: Partial<Record<ChannelId, string>>;
|
||||
onAccountId?: (channel: ChannelId, accountId: string) => void;
|
||||
onResolvedPlugin?: (channel: ChannelId, plugin: ChannelPlugin) => void;
|
||||
promptAccountIds?: boolean;
|
||||
whatsappAccountId?: string;
|
||||
promptWhatsAppAccountId?: boolean;
|
||||
|
||||
@@ -154,6 +154,50 @@ describe("channel plugin catalog", () => {
|
||||
expect(ids).toContain("demo-channel");
|
||||
});
|
||||
|
||||
it("preserves plugin ids when they differ from channel ids", () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-catalog-state-"));
|
||||
const pluginDir = path.join(stateDir, "extensions", "demo-channel-plugin");
|
||||
fs.mkdirSync(pluginDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "package.json"),
|
||||
JSON.stringify({
|
||||
name: "@vendor/demo-channel-plugin",
|
||||
openclaw: {
|
||||
extensions: ["./index.js"],
|
||||
channel: {
|
||||
id: "demo-channel",
|
||||
label: "Demo Channel",
|
||||
selectionLabel: "Demo Channel",
|
||||
docsPath: "/channels/demo-channel",
|
||||
blurb: "Demo channel",
|
||||
},
|
||||
install: {
|
||||
npmSpec: "@vendor/demo-channel-plugin",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(pluginDir, "openclaw.plugin.json"),
|
||||
JSON.stringify({
|
||||
id: "@vendor/demo-runtime",
|
||||
configSchema: {},
|
||||
}),
|
||||
);
|
||||
fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {}", "utf-8");
|
||||
|
||||
const entry = listChannelPluginCatalogEntries({
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
CLAWDBOT_STATE_DIR: undefined,
|
||||
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
|
||||
},
|
||||
}).find((item) => item.id === "demo-channel");
|
||||
|
||||
expect(entry?.pluginId).toBe("@vendor/demo-runtime");
|
||||
});
|
||||
|
||||
it("uses the provided env for external catalog path resolution", () => {
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-home-"));
|
||||
const catalogPath = path.join(home, "catalog.json");
|
||||
|
||||
@@ -1,8 +1,36 @@
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import { setDefaultChannelPluginRegistryForTests } from "./channel-test-helpers.js";
|
||||
import { configMocks, offsetMocks } from "./channels.mock-harness.js";
|
||||
import {
|
||||
ensureOnboardingPluginInstalled,
|
||||
loadOnboardingPluginRegistrySnapshotForChannel,
|
||||
} from "./onboarding/plugin-install.js";
|
||||
import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js";
|
||||
|
||||
const catalogMocks = vi.hoisted(() => ({
|
||||
listChannelPluginCatalogEntries: vi.fn((): ChannelPluginCatalogEntry[] => []),
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/catalog.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../channels/plugins/catalog.js")>();
|
||||
return {
|
||||
...actual,
|
||||
listChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./onboarding/plugin-install.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("./onboarding/plugin-install.js")>();
|
||||
return {
|
||||
...actual,
|
||||
ensureOnboardingPluginInstalled: vi.fn(async ({ cfg }) => ({ cfg, installed: true })),
|
||||
loadOnboardingPluginRegistrySnapshotForChannel: vi.fn(() => createTestRegistry()),
|
||||
};
|
||||
});
|
||||
|
||||
const runtime = createTestRuntime();
|
||||
let channelsAddCommand: typeof import("./channels.js").channelsAddCommand;
|
||||
|
||||
@@ -18,6 +46,15 @@ describe("channelsAddCommand", () => {
|
||||
runtime.log.mockClear();
|
||||
runtime.error.mockClear();
|
||||
runtime.exit.mockClear();
|
||||
catalogMocks.listChannelPluginCatalogEntries.mockClear();
|
||||
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([]);
|
||||
vi.mocked(ensureOnboardingPluginInstalled).mockClear();
|
||||
vi.mocked(ensureOnboardingPluginInstalled).mockImplementation(async ({ cfg }) => ({
|
||||
cfg,
|
||||
installed: true,
|
||||
}));
|
||||
vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockClear();
|
||||
vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockReturnValue(createTestRegistry());
|
||||
setDefaultChannelPluginRegistryForTests();
|
||||
});
|
||||
|
||||
@@ -59,4 +96,149 @@ describe("channelsAddCommand", () => {
|
||||
|
||||
expect(offsetMocks.deleteTelegramUpdateOffset).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("falls back to a scoped snapshot after installing an external channel plugin", async () => {
|
||||
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
|
||||
setActivePluginRegistry(createTestRegistry());
|
||||
const catalogEntry: ChannelPluginCatalogEntry = {
|
||||
id: "msteams",
|
||||
pluginId: "@openclaw/msteams-plugin",
|
||||
meta: {
|
||||
id: "msteams",
|
||||
label: "Microsoft Teams",
|
||||
selectionLabel: "Microsoft Teams",
|
||||
docsPath: "/channels/msteams",
|
||||
blurb: "teams channel",
|
||||
},
|
||||
install: {
|
||||
npmSpec: "@openclaw/msteams",
|
||||
},
|
||||
};
|
||||
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]);
|
||||
const scopedMSTeamsPlugin = {
|
||||
...createChannelTestPluginBase({
|
||||
id: "msteams",
|
||||
label: "Microsoft Teams",
|
||||
docsPath: "/channels/msteams",
|
||||
}),
|
||||
setup: {
|
||||
applyAccountConfig: vi.fn(({ cfg, input }) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: {
|
||||
enabled: true,
|
||||
tenantId: input.token,
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
};
|
||||
vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockReturnValue(
|
||||
createTestRegistry([{ pluginId: "msteams", plugin: scopedMSTeamsPlugin, source: "test" }]),
|
||||
);
|
||||
|
||||
await channelsAddCommand(
|
||||
{
|
||||
channel: "msteams",
|
||||
account: "default",
|
||||
token: "tenant-scoped",
|
||||
},
|
||||
runtime,
|
||||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
expect(ensureOnboardingPluginInstalled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ entry: catalogEntry }),
|
||||
);
|
||||
expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "msteams",
|
||||
pluginId: "@openclaw/msteams-plugin",
|
||||
}),
|
||||
);
|
||||
expect(configMocks.writeConfigFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channels: {
|
||||
msteams: {
|
||||
enabled: true,
|
||||
tenantId: "tenant-scoped",
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses the installed plugin id when channel and plugin ids differ", async () => {
|
||||
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
|
||||
setActivePluginRegistry(createTestRegistry());
|
||||
const catalogEntry: ChannelPluginCatalogEntry = {
|
||||
id: "msteams",
|
||||
pluginId: "@openclaw/msteams-plugin",
|
||||
meta: {
|
||||
id: "msteams",
|
||||
label: "Microsoft Teams",
|
||||
selectionLabel: "Microsoft Teams",
|
||||
docsPath: "/channels/msteams",
|
||||
blurb: "teams channel",
|
||||
},
|
||||
install: {
|
||||
npmSpec: "@openclaw/msteams",
|
||||
},
|
||||
};
|
||||
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]);
|
||||
vi.mocked(ensureOnboardingPluginInstalled).mockImplementation(async ({ cfg }) => ({
|
||||
cfg,
|
||||
installed: true,
|
||||
pluginId: "@vendor/teams-runtime",
|
||||
}));
|
||||
vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockReturnValue(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "@vendor/teams-runtime",
|
||||
plugin: {
|
||||
...createChannelTestPluginBase({
|
||||
id: "msteams",
|
||||
label: "Microsoft Teams",
|
||||
docsPath: "/channels/msteams",
|
||||
}),
|
||||
setup: {
|
||||
applyAccountConfig: vi.fn(({ cfg, input }) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: {
|
||||
enabled: true,
|
||||
tenantId: input.token,
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
},
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
await channelsAddCommand(
|
||||
{
|
||||
channel: "msteams",
|
||||
account: "default",
|
||||
token: "tenant-scoped",
|
||||
},
|
||||
runtime,
|
||||
{ hasFlags: true },
|
||||
);
|
||||
|
||||
expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "msteams",
|
||||
pluginId: "@vendor/teams-runtime",
|
||||
}),
|
||||
);
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
expect(runtime.exit).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getChannelPlugin } from "../../channels/plugins/index.js";
|
||||
import type { ChannelId, ChannelSetupInput } from "../../channels/plugins/types.js";
|
||||
import type { ChannelId, ChannelPlugin, ChannelSetupInput } from "../../channels/plugins/types.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { normalizeAccountId } from "../../routing/session-key.js";
|
||||
|
||||
@@ -10,9 +10,10 @@ export function applyAccountName(params: {
|
||||
channel: ChatChannel;
|
||||
accountId: string;
|
||||
name?: string;
|
||||
plugin?: ChannelPlugin;
|
||||
}): OpenClawConfig {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const plugin = getChannelPlugin(params.channel);
|
||||
const plugin = params.plugin ?? getChannelPlugin(params.channel);
|
||||
const apply = plugin?.setup?.applyAccountName;
|
||||
return apply ? apply({ cfg: params.cfg, accountId, name: params.name }) : params.cfg;
|
||||
}
|
||||
@@ -22,9 +23,10 @@ export function applyChannelAccountConfig(params: {
|
||||
channel: ChatChannel;
|
||||
accountId: string;
|
||||
input: ChannelSetupInput;
|
||||
plugin?: ChannelPlugin;
|
||||
}): OpenClawConfig {
|
||||
const accountId = normalizeAccountId(params.accountId);
|
||||
const plugin = getChannelPlugin(params.channel);
|
||||
const plugin = params.plugin ?? getChannelPlugin(params.channel);
|
||||
const apply = plugin?.setup?.applyAccountConfig;
|
||||
if (!apply) {
|
||||
return params.cfg;
|
||||
|
||||
@@ -3,7 +3,7 @@ import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.
|
||||
import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js";
|
||||
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
|
||||
import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js";
|
||||
import type { ChannelId, ChannelSetupInput } from "../../channels/plugins/types.js";
|
||||
import type { ChannelId, ChannelPlugin, ChannelSetupInput } from "../../channels/plugins/types.js";
|
||||
import { writeConfigFile, type OpenClawConfig } from "../../config/config.js";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
|
||||
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
|
||||
@@ -55,6 +55,7 @@ export async function channelsAddCommand(
|
||||
const prompter = createClackPrompter();
|
||||
let selection: ChannelChoice[] = [];
|
||||
const accountIds: Partial<Record<ChannelChoice, string>> = {};
|
||||
const resolvedPlugins = new Map<ChannelChoice, ChannelPlugin>();
|
||||
await prompter.intro("Channel setup");
|
||||
let nextConfig = await setupChannels(cfg, runtime, prompter, {
|
||||
allowDisable: false,
|
||||
@@ -66,6 +67,9 @@ export async function channelsAddCommand(
|
||||
onAccountId: (channel, accountId) => {
|
||||
accountIds[channel] = accountId;
|
||||
},
|
||||
onResolvedPlugin: (channel, plugin) => {
|
||||
resolvedPlugins.set(channel, plugin);
|
||||
},
|
||||
});
|
||||
if (selection.length === 0) {
|
||||
await prompter.outro("No channels selected.");
|
||||
@@ -79,7 +83,7 @@ export async function channelsAddCommand(
|
||||
if (wantsNames) {
|
||||
for (const channel of selection) {
|
||||
const accountId = accountIds[channel] ?? DEFAULT_ACCOUNT_ID;
|
||||
const plugin = getChannelPlugin(channel);
|
||||
const plugin = resolvedPlugins.get(channel) ?? getChannelPlugin(channel);
|
||||
const account = plugin?.config.resolveAccount(nextConfig, accountId) as
|
||||
| { name?: string }
|
||||
| undefined;
|
||||
@@ -95,6 +99,7 @@ export async function channelsAddCommand(
|
||||
channel,
|
||||
accountId,
|
||||
name,
|
||||
plugin,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -170,12 +175,33 @@ export async function channelsAddCommand(
|
||||
const rawChannel = String(opts.channel ?? "");
|
||||
let channel = normalizeChannelId(rawChannel);
|
||||
let catalogEntry = channel ? undefined : resolveCatalogChannelEntry(rawChannel, nextConfig);
|
||||
const resolveWorkspaceDir = () =>
|
||||
resolveAgentWorkspaceDir(nextConfig, resolveDefaultAgentId(nextConfig));
|
||||
// May trigger loadOpenClawPlugins on cache miss (disk scan + jiti import)
|
||||
const loadScopedPlugin = async (
|
||||
channelId: ChannelId,
|
||||
pluginId?: string,
|
||||
): Promise<ChannelPlugin | undefined> => {
|
||||
const existing = getChannelPlugin(channelId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const { loadOnboardingPluginRegistrySnapshotForChannel } =
|
||||
await import("../onboarding/plugin-install.js");
|
||||
const snapshot = loadOnboardingPluginRegistrySnapshotForChannel({
|
||||
cfg: nextConfig,
|
||||
runtime,
|
||||
channel: channelId,
|
||||
...(pluginId ? { pluginId } : {}),
|
||||
workspaceDir: resolveWorkspaceDir(),
|
||||
});
|
||||
return snapshot.channels.find((entry) => entry.plugin.id === channelId)?.plugin;
|
||||
};
|
||||
|
||||
if (!channel && catalogEntry) {
|
||||
const { ensureOnboardingPluginInstalled, reloadOnboardingPluginRegistry } =
|
||||
await import("../onboarding/plugin-install.js");
|
||||
const { ensureOnboardingPluginInstalled } = await import("../onboarding/plugin-install.js");
|
||||
const prompter = createClackPrompter();
|
||||
const workspaceDir = resolveAgentWorkspaceDir(nextConfig, resolveDefaultAgentId(nextConfig));
|
||||
const workspaceDir = resolveWorkspaceDir();
|
||||
const result = await ensureOnboardingPluginInstalled({
|
||||
cfg: nextConfig,
|
||||
entry: catalogEntry,
|
||||
@@ -187,7 +213,10 @@ export async function channelsAddCommand(
|
||||
if (!result.installed) {
|
||||
return;
|
||||
}
|
||||
reloadOnboardingPluginRegistry({ cfg: nextConfig, runtime, workspaceDir });
|
||||
catalogEntry = {
|
||||
...catalogEntry,
|
||||
...(result.pluginId ? { pluginId: result.pluginId } : {}),
|
||||
};
|
||||
channel = normalizeChannelId(catalogEntry.id) ?? (catalogEntry.id as ChannelId);
|
||||
}
|
||||
|
||||
@@ -200,7 +229,7 @@ export async function channelsAddCommand(
|
||||
return;
|
||||
}
|
||||
|
||||
const plugin = getChannelPlugin(channel);
|
||||
const plugin = await loadScopedPlugin(channel, catalogEntry?.pluginId);
|
||||
if (!plugin?.setup?.applyAccountConfig) {
|
||||
runtime.error(`Channel ${channel} does not support add.`);
|
||||
runtime.exit(1);
|
||||
@@ -294,6 +323,7 @@ export async function channelsAddCommand(
|
||||
channel,
|
||||
accountId,
|
||||
input,
|
||||
plugin,
|
||||
});
|
||||
|
||||
if (channel === "telegram" && resolveTelegramAccount) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { createEmptyPluginRegistry } from "../plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
@@ -8,8 +9,16 @@ import {
|
||||
setDefaultChannelPluginRegistryForTests,
|
||||
} from "./channel-test-helpers.js";
|
||||
import { setupChannels } from "./onboard-channels.js";
|
||||
import {
|
||||
loadOnboardingPluginRegistrySnapshotForChannel,
|
||||
reloadOnboardingPluginRegistry,
|
||||
} from "./onboarding/plugin-install.js";
|
||||
import { createExitThrowingRuntime, createWizardPrompter } from "./test-wizard-helpers.js";
|
||||
|
||||
const catalogMocks = vi.hoisted(() => ({
|
||||
listChannelPluginCatalogEntries: vi.fn(),
|
||||
}));
|
||||
|
||||
function createPrompter(overrides: Partial<WizardPrompter>): WizardPrompter {
|
||||
return createWizardPrompter(
|
||||
{
|
||||
@@ -174,6 +183,20 @@ vi.mock("../channel-web.js", () => ({
|
||||
loginWeb: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/catalog.js", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../channels/plugins/catalog.js")>();
|
||||
return {
|
||||
...actual,
|
||||
listChannelPluginCatalogEntries: ((...args) => {
|
||||
const implementation = catalogMocks.listChannelPluginCatalogEntries.getMockImplementation();
|
||||
if (implementation) {
|
||||
return catalogMocks.listChannelPluginCatalogEntries(...args);
|
||||
}
|
||||
return actual.listChannelPluginCatalogEntries(...args);
|
||||
}) as typeof actual.listChannelPluginCatalogEntries,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./onboard-helpers.js", () => ({
|
||||
detectBinary: vi.fn(async () => false),
|
||||
}));
|
||||
@@ -183,6 +206,7 @@ vi.mock("./onboarding/plugin-install.js", async (importOriginal) => {
|
||||
return {
|
||||
...(actual as Record<string, unknown>),
|
||||
// Allow tests to simulate an empty plugin registry during onboarding.
|
||||
loadOnboardingPluginRegistrySnapshotForChannel: vi.fn(() => createEmptyPluginRegistry()),
|
||||
reloadOnboardingPluginRegistry: vi.fn(() => {}),
|
||||
};
|
||||
});
|
||||
@@ -190,6 +214,9 @@ vi.mock("./onboarding/plugin-install.js", async (importOriginal) => {
|
||||
describe("setupChannels", () => {
|
||||
beforeEach(() => {
|
||||
setDefaultChannelPluginRegistryForTests();
|
||||
catalogMocks.listChannelPluginCatalogEntries.mockReset();
|
||||
vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockClear();
|
||||
vi.mocked(reloadOnboardingPluginRegistry).mockClear();
|
||||
});
|
||||
it("QuickStart uses single-select (no multiselect) and doesn't prompt for Telegram token when WhatsApp is chosen", async () => {
|
||||
const select = vi.fn(async () => "whatsapp");
|
||||
@@ -257,6 +284,12 @@ describe("setupChannels", () => {
|
||||
);
|
||||
});
|
||||
expect(sawHardStop).toBe(false);
|
||||
expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "telegram",
|
||||
}),
|
||||
);
|
||||
expect(reloadOnboardingPluginRegistry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("shows explicit dmScope config command in channel primer", async () => {
|
||||
@@ -282,6 +315,243 @@ describe("setupChannels", () => {
|
||||
expect(multiselect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps configured external plugin channels visible when the active registry starts empty", async () => {
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([
|
||||
{
|
||||
id: "msteams",
|
||||
pluginId: "@openclaw/msteams-plugin",
|
||||
meta: {
|
||||
id: "msteams",
|
||||
label: "Microsoft Teams",
|
||||
selectionLabel: "Microsoft Teams",
|
||||
docsPath: "/channels/msteams",
|
||||
blurb: "teams channel",
|
||||
},
|
||||
install: {
|
||||
npmSpec: "@openclaw/msteams",
|
||||
},
|
||||
} satisfies ChannelPluginCatalogEntry,
|
||||
]);
|
||||
vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockImplementation(
|
||||
({ channel }: { channel: string }) => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
if (channel === "msteams") {
|
||||
registry.channels.push({
|
||||
pluginId: "@openclaw/msteams-plugin",
|
||||
source: "test",
|
||||
plugin: {
|
||||
id: "msteams",
|
||||
meta: {
|
||||
id: "msteams",
|
||||
label: "Microsoft Teams",
|
||||
selectionLabel: "Microsoft Teams",
|
||||
docsPath: "/channels/msteams",
|
||||
blurb: "teams channel",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: () => [],
|
||||
resolveAccount: () => ({ accountId: "default" }),
|
||||
},
|
||||
outbound: { deliveryMode: "direct" },
|
||||
},
|
||||
} as never);
|
||||
}
|
||||
return registry;
|
||||
},
|
||||
);
|
||||
const select = vi.fn(async ({ message, options }: { message: string; options: unknown[] }) => {
|
||||
if (message === "Select a channel") {
|
||||
const entries = options as Array<{ value: string; hint?: string }>;
|
||||
const msteams = entries.find((entry) => entry.value === "msteams");
|
||||
expect(msteams).toBeDefined();
|
||||
expect(msteams?.hint ?? "").not.toContain("plugin");
|
||||
expect(msteams?.hint ?? "").not.toContain("install");
|
||||
return "__done__";
|
||||
}
|
||||
return "__done__";
|
||||
});
|
||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
||||
const prompter = createPrompter({
|
||||
select: select as unknown as WizardPrompter["select"],
|
||||
multiselect,
|
||||
text,
|
||||
});
|
||||
|
||||
await runSetupChannels(
|
||||
{
|
||||
channels: {
|
||||
msteams: {
|
||||
tenantId: "tenant-1",
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
"@openclaw/msteams-plugin": { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
prompter,
|
||||
);
|
||||
|
||||
expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channel: "msteams",
|
||||
pluginId: "@openclaw/msteams-plugin",
|
||||
}),
|
||||
);
|
||||
expect(multiselect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses scoped plugin accounts when disabling a configured external channel", async () => {
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
const setAccountEnabled = vi.fn(
|
||||
({
|
||||
cfg,
|
||||
accountId,
|
||||
enabled,
|
||||
}: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
}) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
msteams: {
|
||||
...(cfg.channels?.msteams as Record<string, unknown> | undefined),
|
||||
accounts: {
|
||||
...(cfg.channels?.msteams as { accounts?: Record<string, unknown> } | undefined)
|
||||
?.accounts,
|
||||
[accountId]: {
|
||||
...(
|
||||
cfg.channels?.msteams as
|
||||
| {
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
| undefined
|
||||
)?.accounts?.[accountId],
|
||||
enabled,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockImplementation(
|
||||
({ channel }: { channel: string }) => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
if (channel === "msteams") {
|
||||
registry.channels.push({
|
||||
pluginId: "msteams",
|
||||
source: "test",
|
||||
plugin: {
|
||||
id: "msteams",
|
||||
meta: {
|
||||
id: "msteams",
|
||||
label: "Microsoft Teams",
|
||||
selectionLabel: "Microsoft Teams",
|
||||
docsPath: "/channels/msteams",
|
||||
blurb: "teams channel",
|
||||
},
|
||||
capabilities: { chatTypes: ["direct"] },
|
||||
config: {
|
||||
listAccountIds: (cfg: OpenClawConfig) =>
|
||||
Object.keys(
|
||||
(cfg.channels?.msteams as { accounts?: Record<string, unknown> } | undefined)
|
||||
?.accounts ?? {},
|
||||
),
|
||||
resolveAccount: (cfg: OpenClawConfig, accountId: string) =>
|
||||
(
|
||||
cfg.channels?.msteams as
|
||||
| {
|
||||
accounts?: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
| undefined
|
||||
)?.accounts?.[accountId] ?? { accountId },
|
||||
setAccountEnabled,
|
||||
},
|
||||
onboarding: {
|
||||
getStatus: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
|
||||
channel: "msteams",
|
||||
configured: Boolean(
|
||||
(cfg.channels?.msteams as { tenantId?: string } | undefined)?.tenantId,
|
||||
),
|
||||
statusLines: [],
|
||||
selectionHint: "configured",
|
||||
})),
|
||||
},
|
||||
outbound: { deliveryMode: "direct" },
|
||||
},
|
||||
} as never);
|
||||
}
|
||||
return registry;
|
||||
},
|
||||
);
|
||||
|
||||
let channelSelectionCount = 0;
|
||||
const select = vi.fn(async ({ message, options }: { message: string; options: unknown[] }) => {
|
||||
if (message === "Select a channel") {
|
||||
channelSelectionCount += 1;
|
||||
return channelSelectionCount === 1 ? "msteams" : "__done__";
|
||||
}
|
||||
if (message.includes("already configured")) {
|
||||
return "disable";
|
||||
}
|
||||
if (message === "Microsoft Teams account") {
|
||||
const accountOptions = options as Array<{ value: string; label: string }>;
|
||||
expect(accountOptions.map((option) => option.value)).toEqual(["default", "work"]);
|
||||
return "work";
|
||||
}
|
||||
return "__done__";
|
||||
});
|
||||
const { multiselect, text } = createUnexpectedPromptGuards();
|
||||
const prompter = createPrompter({
|
||||
select: select as unknown as WizardPrompter["select"],
|
||||
multiselect,
|
||||
text,
|
||||
});
|
||||
|
||||
const next = await runSetupChannels(
|
||||
{
|
||||
channels: {
|
||||
msteams: {
|
||||
tenantId: "tenant-1",
|
||||
accounts: {
|
||||
default: { enabled: true },
|
||||
work: { enabled: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
entries: {
|
||||
msteams: { enabled: true },
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig,
|
||||
prompter,
|
||||
{ allowDisable: true },
|
||||
);
|
||||
|
||||
expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ channel: "msteams" }),
|
||||
);
|
||||
expect(setAccountEnabled).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ accountId: "work", enabled: false }),
|
||||
);
|
||||
expect(
|
||||
(
|
||||
next.channels?.msteams as
|
||||
| {
|
||||
accounts?: Record<string, { enabled?: boolean }>;
|
||||
}
|
||||
| undefined
|
||||
)?.accounts?.work?.enabled,
|
||||
).toBe(false);
|
||||
expect(multiselect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("prompts for configured channel action and skips configuration when told to skip", async () => {
|
||||
const select = createQuickstartTelegramSelect({
|
||||
configuredAction: "skip",
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
getChannelSetupPlugin,
|
||||
listChannelSetupPlugins,
|
||||
} from "../channels/plugins/setup-registry.js";
|
||||
import type { ChannelMeta } from "../channels/plugins/types.js";
|
||||
import type { ChannelMeta, ChannelPlugin } from "../channels/plugins/types.js";
|
||||
import {
|
||||
formatChannelPrimerLine,
|
||||
formatChannelSelectionLine,
|
||||
@@ -23,13 +23,14 @@ import type { WizardPrompter, WizardSelectOption } from "../wizard/prompts.js";
|
||||
import type { ChannelChoice } from "./onboard-types.js";
|
||||
import {
|
||||
ensureOnboardingPluginInstalled,
|
||||
reloadOnboardingPluginRegistry,
|
||||
loadOnboardingPluginRegistrySnapshotForChannel,
|
||||
} from "./onboarding/plugin-install.js";
|
||||
import {
|
||||
getChannelOnboardingAdapter,
|
||||
listChannelOnboardingAdapters,
|
||||
} from "./onboarding/registry.js";
|
||||
import type {
|
||||
ChannelOnboardingAdapter,
|
||||
ChannelOnboardingConfiguredResult,
|
||||
ChannelOnboardingDmPolicy,
|
||||
ChannelOnboardingResult,
|
||||
@@ -91,9 +92,10 @@ async function promptRemovalAccountId(params: {
|
||||
prompter: WizardPrompter;
|
||||
label: string;
|
||||
channel: ChannelChoice;
|
||||
plugin?: ChannelPlugin;
|
||||
}): Promise<string> {
|
||||
const { cfg, prompter, label, channel } = params;
|
||||
const plugin = getChannelSetupPlugin(channel);
|
||||
const plugin = params.plugin ?? getChannelSetupPlugin(channel);
|
||||
if (!plugin) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
@@ -117,8 +119,9 @@ async function collectChannelStatus(params: {
|
||||
cfg: OpenClawConfig;
|
||||
options?: SetupChannelsOptions;
|
||||
accountOverrides: Partial<Record<ChannelChoice, string>>;
|
||||
installedPlugins?: ReturnType<typeof listChannelSetupPlugins>;
|
||||
}): Promise<ChannelStatusSummary> {
|
||||
const installedPlugins = listChannelSetupPlugins();
|
||||
const installedPlugins = params.installedPlugins ?? listChannelSetupPlugins();
|
||||
const installedIds = new Set(installedPlugins.map((plugin) => plugin.id));
|
||||
const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg));
|
||||
const catalogEntries = listChannelPluginCatalogEntries({ workspaceDir }).filter(
|
||||
@@ -230,10 +233,12 @@ async function maybeConfigureDmPolicies(params: {
|
||||
selection: ChannelChoice[];
|
||||
prompter: WizardPrompter;
|
||||
accountIdsByChannel?: Map<ChannelChoice, string>;
|
||||
resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined;
|
||||
}): Promise<OpenClawConfig> {
|
||||
const { selection, prompter, accountIdsByChannel } = params;
|
||||
const resolve = params.resolveAdapter ?? getChannelOnboardingAdapter;
|
||||
const dmPolicies = selection
|
||||
.map((channel) => getChannelOnboardingAdapter(channel)?.dmPolicy)
|
||||
.map((channel) => resolve(channel)?.dmPolicy)
|
||||
.filter(Boolean) as ChannelOnboardingDmPolicy[];
|
||||
if (dmPolicies.length === 0) {
|
||||
return params.cfg;
|
||||
@@ -300,23 +305,85 @@ export async function setupChannels(
|
||||
options?: SetupChannelsOptions,
|
||||
): Promise<OpenClawConfig> {
|
||||
let next = cfg;
|
||||
if (listChannelOnboardingAdapters().length === 0) {
|
||||
reloadOnboardingPluginRegistry({
|
||||
cfg: next,
|
||||
runtime,
|
||||
workspaceDir: resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)),
|
||||
});
|
||||
}
|
||||
const forceAllowFromChannels = new Set(options?.forceAllowFromChannels ?? []);
|
||||
const accountOverrides: Partial<Record<ChannelChoice, string>> = {
|
||||
...options?.accountIds,
|
||||
};
|
||||
const scopedPluginsById = new Map<ChannelChoice, ChannelPlugin>();
|
||||
const resolveWorkspaceDir = () => resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next));
|
||||
const rememberScopedPlugin = (plugin: ChannelPlugin) => {
|
||||
const channel = plugin.id;
|
||||
scopedPluginsById.set(channel, plugin);
|
||||
options?.onResolvedPlugin?.(channel, plugin);
|
||||
};
|
||||
const getVisibleChannelPlugin = (channel: ChannelChoice): ChannelPlugin | undefined =>
|
||||
scopedPluginsById.get(channel) ?? getChannelSetupPlugin(channel);
|
||||
const listVisibleInstalledPlugins = (): ChannelPlugin[] => {
|
||||
const merged = new Map<string, ChannelPlugin>();
|
||||
for (const plugin of listChannelSetupPlugins()) {
|
||||
merged.set(plugin.id, plugin);
|
||||
}
|
||||
for (const plugin of scopedPluginsById.values()) {
|
||||
merged.set(plugin.id, plugin);
|
||||
}
|
||||
return Array.from(merged.values());
|
||||
};
|
||||
const loadScopedChannelPlugin = (
|
||||
channel: ChannelChoice,
|
||||
pluginId?: string,
|
||||
): ChannelPlugin | undefined => {
|
||||
const existing = getVisibleChannelPlugin(channel);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const snapshot = loadOnboardingPluginRegistrySnapshotForChannel({
|
||||
cfg: next,
|
||||
runtime,
|
||||
channel,
|
||||
...(pluginId ? { pluginId } : {}),
|
||||
workspaceDir: resolveWorkspaceDir(),
|
||||
});
|
||||
const plugin = snapshot.channels.find((entry) => entry.plugin.id === channel)?.plugin;
|
||||
if (plugin) {
|
||||
rememberScopedPlugin(plugin);
|
||||
}
|
||||
return plugin;
|
||||
};
|
||||
const getVisibleOnboardingAdapter = (channel: ChannelChoice) => {
|
||||
const adapter = getChannelOnboardingAdapter(channel);
|
||||
if (adapter) {
|
||||
return adapter;
|
||||
}
|
||||
return scopedPluginsById.get(channel)?.onboarding;
|
||||
};
|
||||
const preloadConfiguredExternalPlugins = () => {
|
||||
// Keep onboarding memory bounded by snapshot-loading only configured external plugins.
|
||||
const workspaceDir = resolveWorkspaceDir();
|
||||
for (const entry of listChannelPluginCatalogEntries({ workspaceDir })) {
|
||||
const channel = entry.id as ChannelChoice;
|
||||
if (getVisibleChannelPlugin(channel)) {
|
||||
continue;
|
||||
}
|
||||
const explicitlyEnabled =
|
||||
next.plugins?.entries?.[entry.pluginId ?? channel]?.enabled === true;
|
||||
if (!explicitlyEnabled && !isChannelConfigured(next, channel)) {
|
||||
continue;
|
||||
}
|
||||
loadScopedChannelPlugin(channel, entry.pluginId);
|
||||
}
|
||||
};
|
||||
if (options?.whatsappAccountId?.trim()) {
|
||||
accountOverrides.whatsapp = options.whatsappAccountId.trim();
|
||||
}
|
||||
preloadConfiguredExternalPlugins();
|
||||
|
||||
const { installedPlugins, catalogEntries, statusByChannel, statusLines } =
|
||||
await collectChannelStatus({ cfg: next, options, accountOverrides });
|
||||
await collectChannelStatus({
|
||||
cfg: next,
|
||||
options,
|
||||
accountOverrides,
|
||||
installedPlugins: listVisibleInstalledPlugins(),
|
||||
});
|
||||
if (!options?.skipStatusNote && statusLines.length > 0) {
|
||||
await prompter.note(statusLines.join("\n"), "Channel status");
|
||||
}
|
||||
@@ -363,7 +430,7 @@ export async function setupChannels(
|
||||
const accountIdsByChannel = new Map<ChannelChoice, string>();
|
||||
const recordAccount = (channel: ChannelChoice, accountId: string) => {
|
||||
options?.onAccountId?.(channel, accountId);
|
||||
const adapter = getChannelOnboardingAdapter(channel);
|
||||
const adapter = getVisibleOnboardingAdapter(channel);
|
||||
adapter?.onAccountRecorded?.(accountId, options);
|
||||
accountIdsByChannel.set(channel, accountId);
|
||||
};
|
||||
@@ -376,7 +443,6 @@ export async function setupChannels(
|
||||
};
|
||||
|
||||
const resolveDisabledHint = (channel: ChannelChoice): string | undefined => {
|
||||
const plugin = getChannelSetupPlugin(channel);
|
||||
if (
|
||||
typeof (next.channels as Record<string, { enabled?: boolean }> | undefined)?.[channel]
|
||||
?.enabled === "boolean"
|
||||
@@ -385,6 +451,7 @@ export async function setupChannels(
|
||||
? "disabled"
|
||||
: undefined;
|
||||
}
|
||||
const plugin = getVisibleChannelPlugin(channel);
|
||||
if (!plugin) {
|
||||
if (next.plugins?.entries?.[channel]?.enabled === false) {
|
||||
return "plugin disabled";
|
||||
@@ -424,9 +491,9 @@ export async function setupChannels(
|
||||
|
||||
const getChannelEntries = () => {
|
||||
const core = listChatChannels();
|
||||
const installed = listChannelSetupPlugins();
|
||||
const installed = listVisibleInstalledPlugins();
|
||||
const installedIds = new Set(installed.map((plugin) => plugin.id));
|
||||
const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next));
|
||||
const workspaceDir = resolveWorkspaceDir();
|
||||
const catalog = listChannelPluginCatalogEntries({ workspaceDir }).filter(
|
||||
(entry) => !installedIds.has(entry.id),
|
||||
);
|
||||
@@ -454,7 +521,7 @@ export async function setupChannels(
|
||||
};
|
||||
|
||||
const refreshStatus = async (channel: ChannelChoice) => {
|
||||
const adapter = getChannelOnboardingAdapter(channel);
|
||||
const adapter = getVisibleOnboardingAdapter(channel);
|
||||
if (!adapter) {
|
||||
return;
|
||||
}
|
||||
@@ -463,6 +530,10 @@ export async function setupChannels(
|
||||
};
|
||||
|
||||
const enableBundledPluginForSetup = async (channel: ChannelChoice): Promise<boolean> => {
|
||||
if (getVisibleChannelPlugin(channel)) {
|
||||
await refreshStatus(channel);
|
||||
return true;
|
||||
}
|
||||
const result = enablePluginInConfig(next, channel);
|
||||
next = result.config;
|
||||
if (!result.enabled) {
|
||||
@@ -472,12 +543,22 @@ export async function setupChannels(
|
||||
);
|
||||
return false;
|
||||
}
|
||||
const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next));
|
||||
reloadOnboardingPluginRegistry({
|
||||
cfg: next,
|
||||
runtime,
|
||||
workspaceDir,
|
||||
});
|
||||
const adapter = getVisibleOnboardingAdapter(channel);
|
||||
const plugin = loadScopedChannelPlugin(channel);
|
||||
if (!plugin) {
|
||||
if (adapter) {
|
||||
await prompter.note(
|
||||
`${channel} plugin not available (continuing with onboarding). If the channel still doesn't work after setup, run \`${formatCliCommand(
|
||||
"openclaw plugins list",
|
||||
)}\` and \`${formatCliCommand("openclaw plugins enable " + channel)}\`, then restart the gateway.`,
|
||||
"Channel setup",
|
||||
);
|
||||
await refreshStatus(channel);
|
||||
return true;
|
||||
}
|
||||
await prompter.note(`${channel} plugin not available.`, "Channel setup");
|
||||
return false;
|
||||
}
|
||||
await refreshStatus(channel);
|
||||
return true;
|
||||
};
|
||||
@@ -503,7 +584,7 @@ export async function setupChannels(
|
||||
};
|
||||
|
||||
const configureChannel = async (channel: ChannelChoice) => {
|
||||
const adapter = getChannelOnboardingAdapter(channel);
|
||||
const adapter = getVisibleOnboardingAdapter(channel);
|
||||
if (!adapter) {
|
||||
await prompter.note(`${channel} does not support onboarding yet.`, "Channel setup");
|
||||
return;
|
||||
@@ -521,8 +602,8 @@ export async function setupChannels(
|
||||
};
|
||||
|
||||
const handleConfiguredChannel = async (channel: ChannelChoice, label: string) => {
|
||||
const plugin = getChannelSetupPlugin(channel);
|
||||
const adapter = getChannelOnboardingAdapter(channel);
|
||||
const plugin = getVisibleChannelPlugin(channel);
|
||||
const adapter = getVisibleOnboardingAdapter(channel);
|
||||
if (adapter?.configureWhenConfigured) {
|
||||
const custom = await adapter.configureWhenConfigured({
|
||||
cfg: next,
|
||||
@@ -577,6 +658,7 @@ export async function setupChannels(
|
||||
prompter,
|
||||
label,
|
||||
channel,
|
||||
plugin,
|
||||
})
|
||||
: DEFAULT_ACCOUNT_ID;
|
||||
const resolvedAccountId =
|
||||
@@ -615,7 +697,7 @@ export async function setupChannels(
|
||||
const { catalogById } = getChannelEntries();
|
||||
const catalogEntry = catalogById.get(channel);
|
||||
if (catalogEntry) {
|
||||
const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next));
|
||||
const workspaceDir = resolveWorkspaceDir();
|
||||
const result = await ensureOnboardingPluginInstalled({
|
||||
cfg: next,
|
||||
entry: catalogEntry,
|
||||
@@ -627,11 +709,7 @@ export async function setupChannels(
|
||||
if (!result.installed) {
|
||||
return;
|
||||
}
|
||||
reloadOnboardingPluginRegistry({
|
||||
cfg: next,
|
||||
runtime,
|
||||
workspaceDir,
|
||||
});
|
||||
loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId);
|
||||
await refreshStatus(channel);
|
||||
} else {
|
||||
const enabled = await enableBundledPluginForSetup(channel);
|
||||
@@ -640,8 +718,8 @@ export async function setupChannels(
|
||||
}
|
||||
}
|
||||
|
||||
const plugin = getChannelSetupPlugin(channel);
|
||||
const adapter = getChannelOnboardingAdapter(channel);
|
||||
const plugin = getVisibleChannelPlugin(channel);
|
||||
const adapter = getVisibleOnboardingAdapter(channel);
|
||||
const label = plugin?.meta.label ?? catalogEntry?.meta.label ?? channel;
|
||||
const status = statusByChannel.get(channel);
|
||||
const configured = status?.configured ?? false;
|
||||
@@ -730,6 +808,7 @@ export async function setupChannels(
|
||||
selection,
|
||||
prompter,
|
||||
accountIdsByChannel,
|
||||
resolveAdapter: getVisibleOnboardingAdapter,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -58,15 +58,20 @@ import fs from "node:fs";
|
||||
import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import { loadOpenClawPlugins } from "../../plugins/loader.js";
|
||||
import { createEmptyPluginRegistry } from "../../plugins/registry.js";
|
||||
import { setActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import type { WizardPrompter } from "../../wizard/prompts.js";
|
||||
import { makePrompter, makeRuntime } from "./__tests__/test-utils.js";
|
||||
import {
|
||||
ensureOnboardingPluginInstalled,
|
||||
loadOnboardingPluginRegistrySnapshotForChannel,
|
||||
reloadOnboardingPluginRegistry,
|
||||
reloadOnboardingPluginRegistryForChannel,
|
||||
} from "./plugin-install.js";
|
||||
|
||||
const baseEntry: ChannelPluginCatalogEntry = {
|
||||
id: "zalo",
|
||||
pluginId: "zalo",
|
||||
meta: {
|
||||
id: "zalo",
|
||||
label: "Zalo",
|
||||
@@ -84,6 +89,7 @@ const baseEntry: ChannelPluginCatalogEntry = {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resolveBundledPluginSources.mockReturnValue(new Map());
|
||||
setActivePluginRegistry(createEmptyPluginRegistry());
|
||||
});
|
||||
|
||||
function mockRepoLocalPathExists() {
|
||||
@@ -171,6 +177,30 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
expect(result.cfg.plugins?.entries?.zalo?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the catalog plugin id for local-path installs", async () => {
|
||||
const runtime = makeRuntime();
|
||||
const prompter = makePrompter({
|
||||
select: vi.fn(async () => "local") as WizardPrompter["select"],
|
||||
});
|
||||
const cfg: OpenClawConfig = {};
|
||||
mockRepoLocalPathExists();
|
||||
|
||||
const result = await ensureOnboardingPluginInstalled({
|
||||
cfg,
|
||||
entry: {
|
||||
...baseEntry,
|
||||
id: "teams",
|
||||
pluginId: "@openclaw/msteams-plugin",
|
||||
},
|
||||
prompter,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(result.installed).toBe(true);
|
||||
expect(result.pluginId).toBe("@openclaw/msteams-plugin");
|
||||
expect(result.cfg.plugins?.entries?.["@openclaw/msteams-plugin"]?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it("defaults to local on dev channel when local path exists", async () => {
|
||||
expect(await runInitialValueForChannel("dev")).toBe("local");
|
||||
});
|
||||
@@ -268,4 +298,109 @@ describe("ensureOnboardingPluginInstalled", () => {
|
||||
vi.mocked(loadOpenClawPlugins).mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
});
|
||||
|
||||
it("scopes channel reloads when onboarding starts from an empty registry", () => {
|
||||
const runtime = makeRuntime();
|
||||
const cfg: OpenClawConfig = {};
|
||||
|
||||
reloadOnboardingPluginRegistryForChannel({
|
||||
cfg,
|
||||
runtime,
|
||||
channel: "telegram",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
});
|
||||
|
||||
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: cfg,
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
cache: false,
|
||||
onlyPluginIds: ["telegram"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps full reloads when the active plugin registry is already populated", () => {
|
||||
const runtime = makeRuntime();
|
||||
const cfg: OpenClawConfig = {};
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.plugins.push({
|
||||
id: "loaded",
|
||||
name: "loaded",
|
||||
source: "/tmp/loaded.cjs",
|
||||
origin: "bundled",
|
||||
enabled: true,
|
||||
status: "loaded",
|
||||
toolNames: [],
|
||||
hookNames: [],
|
||||
channelIds: [],
|
||||
providerIds: [],
|
||||
gatewayMethods: [],
|
||||
cliCommands: [],
|
||||
services: [],
|
||||
commands: [],
|
||||
httpRoutes: 0,
|
||||
hookCount: 0,
|
||||
configSchema: true,
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
|
||||
reloadOnboardingPluginRegistryForChannel({
|
||||
cfg,
|
||||
runtime,
|
||||
channel: "telegram",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
});
|
||||
|
||||
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({
|
||||
onlyPluginIds: expect.anything(),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("can load a channel-scoped snapshot without activating the global registry", () => {
|
||||
const runtime = makeRuntime();
|
||||
const cfg: OpenClawConfig = {};
|
||||
|
||||
loadOnboardingPluginRegistrySnapshotForChannel({
|
||||
cfg,
|
||||
runtime,
|
||||
channel: "telegram",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
});
|
||||
|
||||
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: cfg,
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
cache: false,
|
||||
onlyPluginIds: ["telegram"],
|
||||
activate: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("scopes snapshots by plugin id when channel and plugin ids differ", () => {
|
||||
const runtime = makeRuntime();
|
||||
const cfg: OpenClawConfig = {};
|
||||
|
||||
loadOnboardingPluginRegistrySnapshotForChannel({
|
||||
cfg,
|
||||
runtime,
|
||||
channel: "msteams",
|
||||
pluginId: "@openclaw/msteams-plugin",
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
});
|
||||
|
||||
expect(loadOpenClawPlugins).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: cfg,
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
cache: false,
|
||||
onlyPluginIds: ["@openclaw/msteams-plugin"],
|
||||
activate: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,8 @@ import { installPluginFromNpmSpec } from "../../plugins/install.js";
|
||||
import { buildNpmResolutionInstallFields, recordPluginInstall } from "../../plugins/installs.js";
|
||||
import { loadOpenClawPlugins } from "../../plugins/loader.js";
|
||||
import { createPluginLoaderLogger } from "../../plugins/logger.js";
|
||||
import type { PluginRegistry } from "../../plugins/registry.js";
|
||||
import { getActivePluginRegistry } from "../../plugins/runtime.js";
|
||||
import type { RuntimeEnv } from "../../runtime.js";
|
||||
import type { WizardPrompter } from "../../wizard/prompts.js";
|
||||
|
||||
@@ -23,6 +25,7 @@ type InstallChoice = "npm" | "local" | "skip";
|
||||
type InstallResult = {
|
||||
cfg: OpenClawConfig;
|
||||
installed: boolean;
|
||||
pluginId?: string;
|
||||
};
|
||||
|
||||
function hasGitWorkspace(workspaceDir?: string): boolean {
|
||||
@@ -174,8 +177,9 @@ export async function ensureOnboardingPluginInstalled(params: {
|
||||
|
||||
if (choice === "local" && localPath) {
|
||||
next = addPluginLoadPath(next, localPath);
|
||||
next = enablePluginInConfig(next, entry.id).config;
|
||||
return { cfg: next, installed: true };
|
||||
const pluginId = entry.pluginId ?? entry.id;
|
||||
next = enablePluginInConfig(next, pluginId).config;
|
||||
return { cfg: next, installed: true, pluginId };
|
||||
}
|
||||
|
||||
const result = await installPluginFromNpmSpec({
|
||||
@@ -196,7 +200,7 @@ export async function ensureOnboardingPluginInstalled(params: {
|
||||
version: result.version,
|
||||
...buildNpmResolutionInstallFields(result.npmResolution),
|
||||
});
|
||||
return { cfg: next, installed: true };
|
||||
return { cfg: next, installed: true, pluginId: result.pluginId };
|
||||
}
|
||||
|
||||
await prompter.note(
|
||||
@@ -211,8 +215,9 @@ export async function ensureOnboardingPluginInstalled(params: {
|
||||
});
|
||||
if (fallback) {
|
||||
next = addPluginLoadPath(next, localPath);
|
||||
next = enablePluginInConfig(next, entry.id).config;
|
||||
return { cfg: next, installed: true };
|
||||
const pluginId = entry.pluginId ?? entry.id;
|
||||
next = enablePluginInConfig(next, pluginId).config;
|
||||
return { cfg: next, installed: true, pluginId };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,14 +230,59 @@ export function reloadOnboardingPluginRegistry(params: {
|
||||
runtime: RuntimeEnv;
|
||||
workspaceDir?: string;
|
||||
}): void {
|
||||
loadOnboardingPluginRegistry(params);
|
||||
}
|
||||
|
||||
function loadOnboardingPluginRegistry(params: {
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
workspaceDir?: string;
|
||||
onlyPluginIds?: string[];
|
||||
activate?: boolean;
|
||||
}): PluginRegistry {
|
||||
clearPluginDiscoveryCache();
|
||||
const workspaceDir =
|
||||
params.workspaceDir ?? resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg));
|
||||
const log = createSubsystemLogger("plugins");
|
||||
loadOpenClawPlugins({
|
||||
return loadOpenClawPlugins({
|
||||
config: params.cfg,
|
||||
workspaceDir,
|
||||
cache: false,
|
||||
logger: createPluginLoaderLogger(log),
|
||||
onlyPluginIds: params.onlyPluginIds,
|
||||
activate: params.activate,
|
||||
});
|
||||
}
|
||||
|
||||
export function reloadOnboardingPluginRegistryForChannel(params: {
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
channel: string;
|
||||
pluginId?: string;
|
||||
workspaceDir?: string;
|
||||
}): void {
|
||||
const activeRegistry = getActivePluginRegistry();
|
||||
// On low-memory hosts, the empty-registry fallback should only recover the selected
|
||||
// plugin instead of importing every bundled extension during onboarding.
|
||||
const onlyPluginIds = activeRegistry?.plugins.length
|
||||
? undefined
|
||||
: [params.pluginId ?? params.channel];
|
||||
loadOnboardingPluginRegistry({
|
||||
...params,
|
||||
onlyPluginIds,
|
||||
});
|
||||
}
|
||||
|
||||
export function loadOnboardingPluginRegistrySnapshotForChannel(params: {
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
channel: string;
|
||||
pluginId?: string;
|
||||
workspaceDir?: string;
|
||||
}): PluginRegistry {
|
||||
return loadOnboardingPluginRegistry({
|
||||
...params,
|
||||
onlyPluginIds: [params.pluginId ?? params.channel],
|
||||
activate: false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,8 +1,23 @@
|
||||
import { discordOnboardingAdapter } from "../../../extensions/discord/src/onboarding.js";
|
||||
import { imessageOnboardingAdapter } from "../../../extensions/imessage/src/onboarding.js";
|
||||
import { signalOnboardingAdapter } from "../../../extensions/signal/src/onboarding.js";
|
||||
import { slackOnboardingAdapter } from "../../../extensions/slack/src/onboarding.js";
|
||||
import { telegramOnboardingAdapter } from "../../../extensions/telegram/src/setup-surface.js";
|
||||
import { whatsappOnboardingAdapter } from "../../../extensions/whatsapp/src/onboarding.js";
|
||||
import { listChannelSetupPlugins } from "../../channels/plugins/setup-registry.js";
|
||||
import { buildChannelOnboardingAdapterFromSetupWizard } from "../../channels/plugins/setup-wizard.js";
|
||||
import type { ChannelChoice } from "../onboard-types.js";
|
||||
import type { ChannelOnboardingAdapter } from "./types.js";
|
||||
|
||||
const BUILTIN_ONBOARDING_ADAPTERS: ChannelOnboardingAdapter[] = [
|
||||
telegramOnboardingAdapter,
|
||||
whatsappOnboardingAdapter,
|
||||
discordOnboardingAdapter,
|
||||
slackOnboardingAdapter,
|
||||
signalOnboardingAdapter,
|
||||
imessageOnboardingAdapter,
|
||||
];
|
||||
|
||||
const setupWizardAdapters = new WeakMap<object, ChannelOnboardingAdapter>();
|
||||
|
||||
function resolveChannelOnboardingAdapter(
|
||||
@@ -27,7 +42,9 @@ function resolveChannelOnboardingAdapter(
|
||||
}
|
||||
|
||||
const CHANNEL_ONBOARDING_ADAPTERS = () => {
|
||||
const adapters = new Map<ChannelChoice, ChannelOnboardingAdapter>();
|
||||
const adapters = new Map<ChannelChoice, ChannelOnboardingAdapter>(
|
||||
BUILTIN_ONBOARDING_ADAPTERS.map((adapter) => [adapter.channel, adapter] as const),
|
||||
);
|
||||
for (const plugin of listChannelSetupPlugins()) {
|
||||
const adapter = resolveChannelOnboardingAdapter(plugin);
|
||||
if (!adapter) {
|
||||
|
||||
@@ -111,6 +111,29 @@ export type CommandRegistrationResult = {
|
||||
error?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validate a plugin command definition without registering it.
|
||||
* Returns an error message if invalid, or null if valid.
|
||||
* Shared by both the global registration path and snapshot (non-activating) loads.
|
||||
*/
|
||||
export function validatePluginCommandDefinition(
|
||||
command: OpenClawPluginCommandDefinition,
|
||||
): string | null {
|
||||
if (typeof command.handler !== "function") {
|
||||
return "Command handler must be a function";
|
||||
}
|
||||
if (typeof command.name !== "string") {
|
||||
return "Command name must be a string";
|
||||
}
|
||||
if (typeof command.description !== "string") {
|
||||
return "Command description must be a string";
|
||||
}
|
||||
if (!command.description.trim()) {
|
||||
return "Command description cannot be empty";
|
||||
}
|
||||
return validateCommandName(command.name.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a plugin command.
|
||||
* Returns an error if the command name is invalid or reserved.
|
||||
@@ -125,28 +148,13 @@ export function registerPluginCommand(
|
||||
return { ok: false, error: "Cannot register commands while processing is in progress" };
|
||||
}
|
||||
|
||||
// Validate handler is a function
|
||||
if (typeof command.handler !== "function") {
|
||||
return { ok: false, error: "Command handler must be a function" };
|
||||
}
|
||||
|
||||
if (typeof command.name !== "string") {
|
||||
return { ok: false, error: "Command name must be a string" };
|
||||
}
|
||||
if (typeof command.description !== "string") {
|
||||
return { ok: false, error: "Command description must be a string" };
|
||||
const definitionError = validatePluginCommandDefinition(command);
|
||||
if (definitionError) {
|
||||
return { ok: false, error: definitionError };
|
||||
}
|
||||
|
||||
const name = command.name.trim();
|
||||
const description = command.description.trim();
|
||||
if (!description) {
|
||||
return { ok: false, error: "Command description cannot be empty" };
|
||||
}
|
||||
|
||||
const validationError = validateCommandName(name);
|
||||
if (validationError) {
|
||||
return { ok: false, error: validationError };
|
||||
}
|
||||
|
||||
const key = `/${name.toLowerCase()}`;
|
||||
|
||||
|
||||
@@ -14,15 +14,19 @@ async function importFreshPluginTestModules() {
|
||||
vi.unmock("./hooks.js");
|
||||
vi.unmock("./loader.js");
|
||||
vi.unmock("jiti");
|
||||
const [loader, hookRunnerGlobal, hooks] = await Promise.all([
|
||||
const [loader, hookRunnerGlobal, hooks, runtime, registry] = await Promise.all([
|
||||
import("./loader.js"),
|
||||
import("./hook-runner-global.js"),
|
||||
import("./hooks.js"),
|
||||
import("./runtime.js"),
|
||||
import("./registry.js"),
|
||||
]);
|
||||
return {
|
||||
...loader,
|
||||
...hookRunnerGlobal,
|
||||
...hooks,
|
||||
...runtime,
|
||||
...registry,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -30,9 +34,13 @@ const {
|
||||
__testing,
|
||||
clearPluginLoaderCache,
|
||||
createHookRunner,
|
||||
createEmptyPluginRegistry,
|
||||
getActivePluginRegistry,
|
||||
getActivePluginRegistryKey,
|
||||
getGlobalHookRunner,
|
||||
loadOpenClawPlugins,
|
||||
resetGlobalHookRunner,
|
||||
setActivePluginRegistry,
|
||||
} = await importFreshPluginTestModules();
|
||||
|
||||
type TempPlugin = { dir: string; file: string; id: string };
|
||||
@@ -580,6 +588,112 @@ describe("loadOpenClawPlugins", () => {
|
||||
expect(Object.keys(registry.gatewayHandlers)).toContain("allowed.ping");
|
||||
});
|
||||
|
||||
it("limits imports to the requested plugin ids", () => {
|
||||
useNoBundledPlugins();
|
||||
const allowed = writePlugin({
|
||||
id: "allowed",
|
||||
filename: "allowed.cjs",
|
||||
body: `module.exports = { id: "allowed", register() {} };`,
|
||||
});
|
||||
const skippedMarker = path.join(makeTempDir(), "skipped-loaded.txt");
|
||||
const skipped = writePlugin({
|
||||
id: "skipped",
|
||||
filename: "skipped.cjs",
|
||||
body: `require("node:fs").writeFileSync(${JSON.stringify(skippedMarker)}, "loaded", "utf-8");
|
||||
module.exports = { id: "skipped", register() { throw new Error("skipped plugin should not load"); } };`,
|
||||
});
|
||||
|
||||
const registry = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [allowed.file, skipped.file] },
|
||||
allow: ["allowed", "skipped"],
|
||||
},
|
||||
},
|
||||
onlyPluginIds: ["allowed"],
|
||||
});
|
||||
|
||||
expect(registry.plugins.map((entry) => entry.id)).toEqual(["allowed"]);
|
||||
expect(fs.existsSync(skippedMarker)).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps scoped plugin loads in a separate cache entry", () => {
|
||||
useNoBundledPlugins();
|
||||
const allowed = writePlugin({
|
||||
id: "allowed",
|
||||
filename: "allowed.cjs",
|
||||
body: `module.exports = { id: "allowed", register() {} };`,
|
||||
});
|
||||
const extra = writePlugin({
|
||||
id: "extra",
|
||||
filename: "extra.cjs",
|
||||
body: `module.exports = { id: "extra", register() {} };`,
|
||||
});
|
||||
const options = {
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [allowed.file, extra.file] },
|
||||
allow: ["allowed", "extra"],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const full = loadOpenClawPlugins(options);
|
||||
const scoped = loadOpenClawPlugins({
|
||||
...options,
|
||||
onlyPluginIds: ["allowed"],
|
||||
});
|
||||
const scopedAgain = loadOpenClawPlugins({
|
||||
...options,
|
||||
onlyPluginIds: ["allowed"],
|
||||
});
|
||||
|
||||
expect(full.plugins.map((entry) => entry.id).toSorted()).toEqual(["allowed", "extra"]);
|
||||
expect(scoped).not.toBe(full);
|
||||
expect(scoped.plugins.map((entry) => entry.id)).toEqual(["allowed"]);
|
||||
expect(scopedAgain).toBe(scoped);
|
||||
});
|
||||
|
||||
it("can load a scoped registry without replacing the active global registry", () => {
|
||||
useNoBundledPlugins();
|
||||
const plugin = writePlugin({
|
||||
id: "allowed",
|
||||
filename: "allowed.cjs",
|
||||
body: `module.exports = { id: "allowed", register() {} };`,
|
||||
});
|
||||
const previousRegistry = createEmptyPluginRegistry();
|
||||
setActivePluginRegistry(previousRegistry, "existing-registry");
|
||||
resetGlobalHookRunner();
|
||||
|
||||
const scoped = loadOpenClawPlugins({
|
||||
cache: false,
|
||||
activate: false,
|
||||
workspaceDir: plugin.dir,
|
||||
config: {
|
||||
plugins: {
|
||||
load: { paths: [plugin.file] },
|
||||
allow: ["allowed"],
|
||||
},
|
||||
},
|
||||
onlyPluginIds: ["allowed"],
|
||||
});
|
||||
|
||||
expect(scoped.plugins.map((entry) => entry.id)).toEqual(["allowed"]);
|
||||
expect(getActivePluginRegistry()).toBe(previousRegistry);
|
||||
expect(getActivePluginRegistryKey()).toBe("existing-registry");
|
||||
expect(getGlobalHookRunner()).toBeNull();
|
||||
});
|
||||
|
||||
it("throws when activate:false is used without cache:false", () => {
|
||||
expect(() => loadOpenClawPlugins({ activate: false })).toThrow(
|
||||
"activate:false requires cache:false",
|
||||
);
|
||||
expect(() => loadOpenClawPlugins({ activate: false, cache: true })).toThrow(
|
||||
"activate:false requires cache:false",
|
||||
);
|
||||
});
|
||||
|
||||
it("re-initializes global hook runner when serving registry from cache", () => {
|
||||
process.env.OPENCLAW_BUNDLED_PLUGINS_DIR = "/nonexistent/bundled/plugins";
|
||||
const plugin = writePlugin({
|
||||
|
||||
@@ -50,6 +50,8 @@ export type PluginLoadOptions = {
|
||||
runtimeOptions?: CreatePluginRuntimeOptions;
|
||||
cache?: boolean;
|
||||
mode?: "full" | "validate";
|
||||
onlyPluginIds?: string[];
|
||||
activate?: boolean;
|
||||
};
|
||||
|
||||
const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 32;
|
||||
@@ -241,6 +243,7 @@ function buildCacheKey(params: {
|
||||
plugins: NormalizedPluginsConfig;
|
||||
installs?: Record<string, PluginInstallRecord>;
|
||||
env: NodeJS.ProcessEnv;
|
||||
onlyPluginIds?: string[];
|
||||
}): string {
|
||||
const { roots, loadPaths } = resolvePluginCacheInputs({
|
||||
workspaceDir: params.workspaceDir,
|
||||
@@ -263,11 +266,20 @@ function buildCacheKey(params: {
|
||||
},
|
||||
]),
|
||||
);
|
||||
const scopeKey = JSON.stringify(params.onlyPluginIds ?? []);
|
||||
return `${roots.workspace ?? ""}::${roots.global ?? ""}::${roots.stock ?? ""}::${JSON.stringify({
|
||||
...params.plugins,
|
||||
installs,
|
||||
loadPaths,
|
||||
})}`;
|
||||
})}::${scopeKey}`;
|
||||
}
|
||||
|
||||
function normalizeScopedPluginIds(ids?: string[]): string[] | undefined {
|
||||
if (!ids) {
|
||||
return undefined;
|
||||
}
|
||||
const normalized = Array.from(new Set(ids.map((id) => id.trim()).filter(Boolean))).toSorted();
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function validatePluginConfig(params: {
|
||||
@@ -640,6 +652,13 @@ function activatePluginRegistry(registry: PluginRegistry, cacheKey: string): voi
|
||||
}
|
||||
|
||||
export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegistry {
|
||||
// Snapshot (non-activating) loads must disable the cache to avoid storing a registry
|
||||
// whose commands were never globally registered.
|
||||
if (options.activate === false && options.cache !== false) {
|
||||
throw new Error(
|
||||
"loadOpenClawPlugins: activate:false requires cache:false to prevent command registry divergence",
|
||||
);
|
||||
}
|
||||
const env = options.env ?? process.env;
|
||||
// Test env: default-disable plugins unless explicitly configured.
|
||||
// This keeps unit/gateway suites fast and avoids loading heavyweight plugin deps by accident.
|
||||
@@ -647,24 +666,37 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
const logger = options.logger ?? defaultLogger();
|
||||
const validateOnly = options.mode === "validate";
|
||||
const normalized = normalizePluginsConfig(cfg.plugins);
|
||||
const onlyPluginIds = normalizeScopedPluginIds(options.onlyPluginIds);
|
||||
const onlyPluginIdSet = onlyPluginIds ? new Set(onlyPluginIds) : null;
|
||||
const shouldActivate = options.activate !== false;
|
||||
// NOTE: `activate` is intentionally excluded from the cache key. All non-activating
|
||||
// (snapshot) callers pass `cache: false` via loadOnboardingPluginRegistry(), so they
|
||||
// never read from or write to the cache. Including `activate` here would be misleading
|
||||
// — it would imply mixed-activate caching is supported, when in practice it is not.
|
||||
const cacheKey = buildCacheKey({
|
||||
workspaceDir: options.workspaceDir,
|
||||
plugins: normalized,
|
||||
installs: cfg.plugins?.installs,
|
||||
env,
|
||||
onlyPluginIds,
|
||||
});
|
||||
const cacheEnabled = options.cache !== false;
|
||||
if (cacheEnabled) {
|
||||
const cached = getCachedPluginRegistry(cacheKey);
|
||||
if (cached) {
|
||||
activatePluginRegistry(cached, cacheKey);
|
||||
if (shouldActivate) {
|
||||
activatePluginRegistry(cached, cacheKey);
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear previously registered plugin commands before reloading
|
||||
clearPluginCommands();
|
||||
clearPluginInteractiveHandlers();
|
||||
// Clear previously registered plugin commands before reloading.
|
||||
// Skip for non-activating (snapshot) loads to avoid wiping commands from other plugins.
|
||||
if (shouldActivate) {
|
||||
clearPluginCommands();
|
||||
clearPluginInteractiveHandlers();
|
||||
}
|
||||
|
||||
// Lazily initialize the runtime so startup paths that discover/skip plugins do
|
||||
// not eagerly load every channel runtime dependency.
|
||||
@@ -703,6 +735,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
logger,
|
||||
runtime,
|
||||
coreGatewayHandlers: options.coreGatewayHandlers as Record<string, GatewayRequestHandler>,
|
||||
suppressGlobalCommands: !shouldActivate,
|
||||
});
|
||||
|
||||
const discovery = discoverOpenClawPlugins({
|
||||
@@ -725,11 +758,15 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
pluginsEnabled: normalized.enabled,
|
||||
allow: normalized.allow,
|
||||
warningCacheKey: cacheKey,
|
||||
discoverablePlugins: manifestRegistry.plugins.map((plugin) => ({
|
||||
id: plugin.id,
|
||||
source: plugin.source,
|
||||
origin: plugin.origin,
|
||||
})),
|
||||
// Keep warning input scoped as well so partial snapshot loads only mention the
|
||||
// plugins that were intentionally requested for this registry.
|
||||
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,
|
||||
@@ -786,6 +823,11 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
continue;
|
||||
}
|
||||
const pluginId = manifestRecord.id;
|
||||
// Filter again at import time as a final guard. The earlier manifest filter keeps
|
||||
// warnings scoped; this one prevents loading/registering anything outside the scope.
|
||||
if (onlyPluginIdSet && !onlyPluginIdSet.has(pluginId)) {
|
||||
continue;
|
||||
}
|
||||
const existingOrigin = seenIds.get(pluginId);
|
||||
if (existingOrigin) {
|
||||
const record = createPluginRecord({
|
||||
@@ -1059,7 +1101,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof memorySlot === "string" && !memorySlotMatched) {
|
||||
// Scoped snapshot loads may intentionally omit the configured memory plugin, so only
|
||||
// emit the missing-memory diagnostic for full registry loads.
|
||||
if (!onlyPluginIdSet && typeof memorySlot === "string" && !memorySlotMatched) {
|
||||
registry.diagnostics.push({
|
||||
level: "warn",
|
||||
message: `memory slot plugin not found or not marked as memory: ${memorySlot}`,
|
||||
@@ -1076,7 +1120,9 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi
|
||||
if (cacheEnabled) {
|
||||
setCachedPluginRegistry(cacheKey, registry);
|
||||
}
|
||||
activatePluginRegistry(registry, cacheKey);
|
||||
if (shouldActivate) {
|
||||
activatePluginRegistry(registry, cacheKey);
|
||||
}
|
||||
return registry;
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import type {
|
||||
import { registerInternalHook } from "../hooks/internal-hooks.js";
|
||||
import type { HookEntry } from "../hooks/types.js";
|
||||
import { resolveUserPath } from "../utils.js";
|
||||
import { registerPluginCommand } from "./commands.js";
|
||||
import { registerPluginCommand, validatePluginCommandDefinition } from "./commands.js";
|
||||
import { normalizePluginHttpPath } from "./http-path.js";
|
||||
import { findOverlappingPluginHttpRoute } from "./http-route-overlap.js";
|
||||
import { registerPluginInteractiveHandler } from "./interactive.js";
|
||||
@@ -177,6 +177,9 @@ export type PluginRegistryParams = {
|
||||
logger: PluginLogger;
|
||||
coreGatewayHandlers?: GatewayRequestHandlers;
|
||||
runtime: PluginRuntime;
|
||||
// When true, skip writing to the global plugin command registry during register().
|
||||
// Used by non-activating snapshot loads to avoid leaking commands into the running gateway.
|
||||
suppressGlobalCommands?: boolean;
|
||||
};
|
||||
|
||||
type PluginTypedHookPolicy = {
|
||||
@@ -615,19 +618,37 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Register with the plugin command system (validates name and checks for duplicates)
|
||||
const result = registerPluginCommand(record.id, command, {
|
||||
pluginName: record.name,
|
||||
pluginRoot: record.rootDir,
|
||||
});
|
||||
if (!result.ok) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `command registration failed: ${result.error}`,
|
||||
// For snapshot (non-activating) loads, record the command locally without touching the
|
||||
// global plugin command registry so running gateway commands stay intact.
|
||||
// We still validate the command definition so diagnostics match the real activation path.
|
||||
// NOTE: cross-plugin duplicate command detection is intentionally skipped here because
|
||||
// snapshot registries are isolated and never write to the global command table. Conflicts
|
||||
// will surface when the plugin is loaded via the normal activation path at gateway startup.
|
||||
if (registryParams.suppressGlobalCommands) {
|
||||
const validationError = validatePluginCommandDefinition(command);
|
||||
if (validationError) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `command registration failed: ${validationError}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
const result = registerPluginCommand(record.id, command, {
|
||||
pluginName: record.name,
|
||||
pluginRoot: record.rootDir,
|
||||
});
|
||||
return;
|
||||
if (!result.ok) {
|
||||
pushDiagnostic({
|
||||
level: "error",
|
||||
pluginId: record.id,
|
||||
source: record.source,
|
||||
message: `command registration failed: ${result.error}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
record.commands.push(name);
|
||||
|
||||
Reference in New Issue
Block a user