refactor: install heavy plugins on demand

This commit is contained in:
Peter Steinberger 2026-03-19 03:36:57 +00:00
parent 83c5bc946d
commit b7ca56f662
19 changed files with 671 additions and 156 deletions

View File

@ -9,6 +9,21 @@ title: "WhatsApp"
Status: production-ready via WhatsApp Web (Baileys). Gateway owns linked session(s).
## Install (on demand)
- Onboarding (`openclaw onboard`) and `openclaw channels add --channel whatsapp`
prompt to install the WhatsApp plugin the first time you select it.
- `openclaw channels login --channel whatsapp` also offers the install flow when
the plugin is not present yet.
- Dev channel + git checkout: defaults to the local plugin path.
- Stable/Beta: defaults to the npm package `@openclaw/whatsapp`.
Manual install stays available:
```bash
openclaw plugins install @openclaw/whatsapp
```
<CardGroup cols={3}>
<Card title="Pairing" icon="link" href="/channels/pairing">
Default DM policy is pairing for unknown senders.

View File

@ -76,6 +76,12 @@ These are published to npm and installed with `openclaw plugins install`:
Microsoft Teams is plugin-only as of 2026.1.15.
Packaged installs also ship install-on-demand metadata for heavyweight official
plugins. Today that includes WhatsApp and `memory-lancedb`: onboarding,
`openclaw channels add`, `openclaw channels login --channel whatsapp`, and
other channel setup flows prompt to install them when first used instead of
shipping their full runtime trees inside the main npm tarball.
### Bundled plugins
These ship with OpenClaw and are enabled by default unless noted.
@ -83,7 +89,7 @@ These ship with OpenClaw and are enabled by default unless noted.
**Memory:**
- `memory-core` -- bundled memory search (default via `plugins.slots.memory`)
- `memory-lancedb` -- long-term memory with auto-recall/capture (set `plugins.slots.memory = "memory-lancedb"`)
- `memory-lancedb` -- install-on-demand long-term memory with auto-recall/capture (set `plugins.slots.memory = "memory-lancedb"`)
**Model providers** (all enabled by default):
@ -193,8 +199,10 @@ enablement via `plugins.entries.<id>.enabled` or
Bundled plugin runtime dependencies are owned by each plugin package. Packaged
builds stage opted-in bundled dependencies under
`dist/extensions/<id>/node_modules` instead of requiring mirrored copies in the
root package. npm artifacts ship the built `dist/extensions/*` tree; source
`extensions/*` directories stay in source checkouts only.
root package. Very large official plugins can ship as metadata-only bundled
entries and install their runtime package on demand. npm artifacts ship the
built `dist/extensions/*` tree; source `extensions/*` directories stay in source
checkouts only.
Installed plugins are enabled by default, but can be disabled the same way.

View File

@ -1,7 +1,6 @@
{
"name": "@openclaw/memory-lancedb",
"version": "2026.3.14",
"private": true,
"description": "OpenClaw LanceDB-backed long-term memory plugin with auto-recall/capture",
"type": "module",
"dependencies": {
@ -12,6 +11,14 @@
"openclaw": {
"extensions": [
"./index.ts"
]
],
"install": {
"npmSpec": "@openclaw/memory-lancedb",
"localPath": "extensions/memory-lancedb",
"defaultChoice": "npm"
},
"release": {
"publishToNpm": true
}
}
}

View File

@ -21,6 +21,14 @@
"docsLabel": "whatsapp",
"blurb": "works with your own number; recommend a separate phone + eSIM.",
"systemImage": "message"
},
"install": {
"npmSpec": "@openclaw/whatsapp",
"localPath": "extensions/whatsapp",
"defaultChoice": "npm"
},
"release": {
"publishToNpm": true
}
}
}

View File

@ -690,7 +690,6 @@
"@aws-sdk/client-bedrock": "^3.1011.0",
"@clack/prompts": "^1.1.0",
"@homebridge/ciao": "^1.3.5",
"@lancedb/lancedb": "^0.27.0",
"@line/bot-sdk": "^10.6.0",
"@lydell/node-pty": "1.2.0-beta.3",
"@mariozechner/pi-agent-core": "0.58.0",
@ -700,7 +699,6 @@
"@modelcontextprotocol/sdk": "1.27.1",
"@mozilla/readability": "^0.6.0",
"@sinclair/typebox": "0.34.48",
"@whiskeysockets/baileys": "7.0.0-rc.9",
"ajv": "^8.18.0",
"chalk": "^5.6.2",
"chokidar": "^5.0.0",

6
pnpm-lock.yaml generated
View File

@ -40,9 +40,6 @@ importers:
'@homebridge/ciao':
specifier: ^1.3.5
version: 1.3.5
'@lancedb/lancedb':
specifier: ^0.27.0
version: 0.27.0(apache-arrow@18.1.0)
'@line/bot-sdk':
specifier: ^10.6.0
version: 10.6.0
@ -73,9 +70,6 @@ importers:
'@sinclair/typebox':
specifier: 0.34.48
version: 0.34.48
'@whiskeysockets/baileys':
specifier: 7.0.0-rc.9
version: 7.0.0-rc.9(audio-decode@2.2.3)(jimp@1.6.0)(sharp@0.34.5)
ajv:
specifier: ^8.18.0
version: 8.18.0

View File

@ -10,6 +10,7 @@ export const optionalBundledClusters = [
"tlon",
"twitch",
"ui",
"whatsapp",
"zalouser",
];

View File

@ -16,8 +16,6 @@ import { slackSetupPlugin } from "../../../extensions/slack/setup-entry.js";
import { synologyChatPlugin } from "../../../extensions/synology-chat/index.js";
import { telegramPlugin, setTelegramRuntime } from "../../../extensions/telegram/index.js";
import { telegramSetupPlugin } from "../../../extensions/telegram/setup-entry.js";
import { whatsappPlugin } from "../../../extensions/whatsapp/index.js";
import { whatsappSetupPlugin } from "../../../extensions/whatsapp/setup-entry.js";
import { zaloPlugin } from "../../../extensions/zalo/index.js";
import type { ChannelId, ChannelPlugin } from "./types.js";
@ -34,13 +32,11 @@ export const bundledChannelPlugins = [
slackPlugin,
synologyChatPlugin,
telegramPlugin,
whatsappPlugin,
zaloPlugin,
] as ChannelPlugin[];
export const bundledChannelSetupPlugins = [
telegramSetupPlugin,
whatsappSetupPlugin,
discordSetupPlugin,
ircPlugin,
slackSetupPlugin,

View File

@ -1,9 +1,11 @@
import fs from "node:fs";
import path from "node:path";
import { MANIFEST_KEY } from "../../compat/legacy-names.js";
import { resolveBundledPluginsDir } from "../../plugins/bundled-dir.js";
import { discoverOpenClawPlugins } from "../../plugins/discovery.js";
import { loadPluginManifest } from "../../plugins/manifest.js";
import type { OpenClawPackageManifest } from "../../plugins/manifest.js";
import type { PackageManifest as PluginPackageManifest } from "../../plugins/manifest.js";
import type { PluginOrigin } from "../../plugins/types.js";
import { isRecord, resolveConfigDir, resolveUserPath } from "../../utils.js";
import type { ChannelMeta } from "./types.js";
@ -263,6 +265,46 @@ function buildExternalCatalogEntry(entry: ExternalCatalogEntry): ChannelPluginCa
});
}
function loadBundledMetadataCatalogEntries(options: CatalogOptions): ChannelPluginCatalogEntry[] {
const bundledDir = resolveBundledPluginsDir(options.env ?? process.env);
if (!bundledDir || !fs.existsSync(bundledDir)) {
return [];
}
const entries: ChannelPluginCatalogEntry[] = [];
for (const dirent of fs.readdirSync(bundledDir, { withFileTypes: true })) {
if (!dirent.isDirectory()) {
continue;
}
const pluginDir = path.join(bundledDir, dirent.name);
const packageJsonPath = path.join(pluginDir, "package.json");
if (!fs.existsSync(packageJsonPath)) {
continue;
}
let packageJson: PluginPackageManifest;
try {
packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf8")) as PluginPackageManifest;
} catch {
continue;
}
const entry = buildCatalogEntry({
packageName: packageJson.name,
packageDir: pluginDir,
rootDir: pluginDir,
origin: "bundled",
workspaceDir: options.workspaceDir,
packageManifest: packageJson.openclaw,
});
if (entry) {
entries.push(entry);
}
}
return entries;
}
export function buildChannelUiCatalog(
plugins: Array<{ id: string; meta: ChannelMeta }>,
): ChannelUiCatalog {
@ -312,6 +354,14 @@ export function listChannelPluginCatalogEntries(
}
}
for (const entry of loadBundledMetadataCatalogEntries(options)) {
const priority = ORIGIN_PRIORITY.bundled ?? 99;
const existing = resolved.get(entry.id);
if (!existing || priority < existing.priority) {
resolved.set(entry.id, { entry, priority });
}
}
const externalEntries = loadExternalCatalogEntries(options)
.map((entry) => buildExternalCatalogEntry(entry))
.filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry));

View File

@ -279,6 +279,54 @@ describe("channel plugin catalog", () => {
expect(ids).toContain("default-env-demo");
});
it("includes bundled metadata-only channel entries even when the runtime entrypoint is omitted", () => {
const packageRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bundled-catalog-"));
const bundledDir = path.join(packageRoot, "dist", "extensions", "whatsapp");
fs.mkdirSync(bundledDir, { recursive: true });
fs.writeFileSync(
path.join(packageRoot, "package.json"),
JSON.stringify({ name: "openclaw" }),
"utf8",
);
fs.writeFileSync(
path.join(bundledDir, "package.json"),
JSON.stringify({
name: "@openclaw/whatsapp",
openclaw: {
extensions: ["./index.js"],
channel: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp (QR link)",
detailLabel: "WhatsApp Web",
docsPath: "/channels/whatsapp",
blurb: "works with your own number; recommend a separate phone + eSIM.",
},
install: {
npmSpec: "@openclaw/whatsapp",
defaultChoice: "npm",
},
},
}),
"utf8",
);
fs.writeFileSync(
path.join(bundledDir, "openclaw.plugin.json"),
JSON.stringify({ id: "whatsapp", channels: ["whatsapp"], configSchema: {} }),
"utf8",
);
const entry = listChannelPluginCatalogEntries({
env: {
...process.env,
OPENCLAW_BUNDLED_PLUGINS_DIR: path.join(packageRoot, "dist", "extensions"),
},
}).find((item) => item.id === "whatsapp");
expect(entry?.install.npmSpec).toBe("@openclaw/whatsapp");
expect(entry?.pluginId).toBe("whatsapp");
});
});
const emptyRegistry = createTestRegistry([]);

View File

@ -2,17 +2,33 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { runChannelLogin, runChannelLogout } from "./channel-auth.js";
const mocks = vi.hoisted(() => ({
resolveAgentWorkspaceDir: vi.fn(),
resolveDefaultAgentId: vi.fn(),
getChannelPluginCatalogEntry: vi.fn(),
resolveChannelDefaultAccountId: vi.fn(),
getChannelPlugin: vi.fn(),
normalizeChannelId: vi.fn(),
loadConfig: vi.fn(),
writeConfigFile: vi.fn(),
resolveMessageChannelSelection: vi.fn(),
setVerbose: vi.fn(),
createClackPrompter: vi.fn(),
ensureChannelSetupPluginInstalled: vi.fn(),
loadChannelSetupPluginRegistrySnapshotForChannel: vi.fn(),
login: vi.fn(),
logoutAccount: vi.fn(),
resolveAccount: vi.fn(),
}));
vi.mock("../agents/agent-scope.js", () => ({
resolveAgentWorkspaceDir: mocks.resolveAgentWorkspaceDir,
resolveDefaultAgentId: mocks.resolveDefaultAgentId,
}));
vi.mock("../channels/plugins/catalog.js", () => ({
getChannelPluginCatalogEntry: mocks.getChannelPluginCatalogEntry,
}));
vi.mock("../channels/plugins/helpers.js", () => ({
resolveChannelDefaultAccountId: mocks.resolveChannelDefaultAccountId,
}));
@ -24,6 +40,7 @@ vi.mock("../channels/plugins/index.js", () => ({
vi.mock("../config/config.js", () => ({
loadConfig: mocks.loadConfig,
writeConfigFile: mocks.writeConfigFile,
}));
vi.mock("../infra/outbound/channel-selection.js", () => ({
@ -34,9 +51,20 @@ vi.mock("../globals.js", () => ({
setVerbose: mocks.setVerbose,
}));
vi.mock("../wizard/clack-prompter.js", () => ({
createClackPrompter: mocks.createClackPrompter,
}));
vi.mock("../commands/channel-setup/plugin-install.js", () => ({
ensureChannelSetupPluginInstalled: mocks.ensureChannelSetupPluginInstalled,
loadChannelSetupPluginRegistrySnapshotForChannel:
mocks.loadChannelSetupPluginRegistrySnapshotForChannel,
}));
describe("channel-auth", () => {
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
const plugin = {
id: "whatsapp",
auth: { login: mocks.login },
gateway: { logoutAccount: mocks.logoutAccount },
config: { resolveAccount: mocks.resolveAccount },
@ -46,12 +74,26 @@ describe("channel-auth", () => {
vi.clearAllMocks();
mocks.normalizeChannelId.mockReturnValue("whatsapp");
mocks.getChannelPlugin.mockReturnValue(plugin);
mocks.getChannelPluginCatalogEntry.mockReturnValue(undefined);
mocks.loadConfig.mockReturnValue({ channels: {} });
mocks.writeConfigFile.mockResolvedValue(undefined);
mocks.resolveMessageChannelSelection.mockResolvedValue({
channel: "whatsapp",
configured: ["whatsapp"],
});
mocks.resolveDefaultAgentId.mockReturnValue("main");
mocks.resolveAgentWorkspaceDir.mockReturnValue("/tmp/workspace");
mocks.resolveChannelDefaultAccountId.mockReturnValue("default-account");
mocks.createClackPrompter.mockReturnValue({} as object);
mocks.ensureChannelSetupPluginInstalled.mockResolvedValue({
cfg: { channels: {} },
installed: true,
pluginId: "whatsapp",
});
mocks.loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue({
channels: [{ plugin }],
channelSetups: [],
});
mocks.resolveAccount.mockReturnValue({ id: "resolved-account" });
mocks.login.mockResolvedValue(undefined);
mocks.logoutAccount.mockResolvedValue(undefined);
@ -115,6 +157,52 @@ describe("channel-auth", () => {
);
});
it("installs a catalog-backed channel plugin on demand for login", async () => {
mocks.getChannelPlugin.mockReturnValueOnce(undefined);
mocks.getChannelPluginCatalogEntry.mockReturnValueOnce({
id: "whatsapp",
pluginId: "@openclaw/whatsapp",
meta: {
id: "whatsapp",
label: "WhatsApp",
selectionLabel: "WhatsApp",
docsPath: "/channels/whatsapp",
blurb: "wa",
},
install: {
npmSpec: "@openclaw/whatsapp",
},
});
mocks.loadChannelSetupPluginRegistrySnapshotForChannel
.mockReturnValueOnce({
channels: [],
channelSetups: [],
})
.mockReturnValueOnce({
channels: [{ plugin }],
channelSetups: [],
});
await runChannelLogin({ channel: "whatsapp" }, runtime);
expect(mocks.ensureChannelSetupPluginInstalled).toHaveBeenCalledWith(
expect.objectContaining({
entry: expect.objectContaining({ id: "whatsapp" }),
runtime,
workspaceDir: "/tmp/workspace",
}),
);
expect(mocks.loadChannelSetupPluginRegistrySnapshotForChannel).toHaveBeenCalledWith(
expect.objectContaining({
channel: "whatsapp",
pluginId: "whatsapp",
workspaceDir: "/tmp/workspace",
}),
);
expect(mocks.writeConfigFile).toHaveBeenCalledWith({ channels: {} });
expect(mocks.login).toHaveBeenCalled();
});
it("runs logout with resolved account and explicit account id", async () => {
await runChannelLogout({ channel: "whatsapp", account: " acct-2 " }, runtime);

View File

@ -1,6 +1,7 @@
import { resolveChannelDefaultAccountId } from "../channels/plugins/helpers.js";
import { getChannelPlugin, normalizeChannelId } from "../channels/plugins/index.js";
import { loadConfig, type OpenClawConfig } from "../config/config.js";
import { resolveInstallableChannelPlugin } from "../commands/channel-setup/channel-plugin-resolution.js";
import { loadConfig, writeConfigFile, type OpenClawConfig } from "../config/config.js";
import { setVerbose } from "../globals.js";
import { resolveMessageChannelSelection } from "../infra/outbound/channel-selection.js";
import { defaultRuntime, type RuntimeEnv } from "../runtime.js";
@ -18,7 +19,14 @@ async function resolveChannelPluginForMode(
opts: ChannelAuthOptions,
mode: ChannelAuthMode,
cfg: OpenClawConfig,
): Promise<{ channelInput: string; channelId: string; plugin: ChannelPlugin }> {
runtime: RuntimeEnv,
): Promise<{
cfg: OpenClawConfig;
configChanged: boolean;
channelInput: string;
channelId: string;
plugin: ChannelPlugin;
}> {
const explicitChannel = opts.channel?.trim();
const channelInput = explicitChannel
? explicitChannel
@ -27,13 +35,28 @@ async function resolveChannelPluginForMode(
if (!channelId) {
throw new Error(`Unsupported channel: ${channelInput}`);
}
const plugin = getChannelPlugin(channelId);
const resolved = await resolveInstallableChannelPlugin({
cfg,
runtime,
channelId,
allowInstall: true,
supports: (candidate) =>
mode === "login" ? Boolean(candidate.auth?.login) : Boolean(candidate.gateway?.logoutAccount),
});
const plugin = resolved.plugin;
const supportsMode =
mode === "login" ? Boolean(plugin?.auth?.login) : Boolean(plugin?.gateway?.logoutAccount);
if (!supportsMode) {
throw new Error(`Channel ${channelId} does not support ${mode}`);
}
return { channelInput, channelId, plugin: plugin as ChannelPlugin };
return {
cfg: resolved.cfg,
configChanged: resolved.configChanged,
channelInput,
channelId,
plugin: plugin as ChannelPlugin,
};
}
function resolveAccountContext(
@ -49,8 +72,16 @@ export async function runChannelLogin(
opts: ChannelAuthOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const cfg = loadConfig();
const { channelInput, plugin } = await resolveChannelPluginForMode(opts, "login", cfg);
const loadedCfg = loadConfig();
const { cfg, configChanged, channelInput, plugin } = await resolveChannelPluginForMode(
opts,
"login",
loadedCfg,
runtime,
);
if (configChanged) {
await writeConfigFile(cfg);
}
const login = plugin.auth?.login;
if (!login) {
throw new Error(`Channel ${channelInput} does not support login`);
@ -71,8 +102,16 @@ export async function runChannelLogout(
opts: ChannelAuthOptions,
runtime: RuntimeEnv = defaultRuntime,
) {
const cfg = loadConfig();
const { channelInput, plugin } = await resolveChannelPluginForMode(opts, "logout", cfg);
const loadedCfg = loadConfig();
const { cfg, configChanged, channelInput, plugin } = await resolveChannelPluginForMode(
opts,
"logout",
loadedCfg,
runtime,
);
if (configChanged) {
await writeConfigFile(cfg);
}
const logoutAccount = plugin.gateway?.logoutAccount;
if (!logoutAccount) {
throw new Error(`Channel ${channelInput} does not support logout`);

View File

@ -0,0 +1,192 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import {
getChannelPluginCatalogEntry,
listChannelPluginCatalogEntries,
type ChannelPluginCatalogEntry,
} from "../../channels/plugins/catalog.js";
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
import type { ChannelId, ChannelPlugin } from "../../channels/plugins/types.js";
import type { OpenClawConfig } from "../../config/config.js";
import type { RuntimeEnv } from "../../runtime.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import type { WizardPrompter } from "../../wizard/prompts.js";
import {
ensureChannelSetupPluginInstalled,
loadChannelSetupPluginRegistrySnapshotForChannel,
} from "./plugin-install.js";
type ChannelPluginSnapshot = {
channels: Array<{ plugin: ChannelPlugin }>;
channelSetups: Array<{ plugin: ChannelPlugin }>;
};
type ResolveInstallableChannelPluginResult = {
cfg: OpenClawConfig;
channelId?: ChannelId;
plugin?: ChannelPlugin;
catalogEntry?: ChannelPluginCatalogEntry;
configChanged: boolean;
};
function resolveWorkspaceDir(cfg: OpenClawConfig) {
return resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg));
}
function resolveResolvedChannelId(params: {
rawChannel?: string | null;
catalogEntry?: ChannelPluginCatalogEntry;
}): ChannelId | undefined {
const normalized = normalizeChannelId(params.rawChannel);
if (normalized) {
return normalized;
}
if (!params.catalogEntry) {
return undefined;
}
return normalizeChannelId(params.catalogEntry.id) ?? (params.catalogEntry.id as ChannelId);
}
export function resolveCatalogChannelEntry(raw: string, cfg: OpenClawConfig | null) {
const trimmed = raw.trim().toLowerCase();
if (!trimmed) {
return undefined;
}
const workspaceDir = cfg ? resolveWorkspaceDir(cfg) : undefined;
return listChannelPluginCatalogEntries({ workspaceDir }).find((entry) => {
if (entry.id.toLowerCase() === trimmed) {
return true;
}
return (entry.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === trimmed);
});
}
function findScopedChannelPlugin(
snapshot: ChannelPluginSnapshot,
channelId: ChannelId,
): ChannelPlugin | undefined {
return (
snapshot.channels.find((entry) => entry.plugin.id === channelId)?.plugin ??
snapshot.channelSetups.find((entry) => entry.plugin.id === channelId)?.plugin
);
}
function loadScopedChannelPlugin(params: {
cfg: OpenClawConfig;
runtime: RuntimeEnv;
channelId: ChannelId;
pluginId?: string;
workspaceDir?: string;
}): ChannelPlugin | undefined {
const snapshot = loadChannelSetupPluginRegistrySnapshotForChannel({
cfg: params.cfg,
runtime: params.runtime,
channel: params.channelId,
...(params.pluginId ? { pluginId: params.pluginId } : {}),
workspaceDir: params.workspaceDir,
});
return findScopedChannelPlugin(snapshot, params.channelId);
}
export async function resolveInstallableChannelPlugin(params: {
cfg: OpenClawConfig;
runtime: RuntimeEnv;
rawChannel?: string | null;
channelId?: ChannelId;
allowInstall?: boolean;
prompter?: WizardPrompter;
supports?: (plugin: ChannelPlugin) => boolean;
}): Promise<ResolveInstallableChannelPluginResult> {
const supports = params.supports ?? (() => true);
let nextCfg = params.cfg;
const workspaceDir = resolveWorkspaceDir(nextCfg);
const catalogEntry =
(params.rawChannel ? resolveCatalogChannelEntry(params.rawChannel, nextCfg) : undefined) ??
(params.channelId
? getChannelPluginCatalogEntry(params.channelId, {
workspaceDir,
})
: undefined);
const channelId =
params.channelId ??
resolveResolvedChannelId({
rawChannel: params.rawChannel,
catalogEntry,
});
if (!channelId) {
return {
cfg: nextCfg,
catalogEntry,
configChanged: false,
};
}
const existing = getChannelPlugin(channelId);
if (existing && supports(existing)) {
return {
cfg: nextCfg,
channelId,
plugin: existing,
catalogEntry,
configChanged: false,
};
}
const resolvedPluginId = catalogEntry?.pluginId;
if (catalogEntry) {
const scoped = loadScopedChannelPlugin({
cfg: nextCfg,
runtime: params.runtime,
channelId,
pluginId: resolvedPluginId,
workspaceDir,
});
if (scoped && supports(scoped)) {
return {
cfg: nextCfg,
channelId,
plugin: scoped,
catalogEntry,
configChanged: false,
};
}
if (params.allowInstall !== false) {
const installResult = await ensureChannelSetupPluginInstalled({
cfg: nextCfg,
entry: catalogEntry,
prompter: params.prompter ?? createClackPrompter(),
runtime: params.runtime,
workspaceDir,
});
nextCfg = installResult.cfg;
const installedPluginId = installResult.pluginId ?? resolvedPluginId;
const installedPlugin = installResult.installed
? loadScopedChannelPlugin({
cfg: nextCfg,
runtime: params.runtime,
channelId,
pluginId: installedPluginId,
workspaceDir: resolveWorkspaceDir(nextCfg),
})
: undefined;
return {
cfg: nextCfg,
channelId,
plugin: installedPlugin ?? existing,
catalogEntry:
installedPluginId && catalogEntry.pluginId !== installedPluginId
? { ...catalogEntry, pluginId: installedPluginId }
: catalogEntry,
configChanged: nextCfg !== params.cfg,
};
}
}
return {
cfg: nextCfg,
channelId,
plugin: existing,
catalogEntry,
configChanged: false,
};
}

View File

@ -153,7 +153,9 @@ describe("channelsAddCommand", () => {
})),
},
};
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue(
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel)
.mockReturnValueOnce(createTestRegistry())
.mockReturnValueOnce(
createTestRegistry([{ pluginId: "msteams", plugin: scopedMSTeamsPlugin, source: "test" }]),
);
@ -292,7 +294,9 @@ describe("channelsAddCommand", () => {
installed: true,
pluginId: "@vendor/teams-runtime",
}));
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue(
vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel)
.mockReturnValueOnce(createTestRegistry())
.mockReturnValueOnce(
createTestRegistry([
{
pluginId: "@vendor/teams-runtime",

View File

@ -24,8 +24,9 @@ vi.mock("../config/config.js", async (importOriginal) => {
};
});
vi.mock("../../extensions/telegram/api.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../extensions/telegram/api.js")>();
vi.mock("../../extensions/telegram/src/update-offset-store.js", async (importOriginal) => {
const actual =
await importOriginal<typeof import("../../extensions/telegram/src/update-offset-store.js")>();
return {
...actual,
deleteTelegramUpdateOffset: offsetMocks.deleteTelegramUpdateOffset,

View File

@ -0,0 +1,113 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({
resolveCommandSecretRefsViaGateway: vi.fn(),
getChannelsCommandSecretTargetIds: vi.fn(() => []),
loadConfig: vi.fn(),
writeConfigFile: vi.fn(),
resolveMessageChannelSelection: vi.fn(),
resolveInstallableChannelPlugin: vi.fn(),
getChannelPlugin: vi.fn(),
}));
vi.mock("../cli/command-secret-gateway.js", () => ({
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway,
}));
vi.mock("../cli/command-secret-targets.js", () => ({
getChannelsCommandSecretTargetIds: mocks.getChannelsCommandSecretTargetIds,
}));
vi.mock("../config/config.js", () => ({
loadConfig: mocks.loadConfig,
writeConfigFile: mocks.writeConfigFile,
}));
vi.mock("../infra/outbound/channel-selection.js", () => ({
resolveMessageChannelSelection: mocks.resolveMessageChannelSelection,
}));
vi.mock("./channel-setup/channel-plugin-resolution.js", () => ({
resolveInstallableChannelPlugin: mocks.resolveInstallableChannelPlugin,
}));
vi.mock("../channels/plugins/index.js", () => ({
getChannelPlugin: mocks.getChannelPlugin,
}));
const { channelsResolveCommand } = await import("./channels/resolve.js");
describe("channelsResolveCommand", () => {
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mocks.loadConfig.mockReturnValue({ channels: {} });
mocks.writeConfigFile.mockResolvedValue(undefined);
mocks.resolveCommandSecretRefsViaGateway.mockResolvedValue({
resolvedConfig: { channels: {} },
diagnostics: [],
});
mocks.resolveMessageChannelSelection.mockResolvedValue({
channel: "telegram",
configured: ["telegram"],
source: "explicit",
});
});
it("persists install-on-demand channel setup before resolving explicit targets", async () => {
const resolveTargets = vi.fn().mockResolvedValue([
{
input: "friends",
resolved: true,
id: "120363000000@g.us",
name: "Friends",
},
]);
const installedCfg = {
channels: {},
plugins: {
entries: {
whatsapp: { enabled: true },
},
},
};
mocks.resolveInstallableChannelPlugin.mockResolvedValue({
cfg: installedCfg,
channelId: "whatsapp",
configChanged: true,
plugin: {
id: "whatsapp",
resolver: { resolveTargets },
},
});
await channelsResolveCommand(
{
channel: "whatsapp",
entries: ["friends"],
},
runtime,
);
expect(mocks.resolveInstallableChannelPlugin).toHaveBeenCalledWith(
expect.objectContaining({
rawChannel: "whatsapp",
allowInstall: true,
}),
);
expect(mocks.writeConfigFile).toHaveBeenCalledWith(installedCfg);
expect(resolveTargets).toHaveBeenCalledWith(
expect.objectContaining({
cfg: installedCfg,
inputs: ["friends"],
kind: "group",
}),
);
expect(runtime.log).toHaveBeenCalledWith("friends -> 120363000000@g.us (Friends)");
});
});

View File

@ -1,16 +1,18 @@
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
import { listChannelPluginCatalogEntries } from "../../channels/plugins/catalog.js";
import { resolveDefaultAgentId } from "../../agents/agent-scope.js";
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 { ChannelSetupPlugin } from "../../channels/plugins/setup-wizard-types.js";
import type { ChannelId, ChannelPlugin, ChannelSetupInput } from "../../channels/plugins/types.js";
import { writeConfigFile, type OpenClawConfig } from "../../config/config.js";
import type { ChannelSetupInput } from "../../channels/plugins/types.js";
import { writeConfigFile } from "../../config/config.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../routing/session-key.js";
import { defaultRuntime, type RuntimeEnv } from "../../runtime.js";
import { createClackPrompter } from "../../wizard/clack-prompter.js";
import { applyAgentBindings, describeBinding } from "../agents.bindings.js";
import { isCatalogChannelInstalled } from "../channel-setup/discovery.js";
import {
resolveCatalogChannelEntry,
resolveInstallableChannelPlugin,
} from "../channel-setup/channel-plugin-resolution.js";
import type { ChannelChoice } from "../onboard-types.js";
import { applyAccountName, applyChannelAccountConfig } from "./add-mutators.js";
import { channelLabel, requireValidConfig, shouldUseWizard } from "./shared.js";
@ -22,21 +24,6 @@ export type ChannelsAddOptions = {
groupChannels?: string;
dmAllowlist?: string;
} & Omit<ChannelSetupInput, "groupChannels" | "dmAllowlist" | "initialSyncLimit">;
function resolveCatalogChannelEntry(raw: string, cfg: OpenClawConfig | null) {
const trimmed = raw.trim().toLowerCase();
if (!trimmed) {
return undefined;
}
const workspaceDir = cfg ? resolveAgentWorkspaceDir(cfg, resolveDefaultAgentId(cfg)) : undefined;
return listChannelPluginCatalogEntries({ workspaceDir }).find((entry) => {
if (entry.id.toLowerCase() === trimmed) {
return true;
}
return (entry.meta.aliases ?? []).some((alias) => alias.trim().toLowerCase() === trimmed);
});
}
export async function channelsAddCommand(
opts: ChannelsAddOptions,
runtime: RuntimeEnv = defaultRuntime,
@ -177,62 +164,17 @@ export async function channelsAddCommand(
const rawChannel = String(opts.channel ?? "");
let channel = normalizeChannelId(rawChannel);
let catalogEntry = channel ? undefined : resolveCatalogChannelEntry(rawChannel, nextConfig);
const resolveWorkspaceDir = () =>
resolveAgentWorkspaceDir(nextConfig, resolveDefaultAgentId(nextConfig));
// May trigger loadOpenClawPlugins on cache miss (disk scan + jiti import)
const loadScopedPlugin = async (
channelId: ChannelId,
pluginId?: string,
): Promise<ChannelPlugin | undefined> => {
const existing = getChannelPlugin(channelId);
if (existing) {
return existing;
}
const { loadChannelSetupPluginRegistrySnapshotForChannel } =
await import("../channel-setup/plugin-install.js");
const snapshot = loadChannelSetupPluginRegistrySnapshotForChannel({
const resolvedPluginState = await resolveInstallableChannelPlugin({
cfg: nextConfig,
runtime,
channel: channelId,
...(pluginId ? { pluginId } : {}),
workspaceDir: resolveWorkspaceDir(),
rawChannel,
allowInstall: true,
prompter: createClackPrompter(),
supports: (plugin) => Boolean(plugin.setup?.applyAccountConfig),
});
return (
snapshot.channels.find((entry) => entry.plugin.id === channelId)?.plugin ??
snapshot.channelSetups.find((entry) => entry.plugin.id === channelId)?.plugin
);
};
if (!channel && catalogEntry) {
const workspaceDir = resolveWorkspaceDir();
if (
!isCatalogChannelInstalled({
cfg: nextConfig,
entry: catalogEntry,
workspaceDir,
})
) {
const { ensureChannelSetupPluginInstalled } =
await import("../channel-setup/plugin-install.js");
const prompter = createClackPrompter();
const result = await ensureChannelSetupPluginInstalled({
cfg: nextConfig,
entry: catalogEntry,
prompter,
runtime,
workspaceDir,
});
nextConfig = result.cfg;
if (!result.installed) {
return;
}
catalogEntry = {
...catalogEntry,
...(result.pluginId ? { pluginId: result.pluginId } : {}),
};
}
channel = normalizeChannelId(catalogEntry.id) ?? (catalogEntry.id as ChannelId);
}
nextConfig = resolvedPluginState.cfg;
channel = resolvedPluginState.channelId ?? channel;
catalogEntry = resolvedPluginState.catalogEntry ?? catalogEntry;
if (!channel) {
const hint = catalogEntry
@ -243,7 +185,7 @@ export async function channelsAddCommand(
return;
}
const plugin = await loadScopedPlugin(channel, catalogEntry?.pluginId);
const plugin = resolvedPluginState.plugin ?? (channel ? getChannelPlugin(channel) : undefined);
if (!plugin?.setup?.applyAccountConfig) {
runtime.error(`Channel ${channel} does not support add.`);
runtime.exit(1);

View File

@ -2,10 +2,11 @@ import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { ChannelResolveKind, ChannelResolveResult } from "../../channels/plugins/types.js";
import { resolveCommandSecretRefsViaGateway } from "../../cli/command-secret-gateway.js";
import { getChannelsCommandSecretTargetIds } from "../../cli/command-secret-targets.js";
import { loadConfig } from "../../config/config.js";
import { loadConfig, writeConfigFile } from "../../config/config.js";
import { danger } from "../../globals.js";
import { resolveMessageChannelSelection } from "../../infra/outbound/channel-selection.js";
import type { RuntimeEnv } from "../../runtime.js";
import { resolveInstallableChannelPlugin } from "../channel-setup/channel-plugin-resolution.js";
export type ChannelsResolveOptions = {
channel?: string;
@ -71,12 +72,13 @@ function formatResolveResult(result: ResolveResult): string {
export async function channelsResolveCommand(opts: ChannelsResolveOptions, runtime: RuntimeEnv) {
const loadedRaw = loadConfig();
const { resolvedConfig: cfg, diagnostics } = await resolveCommandSecretRefsViaGateway({
const { resolvedConfig, diagnostics } = await resolveCommandSecretRefsViaGateway({
config: loadedRaw,
commandName: "channels resolve",
targetIds: getChannelsCommandSecretTargetIds(),
mode: "read_only_operational",
});
let cfg = resolvedConfig;
for (const entry of diagnostics) {
runtime.log(`[secrets] ${entry}`);
}
@ -85,13 +87,35 @@ export async function channelsResolveCommand(opts: ChannelsResolveOptions, runti
throw new Error("At least one entry is required.");
}
const selection = await resolveMessageChannelSelection({
const explicitChannel = opts.channel?.trim();
const resolvedExplicit = explicitChannel
? await resolveInstallableChannelPlugin({
cfg,
runtime,
rawChannel: explicitChannel,
allowInstall: true,
supports: (plugin) => Boolean(plugin.resolver?.resolveTargets),
})
: null;
if (resolvedExplicit?.configChanged) {
cfg = resolvedExplicit.cfg;
await writeConfigFile(cfg);
}
const selection = explicitChannel
? {
channel: resolvedExplicit?.channelId,
}
: await resolveMessageChannelSelection({
cfg,
channel: opts.channel ?? null,
});
const plugin = getChannelPlugin(selection.channel);
const plugin =
(explicitChannel ? resolvedExplicit?.plugin : undefined) ??
(selection.channel ? getChannelPlugin(selection.channel) : undefined);
if (!plugin?.resolver?.resolveTargets) {
throw new Error(`Channel ${selection.channel} does not support resolve.`);
const channelText = selection.channel ?? explicitChannel ?? "";
throw new Error(`Channel ${channelText} does not support resolve.`);
}
const preferredKind = resolvePreferredKind(opts.kind);

View File

@ -22,22 +22,12 @@ describe("bundled plugin runtime dependencies", () => {
expect(rootSpec).toBeUndefined();
}
function expectRootMirrorsPluginRuntimeDep(pluginPath: string, dependencyName: string) {
const rootManifest = readJson<PackageManifest>("package.json");
const pluginManifest = readJson<PackageManifest>(pluginPath);
const pluginSpec = pluginManifest.dependencies?.[dependencyName];
const rootSpec = rootManifest.dependencies?.[dependencyName];
expect(pluginSpec).toBeTruthy();
expect(rootSpec).toBe(pluginSpec);
}
it("keeps bundled Feishu runtime deps plugin-local instead of mirroring them into the root package", () => {
expectPluginOwnsRuntimeDep("extensions/feishu/package.json", "@larksuiteoapi/node-sdk");
});
it("keeps bundled memory-lancedb runtime deps mirrored in the root package while its native runtime is still packaged that way", () => {
expectRootMirrorsPluginRuntimeDep("extensions/memory-lancedb/package.json", "@lancedb/lancedb");
it("keeps memory-lancedb runtime deps plugin-local so packaged installs fetch them on demand", () => {
expectPluginOwnsRuntimeDep("extensions/memory-lancedb/package.json", "@lancedb/lancedb");
});
it("keeps bundled Discord runtime deps plugin-local instead of mirroring them into the root package", () => {
@ -52,11 +42,8 @@ describe("bundled plugin runtime dependencies", () => {
expectPluginOwnsRuntimeDep("extensions/telegram/package.json", "grammy");
});
it("keeps bundled WhatsApp runtime deps mirrored in the root package while its heavy runtime still uses the legacy bundle path", () => {
expectRootMirrorsPluginRuntimeDep(
"extensions/whatsapp/package.json",
"@whiskeysockets/baileys",
);
it("keeps WhatsApp runtime deps plugin-local so packaged installs fetch them on demand", () => {
expectPluginOwnsRuntimeDep("extensions/whatsapp/package.json", "@whiskeysockets/baileys");
});
it("keeps bundled proxy-agent deps plugin-local instead of mirroring them into the root package", () => {