From f4cc93dc7da7359c35130bbbb244d3fac695740f Mon Sep 17 00:00:00 2001 From: Mason Date: Mon, 16 Mar 2026 07:52:08 +0800 Subject: [PATCH] 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 * 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 Co-authored-by: Vincent Koc --- src/channels/plugins/catalog.ts | 31 +- src/channels/plugins/onboarding-types.ts | 3 +- src/channels/plugins/plugins-core.test.ts | 44 +++ src/commands/channels.add.test.ts | 184 +++++++++++- src/commands/channels/add-mutators.ts | 8 +- src/commands/channels/add.ts | 44 ++- src/commands/onboard-channels.e2e.test.ts | 270 ++++++++++++++++++ src/commands/onboard-channels.ts | 149 +++++++--- .../onboarding/plugin-install.test.ts | 135 +++++++++ src/commands/onboarding/plugin-install.ts | 62 +++- src/commands/onboarding/registry.ts | 19 +- src/plugins/commands.ts | 44 +-- src/plugins/loader.test.ts | 116 +++++++- src/plugins/loader.ts | 70 ++++- src/plugins/registry.ts | 47 ++- 15 files changed, 1127 insertions(+), 99 deletions(-) diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index a853dcdf805..8f582bb8c8a 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -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 { diff --git a/src/channels/plugins/onboarding-types.ts b/src/channels/plugins/onboarding-types.ts index 75d1b3a62c9..f560b27b172 100644 --- a/src/channels/plugins/onboarding-types.ts +++ b/src/channels/plugins/onboarding-types.ts @@ -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>; onAccountId?: (channel: ChannelId, accountId: string) => void; + onResolvedPlugin?: (channel: ChannelId, plugin: ChannelPlugin) => void; promptAccountIds?: boolean; whatsappAccountId?: string; promptWhatsAppAccountId?: boolean; diff --git a/src/channels/plugins/plugins-core.test.ts b/src/channels/plugins/plugins-core.test.ts index 8297a6b7519..2c8a7473dd6 100644 --- a/src/channels/plugins/plugins-core.test.ts +++ b/src/channels/plugins/plugins-core.test.ts @@ -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"); diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index 3d3929ec878..9f584494fba 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -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(); + return { + ...actual, + listChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries, + }; +}); + +vi.mock("./onboarding/plugin-install.js", async (importOriginal) => { + const actual = await importOriginal(); + 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(); + }); }); diff --git a/src/commands/channels/add-mutators.ts b/src/commands/channels/add-mutators.ts index cb2256bd5ac..1943dd99226 100644 --- a/src/commands/channels/add-mutators.ts +++ b/src/commands/channels/add-mutators.ts @@ -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; diff --git a/src/commands/channels/add.ts b/src/commands/channels/add.ts index 52a358f4946..e412c60215a 100644 --- a/src/commands/channels/add.ts +++ b/src/commands/channels/add.ts @@ -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> = {}; + const resolvedPlugins = new Map(); 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 => { + 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) { diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index b25bf35db78..6c505c6d4e2 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -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 { 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(); + 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), // 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 | undefined), + accounts: { + ...(cfg.channels?.msteams as { accounts?: Record } | undefined) + ?.accounts, + [accountId]: { + ...( + cfg.channels?.msteams as + | { + accounts?: Record>; + } + | 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 } | undefined) + ?.accounts ?? {}, + ), + resolveAccount: (cfg: OpenClawConfig, accountId: string) => + ( + cfg.channels?.msteams as + | { + accounts?: Record>; + } + | 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; + } + | 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", diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index ca4b090ce5a..4a313ebf913 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -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 { 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>; + installedPlugins?: ReturnType; }): Promise { - 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; + resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined; }): Promise { 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 { 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> = { ...options?.accountIds, }; + const scopedPluginsById = new Map(); + 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(); + 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(); 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 | 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 => { + 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, }); } diff --git a/src/commands/onboarding/plugin-install.test.ts b/src/commands/onboarding/plugin-install.test.ts index 2be78d9a6fc..d2c55d330c7 100644 --- a/src/commands/onboarding/plugin-install.test.ts +++ b/src/commands/onboarding/plugin-install.test.ts @@ -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, + }), + ); + }); }); diff --git a/src/commands/onboarding/plugin-install.ts b/src/commands/onboarding/plugin-install.ts index b4aabc06646..31f5ec1d64d 100644 --- a/src/commands/onboarding/plugin-install.ts +++ b/src/commands/onboarding/plugin-install.ts @@ -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, }); } diff --git a/src/commands/onboarding/registry.ts b/src/commands/onboarding/registry.ts index d8825abc853..6d31199ea2a 100644 --- a/src/commands/onboarding/registry.ts +++ b/src/commands/onboarding/registry.ts @@ -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(); function resolveChannelOnboardingAdapter( @@ -27,7 +42,9 @@ function resolveChannelOnboardingAdapter( } const CHANNEL_ONBOARDING_ADAPTERS = () => { - const adapters = new Map(); + const adapters = new Map( + BUILTIN_ONBOARDING_ADAPTERS.map((adapter) => [adapter.channel, adapter] as const), + ); for (const plugin of listChannelSetupPlugins()) { const adapter = resolveChannelOnboardingAdapter(plugin); if (!adapter) { diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index 6bc049ff626..91e38a6ae99 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -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()}`; diff --git a/src/plugins/loader.test.ts b/src/plugins/loader.test.ts index eec2cf4f410..0460e481b25 100644 --- a/src/plugins/loader.test.ts +++ b/src/plugins/loader.test.ts @@ -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({ diff --git a/src/plugins/loader.ts b/src/plugins/loader.ts index b9ebc7f2a1e..b9132c08f33 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -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; 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, + 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; } diff --git a/src/plugins/registry.ts b/src/plugins/registry.ts index 4b28c277e05..56abbe79bb4 100644 --- a/src/plugins/registry.ts +++ b/src/plugins/registry.ts @@ -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);