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/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index 3d3929ec878..67ccd28f192 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(() => []), +})); + +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,74 @@ 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", + 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" }), + ); + expect(configMocks.writeConfigFile).toHaveBeenCalledWith( + expect.objectContaining({ + channels: { + msteams: { + enabled: true, + tenantId: "tenant-scoped", + }, + }, + }), + ); + 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..d0c10dc5f0a 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,30 @@ 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): 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, + 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 +210,6 @@ export async function channelsAddCommand( if (!result.installed) { return; } - reloadOnboardingPluginRegistry({ cfg: nextConfig, runtime, workspaceDir }); channel = normalizeChannelId(catalogEntry.id) ?? (catalogEntry.id as ChannelId); } @@ -200,7 +222,7 @@ export async function channelsAddCommand( return; } - const plugin = getChannelPlugin(channel); + const plugin = await loadScopedPlugin(channel); if (!plugin?.setup?.applyAccountConfig) { runtime.error(`Channel ${channel} does not support add.`); runtime.exit(1); @@ -294,6 +316,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 88606bcc3cc..67ee658ead4 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -8,6 +8,10 @@ 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"; function createPrompter(overrides: Partial): WizardPrompter { @@ -183,6 +187,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 +195,8 @@ vi.mock("./onboarding/plugin-install.js", async (importOriginal) => { describe("setupChannels", () => { beforeEach(() => { setDefaultChannelPluginRegistryForTests(); + 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 +264,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 +295,226 @@ describe("setupChannels", () => { expect(multiselect).not.toHaveBeenCalled(); }); + it("keeps configured external plugin channels visible when the active registry starts empty", async () => { + setActivePluginRegistry(createEmptyPluginRegistry()); + 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: () => [], + 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: { + msteams: { enabled: true }, + }, + }, + } as OpenClawConfig, + prompter, + ); + + expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "msteams", + }), + ); + 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 6e79379e1f1..579518220f3 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -2,7 +2,7 @@ import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js"; import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js"; import { listChannelPlugins, getChannelPlugin } from "../channels/plugins/index.js"; -import type { ChannelMeta } from "../channels/plugins/types.js"; +import type { ChannelMeta, ChannelPlugin } from "../channels/plugins/types.js"; import { formatChannelPrimerLine, formatChannelSelectionLine, @@ -20,13 +20,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, @@ -88,9 +89,10 @@ async function promptRemovalAccountId(params: { prompter: WizardPrompter; label: string; channel: ChannelChoice; + plugin?: ChannelPlugin; }): Promise { const { cfg, prompter, label, channel } = params; - const plugin = getChannelPlugin(channel); + const plugin = params.plugin ?? getChannelPlugin(channel); if (!plugin) { return DEFAULT_ACCOUNT_ID; } @@ -114,8 +116,9 @@ async function collectChannelStatus(params: { cfg: OpenClawConfig; options?: SetupChannelsOptions; accountOverrides: Partial>; + installedPlugins?: ReturnType; }): Promise { - const installedPlugins = listChannelPlugins(); + const installedPlugins = params.installedPlugins ?? listChannelPlugins(); const installedIds = new Set(installedPlugins.map((plugin) => plugin.id)); const workspaceDir = resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); const catalogEntries = listChannelPluginCatalogEntries({ workspaceDir }).filter( @@ -227,10 +230,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; @@ -301,12 +306,83 @@ export async function setupChannels( 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) ?? getChannelPlugin(channel); + const listVisibleInstalledPlugins = (): ChannelPlugin[] => { + const merged = new Map(); + for (const plugin of listChannelPlugins()) { + 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): ChannelPlugin | undefined => { + const existing = getVisibleChannelPlugin(channel); + if (existing) { + return existing; + } + const snapshot = loadOnboardingPluginRegistrySnapshotForChannel({ + cfg: next, + runtime, + channel, + workspaceDir: resolveWorkspaceDir(), + }); + const plugin = snapshot.channels.find((entry) => entry.plugin.id === channel)?.plugin; + if (plugin) { + rememberScopedPlugin(plugin); + } + return plugin; + }; + // Resolve onboarding adapter with fallback to scoped plugins. + // getChannelOnboardingAdapter reads from the active global registry, but scoped loads + // do not activate the global registry, so extension channels installed during the wizard + // need to be looked up from scopedPluginsById as well. + const getVisibleOnboardingAdapter = (channel: ChannelChoice) => { + const fromGlobal = getChannelOnboardingAdapter(channel); + if (fromGlobal) { + return fromGlobal; + } + return scopedPluginsById.get(channel)?.onboarding; + }; + const preloadConfiguredExternalPlugins = () => { + // Preload configured external channels so onboarding can show their status and reuse + // scoped plugin state without activating the full registry. This may still perform + // one scoped discovery per configured channel; we accept that trade-off here to keep + // onboarding memory usage bounded on low-memory hosts. + const workspaceDir = resolveWorkspaceDir(); + for (const entry of listChannelPluginCatalogEntries({ workspaceDir })) { + const channel = entry.id as ChannelChoice; + if (getVisibleChannelPlugin(channel)) { + continue; + } + const explicitlyEnabled = next.plugins?.entries?.[channel]?.enabled === true; + if (!explicitlyEnabled && !isChannelConfigured(next, channel)) { + continue; + } + loadScopedChannelPlugin(channel); + } + }; 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"); } @@ -353,7 +429,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); }; @@ -366,7 +442,7 @@ export async function setupChannels( }; const resolveDisabledHint = (channel: ChannelChoice): string | undefined => { - const plugin = getChannelPlugin(channel); + const plugin = getVisibleChannelPlugin(channel); if (!plugin) { if (next.plugins?.entries?.[channel]?.enabled === false) { return "plugin disabled"; @@ -411,9 +487,9 @@ export async function setupChannels( const getChannelEntries = () => { const core = listChatChannels(); - const installed = listChannelPlugins(); + 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), ); @@ -441,7 +517,7 @@ export async function setupChannels( }; const refreshStatus = async (channel: ChannelChoice) => { - const adapter = getChannelOnboardingAdapter(channel); + const adapter = getVisibleOnboardingAdapter(channel); if (!adapter) { return; } @@ -450,7 +526,10 @@ export async function setupChannels( }; const ensureBundledPluginEnabled = async (channel: ChannelChoice): Promise => { - if (getChannelPlugin(channel)) { + if (getVisibleChannelPlugin(channel)) { + // Plugin already visible (e.g. preloaded by preloadConfiguredExternalPlugins). + // Refresh status so handleChannelChoice sees the correct configured state. + await refreshStatus(channel); return true; } const result = enablePluginInConfig(next, channel); @@ -462,17 +541,12 @@ export async function setupChannels( ); return false; } - const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)); - reloadOnboardingPluginRegistry({ - cfg: next, - runtime, - workspaceDir, - }); - if (!getChannelPlugin(channel)) { - // Some installs/environments can fail to populate the plugin registry during onboarding, - // even for built-in channels. If the channel supports onboarding, proceed with config - // so setup isn't blocked; the gateway can still load plugins on startup. - const adapter = getChannelOnboardingAdapter(channel); + const adapter = getVisibleOnboardingAdapter(channel); + const plugin = loadScopedChannelPlugin(channel); + if (!plugin) { + // Some installs/environments can fail to populate the plugin registry during onboarding. + // If the channel supports onboarding, proceed with config so setup isn't blocked; + // the gateway can still load plugins on startup. if (adapter) { await prompter.note( `${channel} plugin not available (continuing with onboarding). If the channel still doesn't work after setup, run \`${formatCliCommand( @@ -511,7 +585,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; @@ -529,8 +603,8 @@ export async function setupChannels( }; const handleConfiguredChannel = async (channel: ChannelChoice, label: string) => { - const plugin = getChannelPlugin(channel); - const adapter = getChannelOnboardingAdapter(channel); + const plugin = getVisibleChannelPlugin(channel); + const adapter = getVisibleOnboardingAdapter(channel); if (adapter?.configureWhenConfigured) { const custom = await adapter.configureWhenConfigured({ cfg: next, @@ -585,6 +659,7 @@ export async function setupChannels( prompter, label, channel, + plugin, }) : DEFAULT_ACCOUNT_ID; const resolvedAccountId = @@ -635,11 +710,7 @@ export async function setupChannels( if (!result.installed) { return; } - reloadOnboardingPluginRegistry({ - cfg: next, - runtime, - workspaceDir, - }); + loadScopedChannelPlugin(channel); await refreshStatus(channel); } else { const enabled = await ensureBundledPluginEnabled(channel); @@ -648,8 +719,8 @@ export async function setupChannels( } } - const plugin = getChannelPlugin(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; @@ -738,6 +809,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..78a1c66aa4e 100644 --- a/src/commands/onboarding/plugin-install.test.ts +++ b/src/commands/onboarding/plugin-install.test.ts @@ -58,11 +58,15 @@ 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 = { @@ -84,6 +88,7 @@ const baseEntry: ChannelPluginCatalogEntry = { beforeEach(() => { vi.clearAllMocks(); resolveBundledPluginSources.mockReturnValue(new Map()); + setActivePluginRegistry(createEmptyPluginRegistry()); }); function mockRepoLocalPathExists() { @@ -268,4 +273,86 @@ 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, + }), + ); + }); }); diff --git a/src/commands/onboarding/plugin-install.ts b/src/commands/onboarding/plugin-install.ts index b4aabc06646..4dbfe9da4fc 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"; @@ -225,14 +227,55 @@ 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; + 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.channel]; + loadOnboardingPluginRegistry({ + ...params, + onlyPluginIds, + }); +} + +export function loadOnboardingPluginRegistrySnapshotForChannel(params: { + cfg: OpenClawConfig; + runtime: RuntimeEnv; + channel: string; + workspaceDir?: string; +}): PluginRegistry { + return loadOnboardingPluginRegistry({ + ...params, + onlyPluginIds: [params.channel], + activate: false, }); } diff --git a/src/plugins/commands.ts b/src/plugins/commands.ts index 00e4b3b34ae..c752639dba9 100644 --- a/src/plugins/commands.ts +++ b/src/plugins/commands.ts @@ -102,6 +102,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. @@ -115,28 +138,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 939e9a9f56c..689d4c67df3 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 }; @@ -455,6 +463,103 @@ 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("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 20d5772d3f7..cfd3af6a8e8 100644 --- a/src/plugins/loader.ts +++ b/src/plugins/loader.ts @@ -47,6 +47,8 @@ export type PluginLoadOptions = { runtimeOptions?: CreatePluginRuntimeOptions; cache?: boolean; mode?: "full" | "validate"; + onlyPluginIds?: string[]; + activate?: boolean; }; const MAX_PLUGIN_REGISTRY_CACHE_ENTRIES = 32; @@ -238,6 +240,7 @@ function buildCacheKey(params: { plugins: NormalizedPluginsConfig; installs?: Record; env: NodeJS.ProcessEnv; + onlyPluginIds?: string[]; }): string { const { roots, loadPaths } = resolvePluginCacheInputs({ workspaceDir: params.workspaceDir, @@ -260,11 +263,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: { @@ -636,23 +648,36 @@ 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(); + // Clear previously registered plugin commands before reloading. + // Skip for non-activating (snapshot) loads to avoid wiping commands from other plugins. + if (shouldActivate) { + clearPluginCommands(); + } // Lazily initialize the runtime so startup paths that discover/skip plugins do // not eagerly load every channel runtime dependency. @@ -691,6 +716,7 @@ export function loadOpenClawPlugins(options: PluginLoadOptions = {}): PluginRegi logger, runtime, coreGatewayHandlers: options.coreGatewayHandlers as Record, + suppressGlobalCommands: !shouldActivate, }); const discovery = discoverOpenClawPlugins({ @@ -713,11 +739,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, @@ -774,6 +804,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({ @@ -999,7 +1034,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}`, @@ -1016,7 +1053,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 fe978d6a346..493c090a7bc 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 { normalizeRegisteredProvider } from "./provider-validation.js"; @@ -147,6 +147,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 = { @@ -550,16 +553,34 @@ export function createPluginRegistry(registryParams: PluginRegistryParams) { return; } - // Register with the plugin command system (validates name and checks for duplicates) - const result = registerPluginCommand(record.id, command); - if (!result.ok) { - pushDiagnostic({ - level: "error", - pluginId: record.id, - source: record.source, - message: `command registration failed: ${result.error}`, - }); - return; + // 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); + if (!result.ok) { + pushDiagnostic({ + level: "error", + pluginId: record.id, + source: record.source, + message: `command registration failed: ${result.error}`, + }); + return; + } } record.commands.push(name);