fix(onboarding): use scoped plugin snapshots to prevent OOM on low-memory hosts

Onboarding and channel-add flows previously loaded the full plugin registry,
which caused OOM crashes on memory-constrained hosts. This patch introduces
scoped, non-activating plugin registry snapshots that load only the selected
channel plugin without replacing the running gateway's global state.

Key changes:
- Add onlyPluginIds and activate options to loadOpenClawPlugins for scoped loads
- Add suppressGlobalCommands to plugin registry to avoid leaking commands
- Replace full registry reloads in onboarding with per-channel scoped snapshots
- Validate command definitions in snapshot loads without writing global registry
- Preload configured external plugins via scoped discovery during onboarding

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hxy91819 2026-03-14 22:56:00 +08:00 committed by Vincent Koc
parent 392ddb56e2
commit 815bbd1fb1
12 changed files with 828 additions and 87 deletions

View File

@ -2,7 +2,7 @@ import type { OpenClawConfig } from "../../config/config.js";
import type { DmPolicy } from "../../config/types.js";
import type { RuntimeEnv } from "../../runtime.js";
import type { WizardPrompter } from "../../wizard/prompts.js";
import type { ChannelId } from "./types.js";
import type { ChannelId, ChannelPlugin } from "./types.js";
export type SetupChannelsOptions = {
allowDisable?: boolean;
@ -10,6 +10,7 @@ export type SetupChannelsOptions = {
onSelection?: (selection: ChannelId[]) => void;
accountIds?: Partial<Record<ChannelId, string>>;
onAccountId?: (channel: ChannelId, accountId: string) => void;
onResolvedPlugin?: (channel: ChannelId, plugin: ChannelPlugin) => void;
promptAccountIds?: boolean;
whatsappAccountId?: string;
promptWhatsAppAccountId?: boolean;

View File

@ -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<typeof import("../channels/plugins/catalog.js")>();
return {
...actual,
listChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries,
};
});
vi.mock("./onboarding/plugin-install.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./onboarding/plugin-install.js")>();
return {
...actual,
ensureOnboardingPluginInstalled: vi.fn(async ({ cfg }) => ({ cfg, installed: true })),
loadOnboardingPluginRegistrySnapshotForChannel: vi.fn(() => createTestRegistry()),
};
});
const runtime = createTestRuntime();
let channelsAddCommand: typeof import("./channels.js").channelsAddCommand;
@ -18,6 +46,15 @@ describe("channelsAddCommand", () => {
runtime.log.mockClear();
runtime.error.mockClear();
runtime.exit.mockClear();
catalogMocks.listChannelPluginCatalogEntries.mockClear();
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([]);
vi.mocked(ensureOnboardingPluginInstalled).mockClear();
vi.mocked(ensureOnboardingPluginInstalled).mockImplementation(async ({ cfg }) => ({
cfg,
installed: true,
}));
vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockClear();
vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockReturnValue(createTestRegistry());
setDefaultChannelPluginRegistryForTests();
});
@ -59,4 +96,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();
});
});

View File

@ -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;

View File

@ -3,7 +3,7 @@ import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.
import { parseOptionalDelimitedEntries } from "../../channels/plugins/helpers.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import { moveSingleAccountChannelSectionToDefaultAccount } from "../../channels/plugins/setup-helpers.js";
import type { ChannelId, ChannelSetupInput } from "../../channels/plugins/types.js";
import type { ChannelId, ChannelPlugin, ChannelSetupInput } from "../../channels/plugins/types.js";
import { writeConfigFile, type OpenClawConfig } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
@ -55,6 +55,7 @@ export async function channelsAddCommand(
const prompter = createClackPrompter();
let selection: ChannelChoice[] = [];
const accountIds: Partial<Record<ChannelChoice, string>> = {};
const resolvedPlugins = new Map<ChannelChoice, ChannelPlugin>();
await prompter.intro("Channel setup");
let nextConfig = await setupChannels(cfg, runtime, prompter, {
allowDisable: false,
@ -66,6 +67,9 @@ export async function channelsAddCommand(
onAccountId: (channel, accountId) => {
accountIds[channel] = accountId;
},
onResolvedPlugin: (channel, plugin) => {
resolvedPlugins.set(channel, plugin);
},
});
if (selection.length === 0) {
await prompter.outro("No channels selected.");
@ -79,7 +83,7 @@ export async function channelsAddCommand(
if (wantsNames) {
for (const channel of selection) {
const accountId = accountIds[channel] ?? DEFAULT_ACCOUNT_ID;
const plugin = getChannelPlugin(channel);
const plugin = resolvedPlugins.get(channel) ?? getChannelPlugin(channel);
const account = plugin?.config.resolveAccount(nextConfig, accountId) as
| { name?: string }
| undefined;
@ -95,6 +99,7 @@ export async function channelsAddCommand(
channel,
accountId,
name,
plugin,
});
}
}
@ -170,12 +175,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<ChannelPlugin | undefined> => {
const existing = getChannelPlugin(channelId);
if (existing) {
return existing;
}
const { loadOnboardingPluginRegistrySnapshotForChannel } = await import(
"../onboarding/plugin-install.js"
);
const snapshot = loadOnboardingPluginRegistrySnapshotForChannel({
cfg: nextConfig,
runtime,
channel: channelId,
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) {

View File

@ -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>): WizardPrompter {
@ -183,6 +187,7 @@ vi.mock("./onboarding/plugin-install.js", async (importOriginal) => {
return {
...(actual as Record<string, unknown>),
// Allow tests to simulate an empty plugin registry during onboarding.
loadOnboardingPluginRegistrySnapshotForChannel: vi.fn(() => createEmptyPluginRegistry()),
reloadOnboardingPluginRegistry: vi.fn(() => {}),
};
});
@ -190,6 +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<string, unknown> | undefined),
accounts: {
...(cfg.channels?.msteams as { accounts?: Record<string, unknown> } | undefined)
?.accounts,
[accountId]: {
...(
cfg.channels?.msteams as
| {
accounts?: Record<string, Record<string, unknown>>;
}
| undefined
)?.accounts?.[accountId],
enabled,
},
},
},
},
}),
);
vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockImplementation(
({ channel }: { channel: string }) => {
const registry = createEmptyPluginRegistry();
if (channel === "msteams") {
registry.channels.push({
pluginId: "msteams",
source: "test",
plugin: {
id: "msteams",
meta: {
id: "msteams",
label: "Microsoft Teams",
selectionLabel: "Microsoft Teams",
docsPath: "/channels/msteams",
blurb: "teams channel",
},
capabilities: { chatTypes: ["direct"] },
config: {
listAccountIds: (cfg: OpenClawConfig) =>
Object.keys(
(cfg.channels?.msteams as { accounts?: Record<string, unknown> } | undefined)
?.accounts ?? {},
),
resolveAccount: (cfg: OpenClawConfig, accountId: string) =>
(
cfg.channels?.msteams as
| {
accounts?: Record<string, Record<string, unknown>>;
}
| undefined
)?.accounts?.[accountId] ?? { accountId },
setAccountEnabled,
},
onboarding: {
getStatus: vi.fn(async ({ cfg }: { cfg: OpenClawConfig }) => ({
channel: "msteams",
configured: Boolean(
(cfg.channels?.msteams as { tenantId?: string } | undefined)?.tenantId,
),
statusLines: [],
selectionHint: "configured",
})),
},
outbound: { deliveryMode: "direct" },
},
} as never);
}
return registry;
},
);
let channelSelectionCount = 0;
const select = vi.fn(async ({ message, options }: { message: string; options: unknown[] }) => {
if (message === "Select a channel") {
channelSelectionCount += 1;
return channelSelectionCount === 1 ? "msteams" : "__done__";
}
if (message.includes("already configured")) {
return "disable";
}
if (message === "Microsoft Teams account") {
const accountOptions = options as Array<{ value: string; label: string }>;
expect(accountOptions.map((option) => option.value)).toEqual(["default", "work"]);
return "work";
}
return "__done__";
});
const { multiselect, text } = createUnexpectedPromptGuards();
const prompter = createPrompter({
select: select as unknown as WizardPrompter["select"],
multiselect,
text,
});
const next = await runSetupChannels(
{
channels: {
msteams: {
tenantId: "tenant-1",
accounts: {
default: { enabled: true },
work: { enabled: true },
},
},
},
plugins: {
entries: {
msteams: { enabled: true },
},
},
} as OpenClawConfig,
prompter,
{ allowDisable: true },
);
expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
expect.objectContaining({ channel: "msteams" }),
);
expect(setAccountEnabled).toHaveBeenCalledWith(
expect.objectContaining({ accountId: "work", enabled: false }),
);
expect(
(
next.channels?.msteams as
| {
accounts?: Record<string, { enabled?: boolean }>;
}
| undefined
)?.accounts?.work?.enabled,
).toBe(false);
expect(multiselect).not.toHaveBeenCalled();
});
it("prompts for configured channel action and skips configuration when told to skip", async () => {
const select = createQuickstartTelegramSelect({
configuredAction: "skip",

View File

@ -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<string> {
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<Record<ChannelChoice, string>>;
installedPlugins?: ReturnType<typeof listChannelPlugins>;
}): Promise<ChannelStatusSummary> {
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<ChannelChoice, string>;
resolveAdapter?: (channel: ChannelChoice) => ChannelOnboardingAdapter | undefined;
}): Promise<OpenClawConfig> {
const { selection, prompter, accountIdsByChannel } = params;
const resolve = params.resolveAdapter ?? getChannelOnboardingAdapter;
const dmPolicies = selection
.map((channel) => getChannelOnboardingAdapter(channel)?.dmPolicy)
.map((channel) => resolve(channel)?.dmPolicy)
.filter(Boolean) as ChannelOnboardingDmPolicy[];
if (dmPolicies.length === 0) {
return params.cfg;
@ -301,12 +306,83 @@ export async function setupChannels(
const accountOverrides: Partial<Record<ChannelChoice, string>> = {
...options?.accountIds,
};
const scopedPluginsById = new Map<ChannelChoice, ChannelPlugin>();
const resolveWorkspaceDir = () => resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next));
const rememberScopedPlugin = (plugin: ChannelPlugin) => {
const channel = plugin.id;
scopedPluginsById.set(channel, plugin);
options?.onResolvedPlugin?.(channel, plugin);
};
const getVisibleChannelPlugin = (channel: ChannelChoice): ChannelPlugin | undefined =>
scopedPluginsById.get(channel) ?? getChannelPlugin(channel);
const listVisibleInstalledPlugins = (): ChannelPlugin[] => {
const merged = new Map<string, ChannelPlugin>();
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<ChannelChoice, string>();
const recordAccount = (channel: ChannelChoice, accountId: string) => {
options?.onAccountId?.(channel, accountId);
const adapter = getChannelOnboardingAdapter(channel);
const adapter = getVisibleOnboardingAdapter(channel);
adapter?.onAccountRecorded?.(accountId, options);
accountIdsByChannel.set(channel, accountId);
};
@ -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<boolean> => {
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,
});
}

View File

@ -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,
}),
);
});
});

View File

@ -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,
});
}

View File

@ -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()}`;

View File

@ -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({

View File

@ -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<string, PluginInstallRecord>;
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<string, GatewayRequestHandler>,
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;
}

View File

@ -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);