diff --git a/CHANGELOG.md b/CHANGELOG.md index c45c8cbe7..82fdbc2a1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - Agents: add optional auth-profile copy prompt on `agents add` and improve auth error messaging. - Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor`. - Security: add `clawdbot security audit` (`--deep`, `--fix`) and surface it in `status --all` and `doctor` (includes browser control exposure checks). +- Plugins: add Zalo channel plugin with gateway HTTP hooks and onboarding install prompt. (#854) — thanks @longmaba. - Onboarding: add a security checkpoint prompt (docs link + sandboxing hint); require `--accept-risk` for `--non-interactive`. - Docs: expand gateway security hardening guidance and incident response checklist. - Docs: document DM history limits for channel DMs. (#883) — thanks @pkrmf. diff --git a/docs/start/wizard.md b/docs/start/wizard.md index bc62f7913..d334bb89b 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -289,6 +289,9 @@ Typical fields in `~/.clawdbot/clawdbot.json`: WhatsApp credentials go under `~/.clawdbot/credentials/whatsapp//`. Sessions are stored under `~/.clawdbot/agents//sessions/`. +Some channels are delivered as plugins. When you pick one during onboarding, the wizard +will prompt to install it (npm or a local path) before it can be configured. + ## Related docs - macOS app onboarding: [Onboarding](/start/onboarding) diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts new file mode 100644 index 000000000..85049a663 --- /dev/null +++ b/src/channels/plugins/catalog.ts @@ -0,0 +1,43 @@ +import type { ChannelMeta } from "./types.js"; + +export type ChannelPluginCatalogEntry = { + id: string; + meta: ChannelMeta; + install: { + npmSpec: string; + localPath?: string; + }; +}; + +const CATALOG: ChannelPluginCatalogEntry[] = [ + { + id: "zalo", + meta: { + id: "zalo", + label: "Zalo", + selectionLabel: "Zalo (Bot API)", + docsPath: "/channels/zalo", + docsLabel: "zalo", + blurb: "Vietnam-focused messaging platform with Bot API.", + aliases: ["zl"], + order: 80, + quickstartAllowFrom: true, + }, + install: { + npmSpec: "@clawdbot/zalo", + localPath: "extensions/zalo", + }, + }, +]; + +export function listChannelPluginCatalogEntries(): ChannelPluginCatalogEntry[] { + return [...CATALOG]; +} + +export function getChannelPluginCatalogEntry( + id: string, +): ChannelPluginCatalogEntry | undefined { + const trimmed = id.trim(); + if (!trimmed) return undefined; + return CATALOG.find((entry) => entry.id === trimmed); +} diff --git a/src/commands/onboard-channels.ts b/src/commands/onboard-channels.ts index aba02ce13..1a1c9ff57 100644 --- a/src/commands/onboard-channels.ts +++ b/src/commands/onboard-channels.ts @@ -1,3 +1,5 @@ +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope.js"; +import { listChannelPluginCatalogEntries } from "../channels/plugins/catalog.js"; import { listChannelPlugins, getChannelPlugin } from "../channels/plugins/index.js"; import { formatChannelPrimerLine, formatChannelSelectionLine } from "../channels/registry.js"; import type { ClawdbotConfig } from "../config/config.js"; @@ -10,10 +12,25 @@ import { getChannelOnboardingAdapter, listChannelOnboardingAdapters, } from "./onboarding/registry.js"; +import { + ensureOnboardingPluginInstalled, + reloadOnboardingPluginRegistry, +} from "./onboarding/plugin-install.js"; import type { ChannelOnboardingDmPolicy, SetupChannelsOptions } from "./onboarding/types.js"; -async function noteChannelPrimer(prompter: WizardPrompter): Promise { - const channelLines = listChannelPlugins().map((plugin) => formatChannelPrimerLine(plugin.meta)); +async function noteChannelPrimer( + prompter: WizardPrompter, + channels: Array<{ id: ChannelChoice; blurb: string; label: string }>, +): Promise { + const channelLines = channels.map((channel) => + formatChannelPrimerLine({ + id: channel.id, + label: channel.label, + selectionLabel: channel.label, + docsPath: "/", + blurb: channel.blurb, + }), + ); await prompter.note( [ "DM security: default is pairing; unknown DMs get a pairing code.", @@ -97,6 +114,7 @@ export async function setupChannels( prompter: WizardPrompter, options?: SetupChannelsOptions, ): Promise { + let next = cfg; const forceAllowFromChannels = new Set(options?.forceAllowFromChannels ?? []); const accountOverrides: Partial> = { ...options?.accountIds, @@ -105,13 +123,27 @@ export async function setupChannels( accountOverrides.whatsapp = options.whatsappAccountId.trim(); } + const installedPlugins = listChannelPlugins(); + const catalogEntries = listChannelPluginCatalogEntries().filter( + (entry) => !installedPlugins.some((plugin) => plugin.id === entry.id), + ); const statusEntries = await Promise.all( listChannelOnboardingAdapters().map((adapter) => adapter.getStatus({ cfg, options, accountOverrides }), ), ); - const statusByChannel = new Map(statusEntries.map((entry) => [entry.channel, entry])); - const statusLines = statusEntries.flatMap((entry) => entry.statusLines); + const catalogStatuses = catalogEntries.map((entry) => ({ + channel: entry.id, + configured: false, + statusLines: [`${entry.meta.label}: install plugin to enable`], + selectionHint: "plugin · install", + quickstartScore: 0, + })); + const combinedStatuses = [...statusEntries, ...catalogStatuses]; + const statusByChannel = new Map( + combinedStatuses.map((entry) => [entry.channel, entry]), + ); + const statusLines = combinedStatuses.flatMap((entry) => entry.statusLines); if (statusLines.length > 0) { await prompter.note(statusLines.join("\n"), "Channel status"); } @@ -124,11 +156,32 @@ export async function setupChannels( }); if (!shouldConfigure) return cfg; - await noteChannelPrimer(prompter); + const primerChannels = [ + ...installedPlugins.map((plugin) => ({ + id: plugin.id as ChannelChoice, + label: plugin.meta.label, + blurb: plugin.meta.blurb, + })), + ...catalogEntries.map((entry) => ({ + id: entry.id as ChannelChoice, + label: entry.meta.label, + blurb: entry.meta.blurb, + })), + ]; + await noteChannelPrimer(prompter, primerChannels); - const selectionOptions = listChannelPlugins().map((plugin) => { - const meta = plugin.meta; - const status = statusByChannel.get(meta.id as ChannelChoice); + const selectionOptions = [ + ...installedPlugins.map((plugin) => ({ + id: plugin.id as ChannelChoice, + meta: plugin.meta, + })), + ...catalogEntries.map((entry) => ({ + id: entry.id as ChannelChoice, + meta: entry.meta, + })), + ].map((entry) => { + const meta = entry.meta; + const status = statusByChannel.get(entry.id); return { value: meta.id, label: meta.selectionLabel ?? meta.label, @@ -163,12 +216,43 @@ export async function setupChannels( })) as ChannelChoice[]; } + const catalogById = new Map( + catalogEntries.map((entry) => [entry.id as ChannelChoice, entry]), + ); + if (selection.some((channel) => catalogById.has(channel))) { + const workspaceDir = resolveAgentWorkspaceDir(next, resolveDefaultAgentId(next)); + for (const channel of selection) { + const entry = catalogById.get(channel); + if (!entry) continue; + const result = await ensureOnboardingPluginInstalled({ + cfg: next, + entry, + prompter, + runtime, + workspaceDir, + }); + next = result.cfg; + if (!result.installed) { + selection = selection.filter((id) => id !== channel); + continue; + } + reloadOnboardingPluginRegistry({ + cfg: next, + runtime, + workspaceDir, + }); + } + } + options?.onSelection?.(selection); const selectionNotes = new Map( - listChannelPlugins().map((plugin) => [ - plugin.id, - formatChannelSelectionLine(plugin.meta, formatDocsLink), + [ + ...installedPlugins.map((plugin) => [plugin.id, plugin.meta]), + ...catalogEntries.map((entry) => [entry.id, entry.meta]), + ].map(([id, meta]) => [ + id, + formatChannelSelectionLine(meta, formatDocsLink), ]), ); const selectedLines = selection @@ -185,7 +269,6 @@ export async function setupChannels( adapter?.onAccountRecorded?.(accountId, options); }; - let next = cfg; for (const channel of selection) { const adapter = getChannelOnboardingAdapter(channel); if (!adapter) continue; diff --git a/src/commands/onboarding/__tests__/test-utils.ts b/src/commands/onboarding/__tests__/test-utils.ts new file mode 100644 index 000000000..ad45563b9 --- /dev/null +++ b/src/commands/onboarding/__tests__/test-utils.ts @@ -0,0 +1,25 @@ +import { vi } from "vitest"; + +import type { RuntimeEnv } from "../../../runtime.js"; +import type { WizardPrompter } from "../../../wizard/prompts.js"; + +export const makeRuntime = (overrides: Partial = {}): RuntimeEnv => ({ + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + ...overrides, +}); + +export const makePrompter = ( + overrides: Partial = {}, +): WizardPrompter => ({ + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: vi.fn(async () => "npm") as WizardPrompter["select"], + multiselect: vi.fn(async () => []) as WizardPrompter["multiselect"], + text: vi.fn(async () => "") as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, +}); diff --git a/src/commands/onboarding/plugin-install.test.ts b/src/commands/onboarding/plugin-install.test.ts new file mode 100644 index 000000000..f31012f42 --- /dev/null +++ b/src/commands/onboarding/plugin-install.test.ts @@ -0,0 +1,126 @@ +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:fs", () => ({ + default: { + existsSync: vi.fn(), + }, +})); + +const installPluginFromNpmSpec = vi.fn(); +vi.mock("../../plugins/install.js", () => ({ + installPluginFromNpmSpec: (...args: unknown[]) => installPluginFromNpmSpec(...args), +})); + +vi.mock("../../plugins/loader.js", () => ({ + loadClawdbotPlugins: vi.fn(), +})); + +import fs from "node:fs"; +import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import type { WizardPrompter } from "../../wizard/prompts.js"; +import { makePrompter, makeRuntime } from "./__tests__/test-utils.js"; +import { ensureOnboardingPluginInstalled } from "./plugin-install.js"; + +const baseEntry: ChannelPluginCatalogEntry = { + id: "zalo", + meta: { + id: "zalo", + label: "Zalo", + selectionLabel: "Zalo (Bot API)", + docsPath: "/channels/zalo", + docsLabel: "zalo", + blurb: "Test", + }, + install: { + npmSpec: "@clawdbot/zalo", + localPath: "extensions/zalo", + }, +}; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("ensureOnboardingPluginInstalled", () => { + it("installs from npm and enables the plugin", async () => { + const runtime = makeRuntime(); + const prompter = makePrompter({ + select: vi.fn(async () => "npm") as WizardPrompter["select"], + }); + const cfg: ClawdbotConfig = { plugins: { allow: ["other"] } }; + vi.mocked(fs.existsSync).mockReturnValue(false); + installPluginFromNpmSpec.mockResolvedValue({ + ok: true, + pluginId: "zalo", + targetDir: "/tmp/zalo", + extensions: [], + }); + + const result = await ensureOnboardingPluginInstalled({ + cfg, + entry: baseEntry, + prompter, + runtime, + }); + + expect(result.installed).toBe(true); + expect(result.cfg.plugins?.entries?.zalo?.enabled).toBe(true); + expect(result.cfg.plugins?.allow).toContain("zalo"); + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ spec: "@clawdbot/zalo" }), + ); + }); + + it("uses local path when selected", async () => { + const runtime = makeRuntime(); + const prompter = makePrompter({ + select: vi.fn(async () => "local") as WizardPrompter["select"], + }); + const cfg: ClawdbotConfig = {}; + vi.mocked(fs.existsSync).mockReturnValue(true); + + const result = await ensureOnboardingPluginInstalled({ + cfg, + entry: baseEntry, + prompter, + runtime, + }); + + const expectedPath = path.resolve(process.cwd(), "extensions/zalo"); + expect(result.installed).toBe(true); + expect(result.cfg.plugins?.load?.paths).toContain(expectedPath); + expect(result.cfg.plugins?.entries?.zalo?.enabled).toBe(true); + }); + + it("falls back to local path after npm install failure", async () => { + const runtime = makeRuntime(); + const note = vi.fn(async () => {}); + const confirm = vi.fn(async () => true); + const prompter = makePrompter({ + select: vi.fn(async () => "npm") as WizardPrompter["select"], + note, + confirm, + }); + const cfg: ClawdbotConfig = {}; + vi.mocked(fs.existsSync).mockReturnValue(true); + installPluginFromNpmSpec.mockResolvedValue({ + ok: false, + error: "nope", + }); + + const result = await ensureOnboardingPluginInstalled({ + cfg, + entry: baseEntry, + prompter, + runtime, + }); + + const expectedPath = path.resolve(process.cwd(), "extensions/zalo"); + expect(result.installed).toBe(true); + expect(result.cfg.plugins?.load?.paths).toContain(expectedPath); + expect(note).toHaveBeenCalled(); + expect(runtime.error).not.toHaveBeenCalled(); + }); +}); diff --git a/src/commands/onboarding/plugin-install.ts b/src/commands/onboarding/plugin-install.ts new file mode 100644 index 000000000..bbcde25b4 --- /dev/null +++ b/src/commands/onboarding/plugin-install.ts @@ -0,0 +1,185 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js"; +import type { ChannelPluginCatalogEntry } from "../../channels/plugins/catalog.js"; +import type { ClawdbotConfig } from "../../config/config.js"; +import { createSubsystemLogger } from "../../logging.js"; +import { loadClawdbotPlugins } from "../../plugins/loader.js"; +import { installPluginFromNpmSpec } from "../../plugins/install.js"; +import type { RuntimeEnv } from "../../runtime.js"; +import type { WizardPrompter } from "../../wizard/prompts.js"; + +type InstallChoice = "npm" | "local" | "skip"; + +type InstallResult = { + cfg: ClawdbotConfig; + installed: boolean; +}; + +function resolveLocalPath(entry: ChannelPluginCatalogEntry, workspaceDir?: string): string | null { + const raw = entry.install.localPath?.trim(); + if (!raw) return null; + const candidates = new Set(); + candidates.add(path.resolve(process.cwd(), raw)); + if (workspaceDir && workspaceDir !== process.cwd()) { + candidates.add(path.resolve(workspaceDir, raw)); + } + for (const candidate of candidates) { + if (fs.existsSync(candidate)) return candidate; + } + return null; +} + +function ensurePluginEnabled(cfg: ClawdbotConfig, pluginId: string): ClawdbotConfig { + const entries = { + ...cfg.plugins?.entries, + [pluginId]: { + ...(cfg.plugins?.entries?.[pluginId] as Record | undefined), + enabled: true, + }, + }; + const next: ClawdbotConfig = { + ...cfg, + plugins: { + ...cfg.plugins, + ...(cfg.plugins?.enabled === false ? { enabled: true } : {}), + entries, + }, + }; + return ensurePluginAllowlist(next, pluginId); +} + +function ensurePluginAllowlist(cfg: ClawdbotConfig, pluginId: string): ClawdbotConfig { + const allow = cfg.plugins?.allow; + if (!allow || allow.includes(pluginId)) return cfg; + return { + ...cfg, + plugins: { + ...cfg.plugins, + allow: [...allow, pluginId], + }, + }; +} + +function addPluginLoadPath(cfg: ClawdbotConfig, pluginPath: string): ClawdbotConfig { + const existing = cfg.plugins?.load?.paths ?? []; + const merged = Array.from(new Set([...existing, pluginPath])); + return { + ...cfg, + plugins: { + ...cfg.plugins, + load: { + ...cfg.plugins?.load, + paths: merged, + }, + }, + }; +} + +async function promptInstallChoice(params: { + entry: ChannelPluginCatalogEntry; + localPath?: string | null; + prompter: WizardPrompter; +}): Promise { + const { entry, localPath, prompter } = params; + const options: Array<{ value: InstallChoice; label: string; hint?: string }> = [ + { value: "npm", label: `Download from npm (${entry.install.npmSpec})` }, + ...(localPath + ? [ + { + value: "local", + label: "Use local plugin path", + hint: localPath, + }, + ] + : []), + { value: "skip", label: "Skip for now" }, + ]; + return (await prompter.select({ + message: `Install ${entry.meta.label} plugin?`, + options, + initialValue: localPath ? "local" : "npm", + })) as InstallChoice; +} + +export async function ensureOnboardingPluginInstalled(params: { + cfg: ClawdbotConfig; + entry: ChannelPluginCatalogEntry; + prompter: WizardPrompter; + runtime: RuntimeEnv; + workspaceDir?: string; +}): Promise { + const { entry, prompter, runtime, workspaceDir } = params; + let next = params.cfg; + const localPath = resolveLocalPath(entry, workspaceDir); + const choice = await promptInstallChoice({ + entry, + localPath, + prompter, + }); + + if (choice === "skip") { + return { cfg: next, installed: false }; + } + + if (choice === "local" && localPath) { + next = addPluginLoadPath(next, localPath); + next = ensurePluginEnabled(next, entry.id); + return { cfg: next, installed: true }; + } + + const result = await installPluginFromNpmSpec({ + spec: entry.install.npmSpec, + logger: { + info: (msg) => runtime.log?.(msg), + warn: (msg) => runtime.log?.(msg), + }, + }); + + if (result.ok) { + next = ensurePluginEnabled(next, result.pluginId); + return { cfg: next, installed: true }; + } + + await prompter.note( + `Failed to install ${entry.install.npmSpec}: ${result.error}`, + "Plugin install", + ); + + if (localPath) { + const fallback = await prompter.confirm({ + message: `Use local plugin path instead? (${localPath})`, + initialValue: true, + }); + if (fallback) { + next = addPluginLoadPath(next, localPath); + next = ensurePluginEnabled(next, entry.id); + return { cfg: next, installed: true }; + } + } + + runtime.error?.(`Plugin install failed: ${result.error}`); + return { cfg: next, installed: false }; +} + +export function reloadOnboardingPluginRegistry(params: { + cfg: ClawdbotConfig; + runtime: RuntimeEnv; + workspaceDir?: string; +}): void { + const workspaceDir = + params.workspaceDir ?? + resolveAgentWorkspaceDir(params.cfg, resolveDefaultAgentId(params.cfg)); + const log = createSubsystemLogger("plugins"); + loadClawdbotPlugins({ + config: params.cfg, + workspaceDir, + cache: false, + logger: { + info: (msg) => log.info(msg), + warn: (msg) => log.warn(msg), + error: (msg) => log.error(msg), + debug: (msg) => log.debug(msg), + }, + }); +}