Skip to content

Commit f4cc93d

Browse files
hxy91819claudevincentkoc
authored
fix(onboarding): use scoped plugin snapshots to prevent OOM on low-memory hosts (openclaw#46763)
* fix(onboarding): use scoped plugin snapshots to prevent OOM on low-memory hosts Onboarding and channel-add flows previously loaded the full plugin registry, which caused OOM crashes on memory-constrained hosts. This patch introduces scoped, non-activating plugin registry snapshots that load only the selected channel plugin without replacing the running gateway's global state. Key changes: - Add onlyPluginIds and activate options to loadOpenClawPlugins for scoped loads - Add suppressGlobalCommands to plugin registry to avoid leaking commands - Replace full registry reloads in onboarding with per-channel scoped snapshots - Validate command definitions in snapshot loads without writing global registry - Preload configured external plugins via scoped discovery during onboarding Co-Authored-By: Claude Opus 4.6 <[email protected]> * fix(test): add return type annotation to hoisted mock to resolve TS2322 * fix(plugins): enforce cache:false invariant for non-activating snapshot loads * Channels: preserve lazy scoped snapshot import after rebase * Onboarding: scope channel snapshots by plugin id * Catalog: trust manifest ids for channel plugin mapping * Onboarding: preserve scoped setup channel loading * Onboarding: restore built-in adapter fallback --------- Co-authored-by: Claude Opus 4.6 <[email protected]> Co-authored-by: Vincent Koc <[email protected]>
1 parent a058bf9 commit f4cc93d

File tree

15 files changed

+1127
-99
lines changed

15 files changed

+1127
-99
lines changed

src/channels/plugins/catalog.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import fs from "node:fs";
22
import path from "node:path";
33
import { MANIFEST_KEY } from "../../compat/legacy-names.js";
44
import { discoverOpenClawPlugins } from "../../plugins/discovery.js";
5+
import { loadPluginManifest } from "../../plugins/manifest.js";
56
import type { OpenClawPackageManifest } from "../../plugins/manifest.js";
67
import type { PluginOrigin } from "../../plugins/types.js";
78
import { isRecord, resolveConfigDir, resolveUserPath } from "../../utils.js";
@@ -25,6 +26,7 @@ export type ChannelUiCatalog = {
2526

2627
export type ChannelPluginCatalogEntry = {
2728
id: string;
29+
pluginId?: string;
2830
meta: ChannelMeta;
2931
install: {
3032
npmSpec: string;
@@ -196,9 +198,26 @@ function resolveInstallInfo(params: {
196198
};
197199
}
198200

201+
function resolveCatalogPluginId(params: {
202+
packageDir?: string;
203+
rootDir?: string;
204+
origin?: PluginOrigin;
205+
}): string | undefined {
206+
const manifestDir = params.packageDir ?? params.rootDir;
207+
if (manifestDir) {
208+
const manifest = loadPluginManifest(manifestDir, params.origin !== "bundled");
209+
if (manifest.ok) {
210+
return manifest.manifest.id;
211+
}
212+
}
213+
return undefined;
214+
}
215+
199216
function buildCatalogEntry(candidate: {
200217
packageName?: string;
201218
packageDir?: string;
219+
rootDir?: string;
220+
origin?: PluginOrigin;
202221
workspaceDir?: string;
203222
packageManifest?: OpenClawPackageManifest;
204223
}): ChannelPluginCatalogEntry | null {
@@ -223,7 +242,17 @@ function buildCatalogEntry(candidate: {
223242
if (!install) {
224243
return null;
225244
}
226-
return { id, meta, install };
245+
const pluginId = resolveCatalogPluginId({
246+
packageDir: candidate.packageDir,
247+
rootDir: candidate.rootDir,
248+
origin: candidate.origin,
249+
});
250+
return {
251+
id,
252+
...(pluginId ? { pluginId } : {}),
253+
meta,
254+
install,
255+
};
227256
}
228257

229258
function buildExternalCatalogEntry(entry: ExternalCatalogEntry): ChannelPluginCatalogEntry | null {

src/channels/plugins/onboarding-types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,15 @@ import type { OpenClawConfig } from "../../config/config.js";
22
import type { DmPolicy } from "../../config/types.js";
33
import type { RuntimeEnv } from "../../runtime.js";
44
import type { WizardPrompter } from "../../wizard/prompts.js";
5-
import type { ChannelId } from "./types.js";
5+
import type { ChannelId, ChannelPlugin } from "./types.js";
66

77
export type SetupChannelsOptions = {
88
allowDisable?: boolean;
99
allowSignalInstall?: boolean;
1010
onSelection?: (selection: ChannelId[]) => void;
1111
accountIds?: Partial<Record<ChannelId, string>>;
1212
onAccountId?: (channel: ChannelId, accountId: string) => void;
13+
onResolvedPlugin?: (channel: ChannelId, plugin: ChannelPlugin) => void;
1314
promptAccountIds?: boolean;
1415
whatsappAccountId?: string;
1516
promptWhatsAppAccountId?: boolean;

src/channels/plugins/plugins-core.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,50 @@ describe("channel plugin catalog", () => {
154154
expect(ids).toContain("demo-channel");
155155
});
156156

157+
it("preserves plugin ids when they differ from channel ids", () => {
158+
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-catalog-state-"));
159+
const pluginDir = path.join(stateDir, "extensions", "demo-channel-plugin");
160+
fs.mkdirSync(pluginDir, { recursive: true });
161+
fs.writeFileSync(
162+
path.join(pluginDir, "package.json"),
163+
JSON.stringify({
164+
name: "@vendor/demo-channel-plugin",
165+
openclaw: {
166+
extensions: ["./index.js"],
167+
channel: {
168+
id: "demo-channel",
169+
label: "Demo Channel",
170+
selectionLabel: "Demo Channel",
171+
docsPath: "/channels/demo-channel",
172+
blurb: "Demo channel",
173+
},
174+
install: {
175+
npmSpec: "@vendor/demo-channel-plugin",
176+
},
177+
},
178+
}),
179+
);
180+
fs.writeFileSync(
181+
path.join(pluginDir, "openclaw.plugin.json"),
182+
JSON.stringify({
183+
id: "@vendor/demo-runtime",
184+
configSchema: {},
185+
}),
186+
);
187+
fs.writeFileSync(path.join(pluginDir, "index.js"), "module.exports = {}", "utf-8");
188+
189+
const entry = listChannelPluginCatalogEntries({
190+
env: {
191+
...process.env,
192+
OPENCLAW_STATE_DIR: stateDir,
193+
CLAWDBOT_STATE_DIR: undefined,
194+
OPENCLAW_BUNDLED_PLUGINS_DIR: "/nonexistent/bundled/plugins",
195+
},
196+
}).find((item) => item.id === "demo-channel");
197+
198+
expect(entry?.pluginId).toBe("@vendor/demo-runtime");
199+
});
200+
157201
it("uses the provided env for external catalog path resolution", () => {
158202
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-home-"));
159203
const catalogPath = path.join(home, "catalog.json");

src/commands/channels.add.test.ts

Lines changed: 183 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,36 @@
1-
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
1+
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
2+
import type { ChannelPluginCatalogEntry } from "../channels/plugins/catalog.js";
3+
import { setActivePluginRegistry } from "../plugins/runtime.js";
4+
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
25
import { setDefaultChannelPluginRegistryForTests } from "./channel-test-helpers.js";
36
import { configMocks, offsetMocks } from "./channels.mock-harness.js";
7+
import {
8+
ensureOnboardingPluginInstalled,
9+
loadOnboardingPluginRegistrySnapshotForChannel,
10+
} from "./onboarding/plugin-install.js";
411
import { baseConfigSnapshot, createTestRuntime } from "./test-runtime-config-helpers.js";
512

13+
const catalogMocks = vi.hoisted(() => ({
14+
listChannelPluginCatalogEntries: vi.fn((): ChannelPluginCatalogEntry[] => []),
15+
}));
16+
17+
vi.mock("../channels/plugins/catalog.js", async (importOriginal) => {
18+
const actual = await importOriginal<typeof import("../channels/plugins/catalog.js")>();
19+
return {
20+
...actual,
21+
listChannelPluginCatalogEntries: catalogMocks.listChannelPluginCatalogEntries,
22+
};
23+
});
24+
25+
vi.mock("./onboarding/plugin-install.js", async (importOriginal) => {
26+
const actual = await importOriginal<typeof import("./onboarding/plugin-install.js")>();
27+
return {
28+
...actual,
29+
ensureOnboardingPluginInstalled: vi.fn(async ({ cfg }) => ({ cfg, installed: true })),
30+
loadOnboardingPluginRegistrySnapshotForChannel: vi.fn(() => createTestRegistry()),
31+
};
32+
});
33+
634
const runtime = createTestRuntime();
735
let channelsAddCommand: typeof import("./channels.js").channelsAddCommand;
836

@@ -18,6 +46,15 @@ describe("channelsAddCommand", () => {
1846
runtime.log.mockClear();
1947
runtime.error.mockClear();
2048
runtime.exit.mockClear();
49+
catalogMocks.listChannelPluginCatalogEntries.mockClear();
50+
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([]);
51+
vi.mocked(ensureOnboardingPluginInstalled).mockClear();
52+
vi.mocked(ensureOnboardingPluginInstalled).mockImplementation(async ({ cfg }) => ({
53+
cfg,
54+
installed: true,
55+
}));
56+
vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockClear();
57+
vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockReturnValue(createTestRegistry());
2158
setDefaultChannelPluginRegistryForTests();
2259
});
2360

@@ -59,4 +96,149 @@ describe("channelsAddCommand", () => {
5996

6097
expect(offsetMocks.deleteTelegramUpdateOffset).not.toHaveBeenCalled();
6198
});
99+
100+
it("falls back to a scoped snapshot after installing an external channel plugin", async () => {
101+
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
102+
setActivePluginRegistry(createTestRegistry());
103+
const catalogEntry: ChannelPluginCatalogEntry = {
104+
id: "msteams",
105+
pluginId: "@openclaw/msteams-plugin",
106+
meta: {
107+
id: "msteams",
108+
label: "Microsoft Teams",
109+
selectionLabel: "Microsoft Teams",
110+
docsPath: "/channels/msteams",
111+
blurb: "teams channel",
112+
},
113+
install: {
114+
npmSpec: "@openclaw/msteams",
115+
},
116+
};
117+
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]);
118+
const scopedMSTeamsPlugin = {
119+
...createChannelTestPluginBase({
120+
id: "msteams",
121+
label: "Microsoft Teams",
122+
docsPath: "/channels/msteams",
123+
}),
124+
setup: {
125+
applyAccountConfig: vi.fn(({ cfg, input }) => ({
126+
...cfg,
127+
channels: {
128+
...cfg.channels,
129+
msteams: {
130+
enabled: true,
131+
tenantId: input.token,
132+
},
133+
},
134+
})),
135+
},
136+
};
137+
vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockReturnValue(
138+
createTestRegistry([{ pluginId: "msteams", plugin: scopedMSTeamsPlugin, source: "test" }]),
139+
);
140+
141+
await channelsAddCommand(
142+
{
143+
channel: "msteams",
144+
account: "default",
145+
token: "tenant-scoped",
146+
},
147+
runtime,
148+
{ hasFlags: true },
149+
);
150+
151+
expect(ensureOnboardingPluginInstalled).toHaveBeenCalledWith(
152+
expect.objectContaining({ entry: catalogEntry }),
153+
);
154+
expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
155+
expect.objectContaining({
156+
channel: "msteams",
157+
pluginId: "@openclaw/msteams-plugin",
158+
}),
159+
);
160+
expect(configMocks.writeConfigFile).toHaveBeenCalledWith(
161+
expect.objectContaining({
162+
channels: {
163+
msteams: {
164+
enabled: true,
165+
tenantId: "tenant-scoped",
166+
},
167+
},
168+
}),
169+
);
170+
expect(runtime.error).not.toHaveBeenCalled();
171+
expect(runtime.exit).not.toHaveBeenCalled();
172+
});
173+
174+
it("uses the installed plugin id when channel and plugin ids differ", async () => {
175+
configMocks.readConfigFileSnapshot.mockResolvedValue({ ...baseConfigSnapshot });
176+
setActivePluginRegistry(createTestRegistry());
177+
const catalogEntry: ChannelPluginCatalogEntry = {
178+
id: "msteams",
179+
pluginId: "@openclaw/msteams-plugin",
180+
meta: {
181+
id: "msteams",
182+
label: "Microsoft Teams",
183+
selectionLabel: "Microsoft Teams",
184+
docsPath: "/channels/msteams",
185+
blurb: "teams channel",
186+
},
187+
install: {
188+
npmSpec: "@openclaw/msteams",
189+
},
190+
};
191+
catalogMocks.listChannelPluginCatalogEntries.mockReturnValue([catalogEntry]);
192+
vi.mocked(ensureOnboardingPluginInstalled).mockImplementation(async ({ cfg }) => ({
193+
cfg,
194+
installed: true,
195+
pluginId: "@vendor/teams-runtime",
196+
}));
197+
vi.mocked(loadOnboardingPluginRegistrySnapshotForChannel).mockReturnValue(
198+
createTestRegistry([
199+
{
200+
pluginId: "@vendor/teams-runtime",
201+
plugin: {
202+
...createChannelTestPluginBase({
203+
id: "msteams",
204+
label: "Microsoft Teams",
205+
docsPath: "/channels/msteams",
206+
}),
207+
setup: {
208+
applyAccountConfig: vi.fn(({ cfg, input }) => ({
209+
...cfg,
210+
channels: {
211+
...cfg.channels,
212+
msteams: {
213+
enabled: true,
214+
tenantId: input.token,
215+
},
216+
},
217+
})),
218+
},
219+
},
220+
source: "test",
221+
},
222+
]),
223+
);
224+
225+
await channelsAddCommand(
226+
{
227+
channel: "msteams",
228+
account: "default",
229+
token: "tenant-scoped",
230+
},
231+
runtime,
232+
{ hasFlags: true },
233+
);
234+
235+
expect(loadOnboardingPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
236+
expect.objectContaining({
237+
channel: "msteams",
238+
pluginId: "@vendor/teams-runtime",
239+
}),
240+
);
241+
expect(runtime.error).not.toHaveBeenCalled();
242+
expect(runtime.exit).not.toHaveBeenCalled();
243+
});
62244
});

src/commands/channels/add-mutators.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { getChannelPlugin } from "../../channels/plugins/index.js";
2-
import type { ChannelId, ChannelSetupInput } from "../../channels/plugins/types.js";
2+
import type { ChannelId, ChannelPlugin, ChannelSetupInput } from "../../channels/plugins/types.js";
33
import type { OpenClawConfig } from "../../config/config.js";
44
import { normalizeAccountId } from "../../routing/session-key.js";
55

@@ -10,9 +10,10 @@ export function applyAccountName(params: {
1010
channel: ChatChannel;
1111
accountId: string;
1212
name?: string;
13+
plugin?: ChannelPlugin;
1314
}): OpenClawConfig {
1415
const accountId = normalizeAccountId(params.accountId);
15-
const plugin = getChannelPlugin(params.channel);
16+
const plugin = params.plugin ?? getChannelPlugin(params.channel);
1617
const apply = plugin?.setup?.applyAccountName;
1718
return apply ? apply({ cfg: params.cfg, accountId, name: params.name }) : params.cfg;
1819
}
@@ -22,9 +23,10 @@ export function applyChannelAccountConfig(params: {
2223
channel: ChatChannel;
2324
accountId: string;
2425
input: ChannelSetupInput;
26+
plugin?: ChannelPlugin;
2527
}): OpenClawConfig {
2628
const accountId = normalizeAccountId(params.accountId);
27-
const plugin = getChannelPlugin(params.channel);
29+
const plugin = params.plugin ?? getChannelPlugin(params.channel);
2830
const apply = plugin?.setup?.applyAccountConfig;
2931
if (!apply) {
3032
return params.cfg;

0 commit comments

Comments
 (0)