From f67e48e6a0be6043b1c6b38a64e7fd42c7ebc52d Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Wed, 22 Apr 2026 22:05:00 -0700 Subject: [PATCH] feat(onboarding): auto-install missing provider and channel plugins Squash-merge PR 70012. --- docs/plugins/manifest.md | 14 + docs/plugins/sdk-setup.md | 42 +- src/channels/plugins/catalog.ts | 11 +- src/commands/agents.commands.add.ts | 48 +- .../auth-choice.apply.plugin-provider.test.ts | 155 +++++ src/commands/auth-choice.apply.types.ts | 1 + src/commands/auth-choice.test.ts | 2 +- .../channel-setup/plugin-install.test.ts | 56 +- src/commands/channel-setup/plugin-install.ts | 217 +------ src/commands/channels.add.test.ts | 2 + src/commands/channels.remove.test.ts | 1 + ...re.gateway-auth.prompt-auth-config.test.ts | 22 + src/commands/configure.gateway-auth.ts | 85 +-- src/commands/onboard-channels.e2e.test.ts | 1 + .../onboarding-plugin-install.test.ts | 515 ++++++++++++++++ src/commands/onboarding-plugin-install.ts | 574 ++++++++++++++++++ src/flows/channel-setup.test.ts | 103 +++- src/flows/channel-setup.ts | 64 +- src/flows/provider-flow.test.ts | 282 +++++++-- src/flows/provider-flow.ts | 128 +++- src/infra/npm-integrity.test.ts | 13 +- src/infra/npm-integrity.ts | 17 +- src/infra/npm-pack-install.test.ts | 10 +- src/plugins/enable.test.ts | 38 +- src/plugins/enable.ts | 14 +- src/plugins/manifest.json5-tolerance.test.ts | 22 +- src/plugins/manifest.ts | 3 + src/plugins/provider-auth-choice.ts | 69 ++- src/plugins/provider-install-catalog.test.ts | 325 ++++++++++ src/plugins/provider-install-catalog.ts | 200 ++++++ src/wizard/setup.test.ts | 73 ++- src/wizard/setup.ts | 104 ++-- .../channel-plugin-catalog-contract-suites.ts | 65 ++ 33 files changed, 2830 insertions(+), 446 deletions(-) create mode 100644 src/commands/onboarding-plugin-install.test.ts create mode 100644 src/commands/onboarding-plugin-install.ts create mode 100644 src/plugins/provider-install-catalog.test.ts create mode 100644 src/plugins/provider-install-catalog.ts diff --git a/docs/plugins/manifest.md b/docs/plugins/manifest.md index 728b3e86b83..bcb9752eca6 100644 --- a/docs/plugins/manifest.md +++ b/docs/plugins/manifest.md @@ -559,13 +559,27 @@ Important examples: | `openclaw.install.npmSpec` / `openclaw.install.localPath` | Install/update hints for bundled and externally published plugins. | | `openclaw.install.defaultChoice` | Preferred install path when multiple install sources are available. | | `openclaw.install.minHostVersion` | Minimum supported OpenClaw host version, using a semver floor like `>=2026.3.22`. | +| `openclaw.install.expectedIntegrity` | Expected npm dist integrity string such as `sha512-...`; install and update flows verify the fetched artifact against it. | | `openclaw.install.allowInvalidConfigRecovery` | Allows a narrow bundled-plugin reinstall recovery path when config is invalid. | | `openclaw.startup.deferConfiguredChannelFullLoadUntilAfterListen` | Lets setup-only channel surfaces load before the full channel plugin during startup. | +Manifest metadata decides which provider/channel/setup choices appear in +onboarding before runtime loads. `package.json#openclaw.install` tells +onboarding how to fetch or enable that plugin when the user picks one of those +choices. Do not move install hints into `openclaw.plugin.json`. + `openclaw.install.minHostVersion` is enforced during install and manifest registry loading. Invalid values are rejected; newer-but-valid values skip the plugin on older hosts. +Exact npm version pinning already lives in `npmSpec`, for example +`"npmSpec": "@wecom/wecom-openclaw-plugin@1.2.3"`. Pair that with +`expectedIntegrity` when you want update flows to fail closed if the fetched +npm artifact no longer matches the pinned release. Interactive onboarding only +offers npm install choices from trusted catalog metadata when `npmSpec` is an +exact version and `expectedIntegrity` is present; otherwise it falls back to a +local source or skip. + Channel plugins should provide `openclaw.setupEntry` when status, channel list, or SecretRef scans need to identify configured accounts without loading the full runtime. The setup entry should expose channel metadata plus setup-safe config, diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md index 2918543189d..e904102fabf 100644 --- a/docs/plugins/sdk-setup.md +++ b/docs/plugins/sdk-setup.md @@ -70,14 +70,14 @@ fields are required. The canonical publish snippets live in ### `openclaw` fields -| Field | Type | Description | -| ------------ | ---------- | ------------------------------------------------------------------------------------------------------ | -| `extensions` | `string[]` | Entry point files (relative to package root) | -| `setupEntry` | `string` | Lightweight setup-only entry (optional) | -| `channel` | `object` | Channel catalog metadata for setup, picker, quickstart, and status surfaces | -| `providers` | `string[]` | Provider ids registered by this plugin | -| `install` | `object` | Install hints: `npmSpec`, `localPath`, `defaultChoice`, `minHostVersion`, `allowInvalidConfigRecovery` | -| `startup` | `object` | Startup behavior flags | +| Field | Type | Description | +| ------------ | ---------- | --------------------------------------------------------------------------------------------------------------------------- | +| `extensions` | `string[]` | Entry point files (relative to package root) | +| `setupEntry` | `string` | Lightweight setup-only entry (optional) | +| `channel` | `object` | Channel catalog metadata for setup, picker, quickstart, and status surfaces | +| `providers` | `string[]` | Provider ids registered by this plugin | +| `install` | `object` | Install hints: `npmSpec`, `localPath`, `defaultChoice`, `minHostVersion`, `expectedIntegrity`, `allowInvalidConfigRecovery` | +| `startup` | `object` | Startup behavior flags | ### `openclaw.channel` @@ -155,11 +155,37 @@ Example: | `localPath` | `string` | Local development or bundled install path. | | `defaultChoice` | `"npm"` \| `"local"` | Preferred install source when both are available. | | `minHostVersion` | `string` | Minimum supported OpenClaw version in the form `>=x.y.z`. | +| `expectedIntegrity` | `string` | Expected npm dist integrity string, usually `sha512-...`, for pinned installs. | | `allowInvalidConfigRecovery` | `boolean` | Lets bundled-plugin reinstall flows recover from specific stale-config failures. | +Interactive onboarding also uses `openclaw.install` for install-on-demand +surfaces. If your plugin exposes provider auth choices or channel setup/catalog +metadata before runtime loads, onboarding can show that choice, prompt for npm +vs local install, install or enable the plugin, then continue the selected +flow. Npm onboarding choices require trusted catalog metadata with an exact +`npmSpec` version and `expectedIntegrity`; unpinned package names and dist-tags +are not offered for automatic onboarding installs. Keep the "what to show" +metadata in `openclaw.plugin.json` and the "how to install it" metadata in +`package.json`. + If `minHostVersion` is set, install and manifest-registry loading both enforce it. Older hosts skip the plugin; invalid version strings are rejected. +For pinned npm installs, keep the exact version in `npmSpec` and add the +expected artifact integrity: + +```json +{ + "openclaw": { + "install": { + "npmSpec": "@wecom/wecom-openclaw-plugin@1.2.3", + "expectedIntegrity": "sha512-REPLACE_WITH_NPM_DIST_INTEGRITY", + "defaultChoice": "npm" + } + } +} +``` + `allowInvalidConfigRecovery` is not a general bypass for broken configs. It is for narrow bundled-plugin recovery only, so reinstall/setup can repair known upgrade leftovers like a missing bundled plugin path or stale `channels.` diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index 4f3db32bdb0..bb7451257ff 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -32,10 +32,8 @@ export type ChannelPluginCatalogEntry = { pluginId?: string; origin?: PluginOrigin; meta: ChannelMeta; - install: { + install: PluginPackageInstall & { npmSpec: string; - localPath?: string; - defaultChoice?: "npm" | "local"; }; }; @@ -217,6 +215,13 @@ function resolveInstallInfo(params: { npmSpec, ...(localPath ? { localPath } : {}), ...(defaultChoice ? { defaultChoice } : {}), + ...(params.install?.minHostVersion ? { minHostVersion: params.install.minHostVersion } : {}), + ...(params.install?.expectedIntegrity + ? { expectedIntegrity: params.install.expectedIntegrity } + : {}), + ...(params.install?.allowInvalidConfigRecovery === true + ? { allowInvalidConfigRecovery: true } + : {}), }; } diff --git a/src/commands/agents.commands.add.ts b/src/commands/agents.commands.add.ts index cee64e45492..e762c96204b 100644 --- a/src/commands/agents.commands.add.ts +++ b/src/commands/agents.commands.add.ts @@ -274,28 +274,34 @@ export async function agentsAddCommand( const authStore = ensureAuthProfileStore(agentDir, { allowKeychainPrompt: false, }); - const authChoice = await promptAuthChoiceGrouped({ - prompter, - store: authStore, - includeSkip: true, - config: nextConfig, - }); - - const authResult = await applyAuthChoice({ - authChoice, - config: nextConfig, - prompter, - runtime, - agentDir, - setDefaultModel: false, - agentId, - }); - nextConfig = authResult.config; - if (authResult.agentModelOverride) { - nextConfig = applyAgentConfig(nextConfig, { - agentId, - model: authResult.agentModelOverride, + while (true) { + const authChoice = await promptAuthChoiceGrouped({ + prompter, + store: authStore, + includeSkip: true, + config: nextConfig, }); + + const authResult = await applyAuthChoice({ + authChoice, + config: nextConfig, + prompter, + runtime, + agentDir, + setDefaultModel: false, + agentId, + }); + nextConfig = authResult.config; + if (authResult.retrySelection) { + continue; + } + if (authResult.agentModelOverride) { + nextConfig = applyAgentConfig(nextConfig, { + agentId, + model: authResult.agentModelOverride, + }); + } + break; } } diff --git a/src/commands/auth-choice.apply.plugin-provider.test.ts b/src/commands/auth-choice.apply.plugin-provider.test.ts index f4da63bf8a7..5a753d90a46 100644 --- a/src/commands/auth-choice.apply.plugin-provider.test.ts +++ b/src/commands/auth-choice.apply.plugin-provider.test.ts @@ -8,6 +8,11 @@ import type { ProviderPlugin } from "../plugins/types.js"; import type { ProviderAuthMethod } from "../plugins/types.js"; import type { ApplyAuthChoiceParams } from "./auth-choice.apply.types.js"; +type ResolveProviderInstallCatalogEntry = + typeof import("../plugins/provider-install-catalog.js").resolveProviderInstallCatalogEntry; +type EnsureOnboardingPluginInstalled = + typeof import("../commands/onboarding-plugin-install.js").ensureOnboardingPluginInstalled; + const resolvePluginProviders = vi.hoisted(() => vi.fn<() => ProviderPlugin[]>(() => [])); const resolveProviderPluginChoice = vi.hoisted(() => vi.fn<() => { provider: ProviderPlugin; method: ProviderAuthMethod } | null>(), @@ -60,6 +65,30 @@ vi.mock("../plugins/provider-oauth-flow.js", () => ({ createVpsAwareOAuthHandlers, })); +const resolveProviderInstallCatalogEntry = vi.hoisted(() => + vi.fn(() => undefined), +); +vi.mock("../plugins/provider-install-catalog.js", () => ({ + resolveProviderInstallCatalogEntry, +})); + +const ensureOnboardingPluginInstalled = vi.hoisted(() => + vi.fn(async ({ cfg, entry }) => ({ + cfg, + installed: false, + pluginId: entry?.pluginId ?? "missing-plugin", + status: "skipped", + })), +); +vi.mock("../commands/onboarding-plugin-install.js", () => ({ + ensureOnboardingPluginInstalled, +})); + +const clearPluginDiscoveryCache = vi.hoisted(() => vi.fn()); +vi.mock("../plugins/discovery.js", () => ({ + clearPluginDiscoveryCache, +})); + const LOCAL_PROVIDER_ID = "local-provider"; const LOCAL_PROVIDER_LABEL = "Local Provider"; const LOCAL_AUTH_METHOD_ID = "local"; @@ -111,6 +140,13 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { beforeEach(() => { vi.clearAllMocks(); applyAuthProfileConfig.mockImplementation((config) => config); + resolveProviderInstallCatalogEntry.mockReturnValue(undefined); + ensureOnboardingPluginInstalled.mockImplementation(async ({ cfg, entry }) => ({ + cfg, + installed: false, + pluginId: entry?.pluginId ?? "missing-plugin", + status: "skipped", + })); }); it("returns an agent model override when default model application is deferred", async () => { @@ -252,6 +288,125 @@ describe("applyAuthChoiceLoadedPluginProvider", () => { }); }); + it("installs a missing provider plugin and retries setup resolution", async () => { + const provider = buildProvider(); + resolveProviderInstallCatalogEntry.mockReturnValue({ + pluginId: "local-provider-plugin", + providerId: LOCAL_PROVIDER_ID, + methodId: LOCAL_AUTH_METHOD_ID, + choiceId: LOCAL_PROVIDER_ID, + choiceLabel: LOCAL_PROVIDER_LABEL, + label: LOCAL_PROVIDER_LABEL, + origin: "bundled", + install: { + npmSpec: "@openclaw/local-provider", + }, + }); + ensureOnboardingPluginInstalled.mockResolvedValue({ + cfg: { + plugins: { + entries: { + "local-provider-plugin": { + enabled: true, + }, + }, + }, + }, + installed: true, + pluginId: "local-provider-plugin", + status: "installed", + }); + resolvePluginProviders.mockReturnValue([provider]); + resolveProviderPluginChoice.mockReturnValueOnce(null).mockReturnValueOnce({ + provider, + method: provider.auth[0], + }); + + const result = await applyAuthChoiceLoadedPluginProvider(buildParams()); + + expect(ensureOnboardingPluginInstalled).toHaveBeenCalledWith( + expect.objectContaining({ + entry: expect.objectContaining({ + pluginId: "local-provider-plugin", + label: LOCAL_PROVIDER_LABEL, + }), + workspaceDir: "/tmp/workspace", + }), + ); + expect(clearPluginDiscoveryCache).toHaveBeenCalledOnce(); + expect(resolvePluginProviders).toHaveBeenCalledTimes(2); + expect(result?.config.agents?.defaults?.model).toEqual({ + primary: LOCAL_DEFAULT_MODEL, + }); + }); + + it("does not persist plugin enablement when install is skipped", async () => { + resolveProviderInstallCatalogEntry.mockReturnValue({ + pluginId: "local-provider-plugin", + providerId: LOCAL_PROVIDER_ID, + methodId: LOCAL_AUTH_METHOD_ID, + choiceId: LOCAL_PROVIDER_ID, + choiceLabel: LOCAL_PROVIDER_LABEL, + label: LOCAL_PROVIDER_LABEL, + origin: "bundled", + install: { + npmSpec: "@openclaw/local-provider", + }, + }); + resolveProviderPluginChoice.mockReturnValue(null); + + const result = await applyAuthChoiceLoadedPluginProvider(buildParams()); + + expect(ensureOnboardingPluginInstalled).toHaveBeenCalledOnce(); + expect(result).toEqual({ config: {}, retrySelection: true }); + }); + + it("preserves install config when the chosen provider still cannot resolve after install", async () => { + resolveProviderInstallCatalogEntry.mockReturnValue({ + pluginId: "local-provider-plugin", + providerId: LOCAL_PROVIDER_ID, + methodId: LOCAL_AUTH_METHOD_ID, + choiceId: LOCAL_PROVIDER_ID, + choiceLabel: LOCAL_PROVIDER_LABEL, + label: LOCAL_PROVIDER_LABEL, + origin: "bundled", + install: { + npmSpec: "@openclaw/local-provider", + }, + }); + ensureOnboardingPluginInstalled.mockResolvedValue({ + cfg: { + plugins: { + entries: { + "local-provider-plugin": { + enabled: true, + }, + }, + }, + }, + installed: true, + pluginId: "local-provider-plugin", + status: "installed", + }); + resolveProviderPluginChoice.mockReturnValue(null); + + const result = await applyAuthChoiceLoadedPluginProvider(buildParams()); + + expect(clearPluginDiscoveryCache).toHaveBeenCalledOnce(); + expect(result).toEqual({ + config: { + plugins: { + entries: { + "local-provider-plugin": { + enabled: true, + }, + }, + }, + }, + retrySelection: true, + }); + }); + it("merges provider config patches and emits provider notes", async () => { applyAuthProfileConfig.mockImplementation((( config: { diff --git a/src/commands/auth-choice.apply.types.ts b/src/commands/auth-choice.apply.types.ts index 92b5c23724a..fe135787196 100644 --- a/src/commands/auth-choice.apply.types.ts +++ b/src/commands/auth-choice.apply.types.ts @@ -18,4 +18,5 @@ export type ApplyAuthChoiceParams = { export type ApplyAuthChoiceResult = { config: OpenClawConfig; agentModelOverride?: string; + retrySelection?: boolean; }; diff --git a/src/commands/auth-choice.test.ts b/src/commands/auth-choice.test.ts index 77c5093dc2a..1cfe2e2fb30 100644 --- a/src/commands/auth-choice.test.ts +++ b/src/commands/auth-choice.test.ts @@ -1015,8 +1015,8 @@ describe("applyAuthChoice", () => { expect(resolvePluginProviders).toHaveBeenCalledWith( expect.objectContaining({ - config: {}, env, + mode: "setup", }), ); expect(confirm).toHaveBeenCalledWith( diff --git a/src/commands/channel-setup/plugin-install.test.ts b/src/commands/channel-setup/plugin-install.test.ts index 54728f9727c..5f0cf9491a7 100644 --- a/src/commands/channel-setup/plugin-install.test.ts +++ b/src/commands/channel-setup/plugin-install.test.ts @@ -8,16 +8,31 @@ import { vi.mock("node:fs", async () => { const actual = await vi.importActual("node:fs"); const existsSync = vi.fn(); + const realpathSync = vi.fn(actual.realpathSync); + const statSync = vi.fn(actual.statSync); return { ...actual, existsSync, + realpathSync, + statSync, default: { ...actual, existsSync, + realpathSync, + statSync, }, }; }); +const execFileSync = vi.fn(); +vi.mock("node:child_process", async () => { + const actual = await vi.importActual("node:child_process"); + return { + ...actual, + execFileSync: (...args: unknown[]) => execFileSync(...args), + }; +}); + const installPluginFromNpmSpec = vi.fn(); const applyPluginAutoEnable = vi.fn(); vi.mock("../../plugins/install.js", () => ({ @@ -180,6 +195,9 @@ function expectSetupSnapshotDoesNotScopeToPlugin(params: { beforeEach(() => { vi.clearAllMocks(); + execFileSync.mockImplementation(() => { + throw new Error("not a git worktree"); + }); applyPluginAutoEnable.mockImplementation((params: { config: unknown }) => ({ config: params.config, changes: [], @@ -193,10 +211,46 @@ beforeEach(() => { }); function mockRepoLocalPathExists() { + execFileSync.mockImplementation((command: string, args: string[]) => { + expect(command).toBe("git"); + expect(args[1]).toBe(process.cwd()); + expect(args[2]).toBe("rev-parse"); + const request = args.slice(3).join(" "); + if (request === "--is-inside-work-tree") { + return "true\n"; + } + if (request === "--path-format=absolute --show-toplevel") { + return `${process.cwd()}\n`; + } + if (request === "--path-format=absolute --git-common-dir") { + return `${process.cwd()}\n`; + } + throw new Error(`unexpected git args: ${request}`); + }); + vi.mocked(fs.realpathSync).mockImplementation(((value: fs.PathLike) => { + const raw = String(value); + if (raw.endsWith(`${path.sep}extensions${path.sep}bundled-chat`)) { + return path.resolve(process.cwd(), bundledPluginRoot("bundled-chat")); + } + return raw; + }) as typeof fs.realpathSync); + vi.mocked(fs.statSync).mockImplementation(((value: fs.PathLike) => { + const raw = String(value); + if (raw.endsWith(`${path.sep}extensions${path.sep}bundled-chat`)) { + return { + isDirectory: () => true, + } as ReturnType; + } + return { + isDirectory: () => true, + } as ReturnType; + }) as typeof fs.statSync); vi.mocked(fs.existsSync).mockImplementation((value) => { const raw = String(value); return ( - raw.endsWith(`${path.sep}.git`) || + raw.endsWith(`${path.sep}.git${path.sep}HEAD`) || + raw.endsWith(`${path.sep}.git${path.sep}objects`) || + raw.endsWith(`${path.sep}.git${path.sep}refs`) || raw.endsWith(`${path.sep}extensions${path.sep}bundled-chat`) ); }); diff --git a/src/commands/channel-setup/plugin-install.ts b/src/commands/channel-setup/plugin-install.ts index 0749ee457f3..a7896b01c3f 100644 --- a/src/commands/channel-setup/plugin-install.ts +++ b/src/commands/channel-setup/plugin-install.ts @@ -1,147 +1,40 @@ -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 { resolveBundledInstallPlanForCatalogEntry } from "../../cli/plugin-install-plan.js"; import { applyPluginAutoEnable } from "../../config/plugin-auto-enable.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { createSubsystemLogger } from "../../logging/subsystem.js"; -import { - findBundledPluginSourceInMap, - resolveBundledPluginSources, -} from "../../plugins/bundled-sources.js"; import { resolveDiscoverableScopedChannelPluginIds } from "../../plugins/channel-plugin-ids.js"; import { clearPluginDiscoveryCache } from "../../plugins/discovery.js"; -import { enablePluginInConfig } from "../../plugins/enable.js"; -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 { getActivePluginChannelRegistry } from "../../plugins/runtime.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { WizardPrompter } from "../../wizard/prompts.js"; +import { + ensureOnboardingPluginInstalled, + type OnboardingPluginInstallEntry, + type OnboardingPluginInstallStatus, +} from "../onboarding-plugin-install.js"; import { getTrustedChannelPluginCatalogEntry } from "./trusted-catalog.js"; -type InstallChoice = "npm" | "local" | "skip"; - type InstallResult = { cfg: OpenClawConfig; installed: boolean; pluginId?: string; + status: OnboardingPluginInstallStatus; }; -function hasGitWorkspace(workspaceDir?: string): boolean { - const candidates = new Set(); - candidates.add(path.join(process.cwd(), ".git")); - if (workspaceDir && workspaceDir !== process.cwd()) { - candidates.add(path.join(workspaceDir, ".git")); - } - for (const candidate of candidates) { - if (fs.existsSync(candidate)) { - return true; - } - } - return false; -} - -function resolveLocalPath( +function toOnboardingPluginInstallEntry( entry: ChannelPluginCatalogEntry, - workspaceDir: string | undefined, - allowLocal: boolean, -): string | null { - if (!allowLocal) { - return 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 addPluginLoadPath(cfg: OpenClawConfig, pluginPath: string): OpenClawConfig { - const existing = cfg.plugins?.load?.paths ?? []; - const merged = Array.from(new Set([...existing, pluginPath])); +): OnboardingPluginInstallEntry { return { - ...cfg, - plugins: { - ...cfg.plugins, - load: { - ...cfg.plugins?.load, - paths: merged, - }, - }, + pluginId: entry.pluginId ?? entry.id, + label: entry.meta.label, + install: entry.install, }; } -async function promptInstallChoice(params: { - entry: ChannelPluginCatalogEntry; - localPath?: string | null; - defaultChoice: InstallChoice; - prompter: WizardPrompter; -}): Promise { - const { entry, localPath, prompter, defaultChoice } = params; - const localOptions: Array<{ value: InstallChoice; label: string; hint?: string }> = localPath - ? [ - { - value: "local", - label: "Use local plugin path", - hint: localPath, - }, - ] - : []; - const options: Array<{ value: InstallChoice; label: string; hint?: string }> = [ - { value: "npm", label: `Download from npm (${entry.install.npmSpec})` }, - ...localOptions, - { value: "skip", label: "Skip for now" }, - ]; - const initialValue: InstallChoice = - defaultChoice === "local" && !localPath ? "npm" : defaultChoice; - return await prompter.select({ - message: `Install ${entry.meta.label} plugin?`, - options, - initialValue, - }); -} - -function resolveInstallDefaultChoice(params: { - cfg: OpenClawConfig; - entry: ChannelPluginCatalogEntry; - localPath?: string | null; - bundledLocalPath?: string | null; -}): InstallChoice { - const { cfg, entry, localPath, bundledLocalPath } = params; - if (bundledLocalPath) { - return "local"; - } - const updateChannel = cfg.update?.channel; - if (updateChannel === "dev") { - return localPath ? "local" : "npm"; - } - if (updateChannel === "stable" || updateChannel === "beta") { - return "npm"; - } - const entryDefault = entry.install.defaultChoice; - if (entryDefault === "local") { - return localPath ? "local" : "npm"; - } - if (entryDefault === "npm") { - return "npm"; - } - return localPath ? "local" : "npm"; -} - export async function ensureChannelSetupPluginInstalled(params: { cfg: OpenClawConfig; entry: ChannelPluginCatalogEntry; @@ -149,83 +42,19 @@ export async function ensureChannelSetupPluginInstalled(params: { runtime: RuntimeEnv; workspaceDir?: string; }): Promise { - const { entry, prompter, runtime, workspaceDir } = params; - let next = params.cfg; - const allowLocal = hasGitWorkspace(workspaceDir); - const bundledSources = resolveBundledPluginSources({ workspaceDir }); - const bundledLocalPath = - resolveBundledInstallPlanForCatalogEntry({ - pluginId: entry.id, - npmSpec: entry.install.npmSpec, - findBundledSource: (lookup) => - findBundledPluginSourceInMap({ bundled: bundledSources, lookup }), - })?.bundledSource.localPath ?? null; - const localPath = bundledLocalPath ?? resolveLocalPath(entry, workspaceDir, allowLocal); - const defaultChoice = resolveInstallDefaultChoice({ - cfg: next, - entry, - localPath, - bundledLocalPath, + const result = await ensureOnboardingPluginInstalled({ + cfg: params.cfg, + entry: toOnboardingPluginInstallEntry(params.entry), + prompter: params.prompter, + runtime: params.runtime, + workspaceDir: params.workspaceDir, }); - const choice = await promptInstallChoice({ - entry, - localPath, - defaultChoice, - prompter, - }); - - if (choice === "skip") { - return { cfg: next, installed: false }; - } - - if (choice === "local" && localPath) { - next = addPluginLoadPath(next, localPath); - const pluginId = entry.pluginId ?? entry.id; - next = enablePluginInConfig(next, pluginId).config; - return { cfg: next, installed: true, pluginId }; - } - - const result = await installPluginFromNpmSpec({ - spec: entry.install.npmSpec, - logger: { - info: (msg) => runtime.log?.(msg), - warn: (msg) => runtime.log?.(msg), - }, - }); - - if (result.ok) { - next = enablePluginInConfig(next, result.pluginId).config; - next = recordPluginInstall(next, { - pluginId: result.pluginId, - source: "npm", - spec: entry.install.npmSpec, - installPath: result.targetDir, - version: result.version, - ...buildNpmResolutionInstallFields(result.npmResolution), - }); - return { cfg: next, installed: true, pluginId: result.pluginId }; - } - - 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); - const pluginId = entry.pluginId ?? entry.id; - next = enablePluginInConfig(next, pluginId).config; - return { cfg: next, installed: true, pluginId }; - } - } - - runtime.error?.(`Plugin install failed: ${result.error}`); - return { cfg: next, installed: false }; + return { + cfg: result.cfg, + installed: result.installed, + pluginId: result.pluginId, + status: result.status, + }; } export function reloadChannelSetupPluginRegistry(params: { diff --git a/src/commands/channels.add.test.ts b/src/commands/channels.add.test.ts index 2634cc74b77..3c6a9df30e2 100644 --- a/src/commands/channels.add.test.ts +++ b/src/commands/channels.add.test.ts @@ -259,6 +259,7 @@ describe("channelsAddCommand", () => { vi.mocked(ensureChannelSetupPluginInstalled).mockImplementation(async ({ cfg }) => ({ cfg, installed: true, + status: "installed", })); vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReset(); vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue( @@ -372,6 +373,7 @@ describe("channelsAddCommand", () => { cfg, installed: true, pluginId: "@vendor/external-chat-runtime", + status: "installed", })); vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue( createTestRegistry([ diff --git a/src/commands/channels.remove.test.ts b/src/commands/channels.remove.test.ts index 3e145948647..7aed9275e32 100644 --- a/src/commands/channels.remove.test.ts +++ b/src/commands/channels.remove.test.ts @@ -73,6 +73,7 @@ describe("channelsRemoveCommand", () => { vi.mocked(ensureChannelSetupPluginInstalled).mockImplementation(async ({ cfg }) => ({ cfg, installed: true, + status: "installed", })); vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockClear(); vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockReturnValue( diff --git a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts index 1863e54e19d..b23282d48ad 100644 --- a/src/commands/configure.gateway-auth.prompt-auth-config.test.ts +++ b/src/commands/configure.gateway-auth.prompt-auth-config.test.ts @@ -212,4 +212,26 @@ describe("promptAuthConfig", () => { }), ); }); + + it("returns to auth selection when plugin install onboarding asks for a retry", async () => { + vi.clearAllMocks(); + mocks.promptAuthChoiceGrouped + .mockResolvedValueOnce("provider-plugin:wecom:default") + .mockResolvedValueOnce("kilocode-api-key"); + mocks.applyAuthChoice + .mockResolvedValueOnce({ config: {}, retrySelection: true }) + .mockResolvedValueOnce(createApplyAuthChoiceConfig()); + mocks.promptModelAllowlist.mockResolvedValue({ models: undefined }); + mocks.resolvePreferredProviderForAuthChoice + .mockResolvedValueOnce("wecom") + .mockResolvedValueOnce("kilocode"); + mocks.resolvePluginProviders.mockReturnValue([]); + mocks.resolveProviderPluginChoice.mockReturnValue(null); + + await promptAuthConfig({}, makeRuntime(), noopPrompter); + + expect(mocks.promptAuthChoiceGrouped).toHaveBeenCalledTimes(2); + expect(mocks.applyAuthChoice).toHaveBeenCalledTimes(2); + expect(mocks.promptModelAllowlist).toHaveBeenCalledTimes(1); + }); }); diff --git a/src/commands/configure.gateway-auth.ts b/src/commands/configure.gateway-auth.ts index d7db3ac630b..65dd16f017e 100644 --- a/src/commands/configure.gateway-auth.ts +++ b/src/commands/configure.gateway-auth.ts @@ -99,27 +99,53 @@ export async function promptAuthConfig( runtime: RuntimeEnv, prompter: WizardPrompter, ): Promise { - const authChoice = await promptAuthChoiceGrouped({ - prompter, - store: ensureAuthProfileStore(undefined, { - allowKeychainPrompt: false, - }), - includeSkip: true, - config: cfg, - }); - let next = cfg; - const preferredProvider = - authChoice === "skip" - ? undefined - : await resolvePreferredProviderForAuthChoice({ - choice: authChoice, - config: cfg, - }); - if (authChoice === "custom-api-key") { - const customResult = await promptCustomApiConfig({ prompter, runtime, config: next }); - next = customResult.config; - } else if (authChoice !== "skip") { + let authChoice: string = "skip"; + let preferredProvider: string | undefined; + while (true) { + authChoice = await promptAuthChoiceGrouped({ + prompter, + store: ensureAuthProfileStore(undefined, { + allowKeychainPrompt: false, + }), + includeSkip: true, + config: next, + }); + + preferredProvider = + authChoice === "skip" + ? undefined + : await resolvePreferredProviderForAuthChoice({ + choice: authChoice, + config: next, + }); + + if (authChoice === "custom-api-key") { + const customResult = await promptCustomApiConfig({ prompter, runtime, config: next }); + next = customResult.config; + break; + } + + if (authChoice === "skip") { + const modelSelection = await promptDefaultModel({ + config: next, + prompter, + allowKeep: true, + ignoreAllowlist: true, + includeProviderPluginSetups: true, + preferredProvider, + workspaceDir: resolveDefaultAgentWorkspaceDir(), + runtime, + }); + if (modelSelection.config) { + next = modelSelection.config; + } + if (modelSelection.model) { + next = applyPrimaryModel(next, modelSelection.model); + } + break; + } + const applied = await applyAuthChoice({ authChoice, config: next, @@ -128,23 +154,10 @@ export async function promptAuthConfig( setDefaultModel: true, }); next = applied.config; - } else { - const modelSelection = await promptDefaultModel({ - config: next, - prompter, - allowKeep: true, - ignoreAllowlist: true, - includeProviderPluginSetups: true, - preferredProvider, - workspaceDir: resolveDefaultAgentWorkspaceDir(), - runtime, - }); - if (modelSelection.config) { - next = modelSelection.config; - } - if (modelSelection.model) { - next = applyPrimaryModel(next, modelSelection.model); + if (applied.retrySelection) { + continue; } + break; } if (authChoice !== "custom-api-key") { diff --git a/src/commands/onboard-channels.e2e.test.ts b/src/commands/onboard-channels.e2e.test.ts index 965dd80580c..fa27b03c1ea 100644 --- a/src/commands/onboard-channels.e2e.test.ts +++ b/src/commands/onboard-channels.e2e.test.ts @@ -505,6 +505,7 @@ describe("setupChannels", () => { vi.mocked(ensureChannelSetupPluginInstalled).mockImplementation(async ({ cfg }) => ({ cfg, installed: true, + status: "installed", })); vi.mocked(loadChannelSetupPluginRegistrySnapshotForChannel).mockClear(); vi.mocked(reloadChannelSetupPluginRegistry).mockClear(); diff --git a/src/commands/onboarding-plugin-install.test.ts b/src/commands/onboarding-plugin-install.test.ts new file mode 100644 index 00000000000..6282d1420c8 --- /dev/null +++ b/src/commands/onboarding-plugin-install.test.ts @@ -0,0 +1,515 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { withTempDir } from "../test-helpers/temp-dir.js"; + +const resolveBundledInstallPlanForCatalogEntry = vi.hoisted(() => vi.fn(() => undefined)); +vi.mock("../cli/plugin-install-plan.js", () => ({ + resolveBundledInstallPlanForCatalogEntry, +})); + +const resolveBundledPluginSources = vi.hoisted(() => vi.fn(() => new Map())); +const findBundledPluginSourceInMap = vi.hoisted(() => vi.fn(() => null)); +vi.mock("../plugins/bundled-sources.js", () => ({ + resolveBundledPluginSources, + findBundledPluginSourceInMap, +})); + +const installPluginFromNpmSpec = vi.hoisted(() => vi.fn()); +vi.mock("../plugins/install.js", () => ({ + installPluginFromNpmSpec, +})); + +const enablePluginInConfig = vi.hoisted(() => vi.fn((cfg) => ({ config: cfg, enabled: true }))); +vi.mock("../plugins/enable.js", () => ({ + enablePluginInConfig, +})); + +const recordPluginInstall = vi.hoisted(() => vi.fn((cfg) => cfg)); +const buildNpmResolutionInstallFields = vi.hoisted(() => vi.fn(() => ({}))); +vi.mock("../plugins/installs.js", () => ({ + recordPluginInstall, + buildNpmResolutionInstallFields, +})); + +const withTimeout = vi.hoisted(() => vi.fn(async (promise: Promise) => await promise)); +vi.mock("../utils/with-timeout.js", () => ({ + withTimeout, +})); + +import { ensureOnboardingPluginInstalled } from "./onboarding-plugin-install.js"; + +describe("ensureOnboardingPluginInstalled", () => { + beforeEach(() => { + vi.clearAllMocks(); + withTimeout.mockImplementation(async (promise: Promise) => await promise); + }); + + it("passes pinned npm specs and expected integrity to npm installs with progress", async () => { + installPluginFromNpmSpec.mockImplementation(async (params) => { + params.logger?.info?.("Downloading demo-plugin…"); + return { + ok: true, + pluginId: "demo-plugin", + targetDir: "/tmp/demo-plugin", + version: "1.2.3", + npmResolution: { + resolvedSpec: "@wecom/wecom-openclaw-plugin@1.2.3", + integrity: "sha512-wecom", + }, + }; + }); + const stop = vi.fn(); + const update = vi.fn(); + + const result = await ensureOnboardingPluginInstalled({ + cfg: {}, + entry: { + pluginId: "demo-plugin", + label: "WeCom", + install: { + npmSpec: "@wecom/wecom-openclaw-plugin@1.2.3", + expectedIntegrity: "sha512-wecom", + }, + }, + prompter: { + select: vi.fn(async () => "npm"), + progress: vi.fn(() => ({ update, stop })), + } as never, + runtime: {} as never, + }); + + expect(installPluginFromNpmSpec).toHaveBeenCalledWith( + expect.objectContaining({ + spec: "@wecom/wecom-openclaw-plugin@1.2.3", + expectedIntegrity: "sha512-wecom", + timeoutMs: 300_000, + }), + ); + expect(update).toHaveBeenCalledWith("Downloading demo-plugin…"); + expect(stop).toHaveBeenCalledWith("Installed WeCom plugin"); + expect(result.installed).toBe(true); + expect(result.status).toBe("installed"); + }); + + it("returns a timed out status and notes the retry path when npm install hangs", async () => { + const note = vi.fn(async () => {}); + const stop = vi.fn(); + withTimeout.mockRejectedValue(new Error("timeout")); + + const result = await ensureOnboardingPluginInstalled({ + cfg: {}, + entry: { + pluginId: "demo-plugin", + label: "Demo Plugin", + install: { + npmSpec: "@demo/plugin@1.2.3", + expectedIntegrity: "sha512-demo", + }, + }, + prompter: { + select: vi.fn(async () => "npm"), + note, + progress: vi.fn(() => ({ update: vi.fn(), stop })), + } as never, + runtime: { + error: vi.fn(), + } as never, + }); + + expect(result).toEqual({ + cfg: {}, + installed: false, + pluginId: "demo-plugin", + status: "timed_out", + }); + expect(stop).toHaveBeenCalledWith("Install timed out: Demo Plugin"); + expect(note).toHaveBeenCalledWith( + "Installing @demo/plugin@1.2.3 timed out after 5 minutes.\nReturning to selection.", + "Plugin install", + ); + }); + + it("does not offer npm installs without an exact version and integrity pin", async () => { + let captured: + | { + options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>; + initialValue: "npm" | "local" | "skip"; + } + | undefined; + + await ensureOnboardingPluginInstalled({ + cfg: {}, + entry: { + pluginId: "demo-plugin", + label: "Demo Plugin", + install: { + npmSpec: "@demo/plugin", + }, + }, + prompter: { + select: vi.fn(async (input) => { + captured = input; + return "skip"; + }), + } as never, + runtime: {} as never, + }); + + expect(captured?.options).toEqual([{ value: "skip", label: "Skip for now" }]); + expect(captured?.initialValue).toBe("skip"); + expect(installPluginFromNpmSpec).not.toHaveBeenCalled(); + }); + + it("does not offer local installs when the workspace only has a spoofed .git marker", async () => { + await withTempDir({ prefix: "openclaw-onboarding-install-spoofed-git-" }, async (temp) => { + const workspaceDir = path.join(temp, "workspace"); + const cwdDir = path.join(temp, "cwd"); + const pluginDir = path.join(workspaceDir, "plugins", "demo"); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.mkdir(cwdDir, { recursive: true }); + await fs.writeFile(path.join(workspaceDir, ".git"), "not-a-gitdir-pointer\n", "utf8"); + + let captured: + | { + message: string; + options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>; + initialValue: "npm" | "local" | "skip"; + } + | undefined; + + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(cwdDir); + let result: Awaited> | undefined; + try { + result = await ensureOnboardingPluginInstalled({ + cfg: {}, + entry: { + pluginId: "demo-plugin", + label: "Demo Plugin", + install: { + localPath: "plugins/demo", + }, + }, + prompter: { + select: vi.fn(async (input) => { + captured = input; + return "skip"; + }), + } as never, + runtime: {} as never, + workspaceDir, + }); + } finally { + cwdSpy.mockRestore(); + } + + expect(captured).toBeDefined(); + expect(captured?.message).toBe("Install Demo Plugin plugin?"); + expect(captured?.options).toEqual([{ value: "skip", label: "Skip for now" }]); + expect(result).toEqual({ + cfg: {}, + installed: false, + pluginId: "demo-plugin", + status: "skipped", + }); + }); + }); + + it("allows local installs for real gitdir checkouts and sanitizes prompt text", async () => { + await withTempDir({ prefix: "openclaw-onboarding-install-gitdir-" }, async (temp) => { + const workspaceDir = path.join(temp, "workspace"); + const pluginDir = path.join(workspaceDir, "plugins", "demo"); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.mkdir(path.join(workspaceDir, ".git"), { recursive: true }); + + let captured: + | { + message: string; + options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>; + initialValue: "npm" | "local" | "skip"; + } + | undefined; + + await ensureOnboardingPluginInstalled({ + cfg: {}, + entry: { + pluginId: "demo-plugin", + label: "Demo\x1b[31m Plugin\n", + install: { + npmSpec: "@demo/plugin@1.2.3", + expectedIntegrity: "sha512-demo", + localPath: "plugins/demo", + }, + }, + prompter: { + select: vi.fn(async (input) => { + captured = input; + return "skip"; + }), + } as never, + runtime: {} as never, + workspaceDir, + }); + + const realPluginDir = await fs.realpath(pluginDir); + expect(captured).toBeDefined(); + expect(captured?.message).toBe("Install Demo Plugin\\n plugin?"); + expect(captured?.options).toEqual([ + { value: "npm", label: "Download from npm (@demo/plugin@1.2.3)" }, + { + value: "local", + label: "Use local plugin path", + hint: realPluginDir, + }, + { value: "skip", label: "Skip for now" }, + ]); + expect(captured?.message).not.toContain("\x1b"); + expect(captured?.options[0]?.label).not.toContain("\x1b"); + }); + }); + + it("does not add local plugin paths when enablement is blocked by policy", async () => { + await withTempDir({ prefix: "openclaw-onboarding-install-blocked-enable-" }, async (temp) => { + const workspaceDir = path.join(temp, "workspace"); + const pluginDir = path.join(workspaceDir, "plugins", "demo"); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.mkdir(path.join(workspaceDir, ".git"), { recursive: true }); + enablePluginInConfig.mockReturnValueOnce({ + config: {}, + enabled: false, + reason: "blocked by allowlist", + }); + const note = vi.fn(async () => {}); + const error = vi.fn(); + + const result = await ensureOnboardingPluginInstalled({ + cfg: {}, + entry: { + pluginId: "demo-plugin", + label: "Demo Plugin", + install: { + localPath: "plugins/demo", + }, + }, + prompter: { + select: vi.fn(async () => "local"), + note, + } as never, + runtime: { error } as never, + workspaceDir, + }); + + expect(result).toEqual({ + cfg: {}, + installed: false, + pluginId: "demo-plugin", + status: "failed", + }); + expect(note).toHaveBeenCalledWith( + "Cannot enable Demo Plugin: blocked by allowlist.", + "Plugin install", + ); + expect(error).toHaveBeenCalledWith( + "Plugin install failed: demo-plugin is disabled (blocked by allowlist).", + ); + }); + }); + + it("allows local installs for linked git worktrees", async () => { + await withTempDir({ prefix: "openclaw-onboarding-install-worktree-" }, async (temp) => { + const workspaceDir = path.join(temp, "workspace"); + const pluginDir = path.join(workspaceDir, "plugins", "demo"); + const commonGitDir = path.join(temp, "repo.git"); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.mkdir(commonGitDir, { recursive: true }); + const realCommonGitDir = await fs.realpath(commonGitDir); + await fs.writeFile(path.join(workspaceDir, ".git"), `gitdir: ${realCommonGitDir}\n`, "utf8"); + + let captured: + | { + message: string; + options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>; + initialValue: "npm" | "local" | "skip"; + } + | undefined; + + await ensureOnboardingPluginInstalled({ + cfg: {}, + entry: { + pluginId: "demo-plugin", + label: "Demo Plugin", + install: { + localPath: "plugins/demo", + }, + }, + prompter: { + select: vi.fn(async (input) => { + captured = input; + return "skip"; + }), + } as never, + runtime: {} as never, + workspaceDir, + }); + + const realPluginDir = await fs.realpath(pluginDir); + expect(captured?.options).toEqual([ + { + value: "local", + label: "Use local plugin path", + hint: realPluginDir, + }, + { value: "skip", label: "Skip for now" }, + ]); + expect(captured?.initialValue).toBe("local"); + }); + }); + + it("keeps local installs available when cwd is a git repo but workspaceDir is not", async () => { + await withTempDir({ prefix: "openclaw-onboarding-install-cwd-git-" }, async (temp) => { + const repoDir = path.join(temp, "repo"); + const workspaceDir = path.join(temp, "workspace"); + const pluginDir = path.join(repoDir, "demo-plugin"); + await fs.mkdir(path.join(repoDir, ".git"), { recursive: true }); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.mkdir(workspaceDir, { recursive: true }); + + let captured: + | { + options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>; + } + | undefined; + const cwdSpy = vi.spyOn(process, "cwd").mockReturnValue(repoDir); + try { + await ensureOnboardingPluginInstalled({ + cfg: {}, + entry: { + pluginId: "demo-plugin", + label: "Demo Plugin", + install: { + localPath: pluginDir, + }, + }, + prompter: { + select: vi.fn(async (input) => { + captured = input; + return "skip"; + }), + } as never, + runtime: {} as never, + workspaceDir, + }); + } finally { + cwdSpy.mockRestore(); + } + + const realPluginDir = await fs.realpath(pluginDir); + expect(captured?.options).toEqual([ + { + value: "local", + label: "Use local plugin path", + hint: realPluginDir, + }, + { value: "skip", label: "Skip for now" }, + ]); + }); + }); + + it("rejects local install paths outside the trusted workspace roots", async () => { + await withTempDir({ prefix: "openclaw-onboarding-install-outside-root-" }, async (temp) => { + const workspaceDir = path.join(temp, "workspace"); + const pluginDir = path.join(temp, "external-plugin"); + await fs.mkdir(path.join(workspaceDir, ".git"), { recursive: true }); + await fs.mkdir(pluginDir, { recursive: true }); + + let captured: + | { + options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>; + } + | undefined; + + await ensureOnboardingPluginInstalled({ + cfg: {}, + entry: { + pluginId: "demo-plugin", + label: "Demo Plugin", + install: { + localPath: pluginDir, + }, + }, + prompter: { + select: vi.fn(async (input) => { + captured = input; + return "skip"; + }), + } as never, + runtime: {} as never, + workspaceDir, + }); + + expect(captured?.options).toEqual([{ value: "skip", label: "Skip for now" }]); + }); + }); + + it("rejects local install paths when relative resolution looks cross-drive", async () => { + await withTempDir({ prefix: "openclaw-onboarding-install-cross-drive-" }, async (temp) => { + const workspaceDir = path.join(temp, "workspace"); + const pluginDir = path.join(workspaceDir, "plugins", "demo"); + await fs.mkdir(path.join(workspaceDir, ".git"), { recursive: true }); + await fs.mkdir(pluginDir, { recursive: true }); + const realWorkspaceDir = await fs.realpath(workspaceDir); + + const originalRelative = path.relative; + const originalIsAbsolute = path.isAbsolute; + const relativeSpy = vi.spyOn(path, "relative").mockImplementation((from, to) => { + if ( + typeof from === "string" && + typeof to === "string" && + from === realWorkspaceDir && + to === path.join(realWorkspaceDir, "plugins", "demo") + ) { + return "D:\\evil"; + } + return originalRelative(from, to); + }); + const isAbsoluteSpy = vi.spyOn(path, "isAbsolute").mockImplementation((value) => { + if (value === "D:\\evil") { + return true; + } + return originalIsAbsolute(value); + }); + + try { + let captured: + | { + options: Array<{ value: "npm" | "local" | "skip"; label: string; hint?: string }>; + } + | undefined; + + await ensureOnboardingPluginInstalled({ + cfg: {}, + entry: { + pluginId: "demo-plugin", + label: "Demo Plugin", + install: { + localPath: "plugins/demo", + }, + }, + prompter: { + select: vi.fn(async (input) => { + captured = input; + return "skip"; + }), + } as never, + runtime: {} as never, + workspaceDir, + }); + + expect(captured?.options).toEqual([{ value: "skip", label: "Skip for now" }]); + } finally { + relativeSpy.mockRestore(); + isAbsoluteSpy.mockRestore(); + } + }); + }); +}); diff --git a/src/commands/onboarding-plugin-install.ts b/src/commands/onboarding-plugin-install.ts new file mode 100644 index 00000000000..0ffd53699e0 --- /dev/null +++ b/src/commands/onboarding-plugin-install.ts @@ -0,0 +1,574 @@ +import fs from "node:fs"; +import path from "node:path"; +import { resolveBundledInstallPlanForCatalogEntry } from "../cli/plugin-install-plan.js"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; +import { + findBundledPluginSourceInMap, + resolveBundledPluginSources, +} from "../plugins/bundled-sources.js"; +import { enablePluginInConfig, type PluginEnableResult } from "../plugins/enable.js"; +import { installPluginFromNpmSpec } from "../plugins/install.js"; +import { buildNpmResolutionInstallFields, recordPluginInstall } from "../plugins/installs.js"; +import type { PluginPackageInstall } from "../plugins/manifest.js"; +import type { RuntimeEnv } from "../runtime.js"; +import { sanitizeTerminalText } from "../terminal/safe-text.js"; +import { withTimeout } from "../utils/with-timeout.js"; +import type { WizardPrompter } from "../wizard/prompts.js"; + +type InstallChoice = "npm" | "local" | "skip"; +const ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS = 5 * 60 * 1000; +const ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS = ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS + 5_000; + +export type OnboardingPluginInstallEntry = { + pluginId: string; + label: string; + install: PluginPackageInstall; +}; + +export type OnboardingPluginInstallStatus = "installed" | "skipped" | "failed" | "timed_out"; + +export type OnboardingPluginInstallResult = { + cfg: OpenClawConfig; + installed: boolean; + pluginId: string; + status: OnboardingPluginInstallStatus; +}; + +function resolveRealDirectory(dir: string): string | null { + try { + const resolved = fs.realpathSync(dir); + return fs.statSync(resolved).isDirectory() ? resolved : null; + } catch { + return null; + } +} + +function resolveGitDirectoryMarker(dir: string): string | null { + const marker = path.join(dir, ".git"); + try { + const stat = fs.statSync(marker); + if (stat.isDirectory()) { + return resolveRealDirectory(marker); + } + if (!stat.isFile()) { + return null; + } + const content = fs.readFileSync(marker, "utf8").trim(); + const match = /^gitdir:\s*(.+)$/i.exec(content); + if (!match) { + return null; + } + const gitDir = match[1]?.trim(); + if (!gitDir) { + return null; + } + return resolveRealDirectory(path.isAbsolute(gitDir) ? gitDir : path.resolve(dir, gitDir)); + } catch { + return null; + } +} + +function isWithinBaseDirectory(baseDir: string, targetPath: string): boolean { + const relative = path.relative(baseDir, targetPath); + return ( + relative === "" || + (!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..") + ); +} + +function hasTrustedGitWorkspace(root: string): boolean { + const realRoot = resolveRealDirectory(root); + if (!realRoot) { + return false; + } + for (let dir = realRoot; ; dir = path.dirname(dir)) { + if (resolveGitDirectoryMarker(dir)) { + return true; + } + const parent = path.dirname(dir); + if (parent === dir) { + return false; + } + } +} + +function hasGitWorkspace(workspaceDir?: string): boolean { + const roots = [process.cwd()]; + if (workspaceDir && workspaceDir !== process.cwd()) { + roots.push(workspaceDir); + } + return roots.some((root) => hasTrustedGitWorkspace(root)); +} + +function addPluginLoadPath(cfg: OpenClawConfig, pluginPath: string): OpenClawConfig { + 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, + }, + }, + }; +} + +function resolveLocalPath(params: { + entry: OnboardingPluginInstallEntry; + workspaceDir?: string; + allowLocal: boolean; +}): string | null { + if (!params.allowLocal) { + return null; + } + const raw = params.entry.install.localPath?.trim(); + if (!raw) { + return null; + } + const candidates = new Set(); + const bases = [process.cwd()]; + if (params.workspaceDir && params.workspaceDir !== process.cwd()) { + bases.push(params.workspaceDir); + } + for (const base of bases) { + const realBase = resolveRealDirectory(base); + if (!realBase) { + continue; + } + candidates.add(path.resolve(realBase, raw)); + } + for (const candidate of candidates) { + try { + const resolved = fs.realpathSync(candidate); + if ( + !bases.some((base) => { + const realBase = resolveRealDirectory(base); + return realBase ? isWithinBaseDirectory(realBase, resolved) : false; + }) + ) { + continue; + } + if (fs.statSync(resolved).isDirectory()) { + return resolved; + } + } catch { + continue; + } + } + return null; +} + +function resolveBundledLocalPath(params: { + entry: OnboardingPluginInstallEntry; + workspaceDir?: string; +}): string | null { + const bundledSources = resolveBundledPluginSources({ workspaceDir: params.workspaceDir }); + const npmSpec = params.entry.install.npmSpec?.trim(); + if (npmSpec) { + return ( + resolveBundledInstallPlanForCatalogEntry({ + pluginId: params.entry.pluginId, + npmSpec, + findBundledSource: (lookup) => + findBundledPluginSourceInMap({ + bundled: bundledSources, + lookup, + }), + })?.bundledSource.localPath ?? null + ); + } + return ( + findBundledPluginSourceInMap({ + bundled: bundledSources, + lookup: { + kind: "pluginId", + value: params.entry.pluginId, + }, + })?.localPath ?? null + ); +} + +function resolvePinnedNpmSpecForOnboarding(install: PluginPackageInstall): string | null { + const npmSpec = install.npmSpec?.trim(); + const expectedIntegrity = install.expectedIntegrity?.trim(); + if (!npmSpec || !expectedIntegrity) { + return null; + } + const parsed = parseRegistryNpmSpec(npmSpec); + return parsed?.selectorKind === "exact-version" ? npmSpec : null; +} + +function resolveInstallDefaultChoice(params: { + cfg: OpenClawConfig; + entry: OnboardingPluginInstallEntry; + localPath?: string | null; + bundledLocalPath?: string | null; + hasNpmSpec: boolean; +}): InstallChoice { + const { cfg, entry, localPath, bundledLocalPath, hasNpmSpec } = params; + if (!hasNpmSpec) { + return localPath ? "local" : "skip"; + } + if (!localPath) { + return "npm"; + } + if (bundledLocalPath) { + return "local"; + } + const updateChannel = cfg.update?.channel; + if (updateChannel === "dev") { + return "local"; + } + if (updateChannel === "stable" || updateChannel === "beta") { + return "npm"; + } + const entryDefault = entry.install.defaultChoice; + if (entryDefault === "local") { + return "local"; + } + if (entryDefault === "npm") { + return "npm"; + } + return "local"; +} + +async function promptInstallChoice(params: { + entry: OnboardingPluginInstallEntry; + localPath?: string | null; + defaultChoice: InstallChoice; + prompter: WizardPrompter; +}): Promise { + const npmSpec = resolvePinnedNpmSpecForOnboarding(params.entry.install); + const safeLabel = sanitizeTerminalText(params.entry.label); + const safeNpmSpec = npmSpec ? sanitizeTerminalText(npmSpec) : null; + const safeLocalPath = params.localPath ? sanitizeTerminalText(params.localPath) : null; + const options: Array<{ value: InstallChoice; label: string; hint?: string }> = []; + if (safeNpmSpec) { + options.push({ + value: "npm", + label: `Download from npm (${safeNpmSpec})`, + }); + } + if (params.localPath) { + options.push({ + value: "local", + label: "Use local plugin path", + ...(safeLocalPath ? { hint: safeLocalPath } : {}), + }); + } + options.push({ value: "skip", label: "Skip for now" }); + + const initialValue = + params.defaultChoice === "local" && !params.localPath + ? npmSpec + ? "npm" + : "skip" + : params.defaultChoice; + + return await params.prompter.select({ + message: `Install ${safeLabel} plugin?`, + options, + initialValue, + }); +} + +function formatDurationLabel(timeoutMs: number): string { + if (timeoutMs % 60_000 === 0) { + const minutes = timeoutMs / 60_000; + return `${minutes} minute${minutes === 1 ? "" : "s"}`; + } + const seconds = Math.round(timeoutMs / 1000); + return `${seconds} second${seconds === 1 ? "" : "s"}`; +} + +function summarizeInstallError(message: string): string { + const cleaned = sanitizeTerminalText(message) + .replace(/^Install failed(?:\s*\([^)]*\))?\s*:?\s*/i, "") + .trim(); + if (!cleaned) { + return "Unknown install failure"; + } + return cleaned.length > 180 ? `${cleaned.slice(0, 179)}…` : cleaned; +} + +function isTimeoutError(error: unknown): boolean { + return error instanceof Error && error.message === "timeout"; +} + +async function applyPluginEnablement(params: { + cfg: OpenClawConfig; + pluginId: string; + label: string; + prompter: WizardPrompter; + runtime: RuntimeEnv; +}): Promise { + const enableResult = enablePluginInConfig(params.cfg, params.pluginId); + if (enableResult.enabled) { + return enableResult; + } + const safeLabel = sanitizeTerminalText(params.label); + const reason = enableResult.reason ?? "plugin disabled"; + await params.prompter.note(`Cannot enable ${safeLabel}: ${reason}.`, "Plugin install"); + params.runtime.error?.( + `Plugin install failed: ${sanitizeTerminalText(params.pluginId)} is disabled (${reason}).`, + ); + return enableResult; +} + +async function installPluginFromNpmSpecWithProgress(params: { + entry: OnboardingPluginInstallEntry; + npmSpec: string; + prompter: WizardPrompter; + runtime: RuntimeEnv; +}): Promise< + | { status: "timed_out" } + | { + status: "completed"; + result: Awaited>; + } +> { + const safeLabel = sanitizeTerminalText(params.entry.label); + const progress = params.prompter.progress(`Installing ${safeLabel} plugin…`); + const updateProgress = (message: string) => { + const next = sanitizeTerminalText(message).trim(); + if (!next) { + return; + } + progress.update(next); + }; + + try { + const result = await withTimeout( + installPluginFromNpmSpec({ + spec: params.npmSpec, + timeoutMs: ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS, + expectedIntegrity: params.entry.install.expectedIntegrity, + logger: { + info: updateProgress, + warn: (message) => { + updateProgress(message); + params.runtime.log?.(sanitizeTerminalText(message)); + }, + }, + }), + ONBOARDING_PLUGIN_INSTALL_WATCHDOG_TIMEOUT_MS, + ); + if (result.ok) { + progress.stop(`Installed ${safeLabel} plugin`); + } else { + progress.stop(`Install failed: ${safeLabel}`); + } + return { + status: "completed", + result, + }; + } catch (error) { + if (isTimeoutError(error)) { + progress.stop(`Install timed out: ${safeLabel}`); + return { status: "timed_out" }; + } + progress.stop(`Install failed: ${safeLabel}`); + return { + status: "completed", + result: { + ok: false, + error: error instanceof Error ? error.message : String(error), + }, + }; + } +} + +export async function ensureOnboardingPluginInstalled(params: { + cfg: OpenClawConfig; + entry: OnboardingPluginInstallEntry; + prompter: WizardPrompter; + runtime: RuntimeEnv; + workspaceDir?: string; +}): Promise { + const { entry, prompter, runtime, workspaceDir } = params; + let next = params.cfg; + const allowLocal = hasGitWorkspace(workspaceDir); + const bundledLocalPath = resolveBundledLocalPath({ entry, workspaceDir }); + const localPath = + bundledLocalPath ?? + resolveLocalPath({ + entry, + workspaceDir, + allowLocal, + }); + const npmSpec = resolvePinnedNpmSpecForOnboarding(entry.install); + const defaultChoice = resolveInstallDefaultChoice({ + cfg: next, + entry, + localPath, + bundledLocalPath, + hasNpmSpec: Boolean(npmSpec), + }); + const choice = await promptInstallChoice({ + entry, + localPath, + defaultChoice, + prompter, + }); + + if (choice === "skip") { + return { + cfg: next, + installed: false, + pluginId: entry.pluginId, + status: "skipped", + }; + } + + if (choice === "local" && localPath) { + const enableResult = await applyPluginEnablement({ + cfg: next, + pluginId: entry.pluginId, + label: entry.label, + prompter, + runtime, + }); + if (!enableResult.enabled) { + return { + cfg: enableResult.config, + installed: false, + pluginId: entry.pluginId, + status: "failed", + }; + } + next = addPluginLoadPath(enableResult.config, localPath); + return { + cfg: next, + installed: true, + pluginId: entry.pluginId, + status: "installed", + }; + } + + if (!npmSpec) { + await prompter.note( + `No npm install source is available for ${sanitizeTerminalText(entry.label)}. Returning to selection.`, + "Plugin install", + ); + runtime.error?.( + `Plugin install failed: no npm spec available for ${sanitizeTerminalText(entry.pluginId)}.`, + ); + return { + cfg: next, + installed: false, + pluginId: entry.pluginId, + status: "failed", + }; + } + + const installOutcome = await installPluginFromNpmSpecWithProgress({ + entry, + npmSpec, + prompter, + runtime, + }); + + if (installOutcome.status === "timed_out") { + await prompter.note( + [ + `Installing ${sanitizeTerminalText(npmSpec)} timed out after ${formatDurationLabel(ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS)}.`, + "Returning to selection.", + ].join("\n"), + "Plugin install", + ); + runtime.error?.( + `Plugin install timed out after ${ONBOARDING_PLUGIN_INSTALL_TIMEOUT_MS}ms: ${sanitizeTerminalText(npmSpec)}`, + ); + return { + cfg: next, + installed: false, + pluginId: entry.pluginId, + status: "timed_out", + }; + } + + const { result } = installOutcome; + + if (result.ok) { + const enableResult = await applyPluginEnablement({ + cfg: next, + pluginId: result.pluginId, + label: entry.label, + prompter, + runtime, + }); + if (!enableResult.enabled) { + return { + cfg: enableResult.config, + installed: false, + pluginId: result.pluginId, + status: "failed", + }; + } + next = enableResult.config; + next = recordPluginInstall(next, { + pluginId: result.pluginId, + source: "npm", + spec: npmSpec, + installPath: result.targetDir, + version: result.version, + ...buildNpmResolutionInstallFields(result.npmResolution), + }); + return { + cfg: next, + installed: true, + pluginId: result.pluginId, + status: "installed", + }; + } + + await prompter.note( + [ + `Failed to install ${sanitizeTerminalText(npmSpec)}: ${summarizeInstallError(result.error)}`, + "Returning to selection.", + ].join("\n"), + "Plugin install", + ); + + if (localPath) { + const fallback = await prompter.confirm({ + message: `Use local plugin path instead? (${sanitizeTerminalText(localPath)})`, + initialValue: true, + }); + if (fallback) { + const enableResult = await applyPluginEnablement({ + cfg: next, + pluginId: entry.pluginId, + label: entry.label, + prompter, + runtime, + }); + if (!enableResult.enabled) { + return { + cfg: enableResult.config, + installed: false, + pluginId: entry.pluginId, + status: "failed", + }; + } + next = addPluginLoadPath(enableResult.config, localPath); + return { + cfg: next, + installed: true, + pluginId: entry.pluginId, + status: "installed", + }; + } + } + + runtime.error?.(`Plugin install failed: ${sanitizeTerminalText(result.error)}`); + return { + cfg: next, + installed: false, + pluginId: entry.pluginId, + status: "failed", + }; +} diff --git a/src/flows/channel-setup.test.ts b/src/flows/channel-setup.test.ts index 17f24553233..ce9779a0730 100644 --- a/src/flows/channel-setup.test.ts +++ b/src/flows/channel-setup.test.ts @@ -9,6 +9,8 @@ type ChannelSetupPlugin = import("../channels/plugins/setup-wizard-types.js").Ch type ResolveChannelSetupEntries = typeof import("../commands/channel-setup/discovery.js").resolveChannelSetupEntries; type CollectChannelStatus = typeof import("./channel-setup.status.js").collectChannelStatus; +type EnsureChannelSetupPluginInstalled = + typeof import("../commands/channel-setup/plugin-install.js").ensureChannelSetupPluginInstalled; type LoadChannelSetupPluginRegistrySnapshotForChannel = typeof import("../commands/channel-setup/plugin-install.js").loadChannelSetupPluginRegistrySnapshotForChannel; type PluginRegistry = ReturnType; @@ -88,6 +90,14 @@ const listActiveChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => [] const loadChannelSetupPluginRegistrySnapshotForChannel = vi.hoisted(() => vi.fn((_params) => makePluginRegistry()), ); +const ensureChannelSetupPluginInstalled = vi.hoisted(() => + vi.fn(async ({ cfg, entry }) => ({ + cfg, + installed: true, + pluginId: entry?.pluginId, + status: "installed", + })), +); const resolveChannelSetupEntries = vi.hoisted(() => vi.fn((_params) => ({ entries: [], @@ -134,7 +144,8 @@ vi.mock("../commands/channel-setup/discovery.js", () => ({ })); vi.mock("../commands/channel-setup/plugin-install.js", () => ({ - ensureChannelSetupPluginInstalled: vi.fn(), + ensureChannelSetupPluginInstalled: (params: Parameters[0]) => + ensureChannelSetupPluginInstalled(params), loadChannelSetupPluginRegistrySnapshotForChannel: ( params: Parameters[0], ) => loadChannelSetupPluginRegistrySnapshotForChannel(params), @@ -189,6 +200,12 @@ describe("setupChannels workspace shadow exclusion", () => { listActiveChannelSetupPlugins.mockReturnValue([]); listChannelSetupPlugins.mockReturnValue([]); loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue(makePluginRegistry()); + ensureChannelSetupPluginInstalled.mockImplementation(async ({ cfg, entry }) => ({ + cfg, + installed: true, + pluginId: entry?.pluginId, + status: "installed", + })); resolveChannelSetupEntries.mockReturnValue(makeChannelSetupEntries()); collectChannelStatus.mockResolvedValue({ installedPlugins: [], @@ -466,6 +483,90 @@ describe("setupChannels workspace shadow exclusion", () => { }); }); + it("returns to quickstart selection when install-on-demand is skipped", async () => { + const configure = vi.fn(async ({ cfg }: { cfg: Record }) => ({ cfg })); + const externalChatPlugin = makeSetupPlugin({ + id: "external-chat", + label: "External Chat", + setupWizard: { + channel: "external-chat", + getStatus: vi.fn(async () => ({ + channel: "external-chat", + configured: false, + statusLines: [], + })), + configure, + } as ChannelSetupPlugin["setupWizard"], + }); + const installableCatalogEntry = makeCatalogEntry("external-chat", "External Chat", { + pluginId: "@vendor/external-chat-plugin", + }); + resolveChannelSetupEntries.mockReturnValue( + makeChannelSetupEntries({ + entries: [ + { + id: "external-chat", + meta: makeMeta("external-chat", "External Chat"), + }, + ], + installableCatalogEntries: [installableCatalogEntry], + installableCatalogById: new Map([["external-chat", installableCatalogEntry]]), + }), + ); + ensureChannelSetupPluginInstalled + .mockResolvedValueOnce({ + cfg: {}, + installed: false, + pluginId: "@vendor/external-chat-plugin", + status: "skipped", + }) + .mockResolvedValueOnce({ + cfg: {}, + installed: true, + pluginId: "@vendor/external-chat-plugin", + status: "installed", + }); + loadChannelSetupPluginRegistrySnapshotForChannel.mockReturnValue( + makePluginRegistry({ + channelSetups: [ + { + pluginId: "@vendor/external-chat-plugin", + source: "global", + enabled: true, + plugin: externalChatPlugin, + }, + ], + }), + ); + let quickstartSelectionCount = 0; + const select = vi.fn(async ({ message }: { message: string }) => { + if (message === "Select channel (QuickStart)") { + quickstartSelectionCount += 1; + return "external-chat"; + } + return "__done__"; + }); + + await setupChannels( + {} as never, + {} as never, + { + confirm: vi.fn(async () => true), + note: vi.fn(async () => undefined), + select, + } as never, + { + quickstartDefaults: true, + skipConfirm: true, + skipDmPolicyPrompt: true, + }, + ); + + expect(quickstartSelectionCount).toBe(2); + expect(ensureChannelSetupPluginInstalled).toHaveBeenCalledTimes(2); + expect(configure).toHaveBeenCalledTimes(1); + }); + it("does not load or re-enable an explicitly disabled channel when selected lazily", async () => { const setupWizard = { channel: "external-chat", diff --git a/src/flows/channel-setup.ts b/src/flows/channel-setup.ts index 08da270bf7e..be4ec804ce9 100644 --- a/src/flows/channel-setup.ts +++ b/src/flows/channel-setup.ts @@ -509,7 +509,9 @@ export async function setupChannels( await refreshStatus(channel); }; - const handleChannelChoice = async (channel: ChannelChoice) => { + const handleChannelChoice = async ( + channel: ChannelChoice, + ): Promise<"done" | "retry_selection"> => { const { catalogById, installedCatalogById } = getChannelEntries(); const catalogEntry = catalogById.get(channel); const installedCatalogEntry = installedCatalogById.get(channel); @@ -521,7 +523,7 @@ export async function setupChannels( `${channel} cannot be configured while ${deferredDisabledHint}. Enable it before setup.`, "Channel setup", ); - return; + return "done"; } if (catalogEntry) { const workspaceDir = resolveWorkspaceDir(); @@ -534,7 +536,7 @@ export async function setupChannels( }); next = result.cfg; if (!result.installed) { - return; + return "retry_selection"; } await loadScopedChannelPlugin(channel, result.pluginId ?? catalogEntry.pluginId); await refreshStatus(channel); @@ -542,13 +544,13 @@ export async function setupChannels( const plugin = await loadScopedChannelPlugin(channel, installedCatalogEntry.pluginId); if (!plugin) { await prompter.note(`${channel} plugin not available.`, "Channel setup"); - return; + return "done"; } await refreshStatus(channel); } else { const enabled = await enableBundledPluginForSetup(channel); if (!enabled) { - return; + return "done"; } } @@ -570,38 +572,44 @@ export async function setupChannels( label, }); if (!(await applyCustomSetupResult(channel, custom))) { - return; + return "done"; } - return; + return "done"; } if (configured) { await handleConfiguredChannel(channel, label); - return; + return "done"; } await configureChannel(channel); + return "done"; }; if (options?.quickstartDefaults) { - const { entries } = getChannelEntries(); - const choice = await prompter.select({ - message: "Select channel (QuickStart)", - options: [ - ...resolveChannelSetupSelectionContributions({ - entries, - statusByChannel, - resolveDisabledHint, - }).map((contribution) => contribution.option), - { - value: "__skip__", - label: "Skip for now", - hint: `You can add channels later via \`${formatCliCommand("openclaw channels add")}\``, - }, - ], - initialValue: quickstartDefault, - searchable: true, - }); - if (choice !== "__skip__") { - await handleChannelChoice(choice); + while (true) { + const { entries } = getChannelEntries(); + const choice = await prompter.select({ + message: "Select channel (QuickStart)", + options: [ + ...resolveChannelSetupSelectionContributions({ + entries, + statusByChannel, + resolveDisabledHint, + }).map((contribution) => contribution.option), + { + value: "__skip__", + label: "Skip for now", + hint: `You can add channels later via \`${formatCliCommand("openclaw channels add")}\``, + }, + ], + initialValue: quickstartDefault, + searchable: true, + }); + if (choice === "__skip__") { + break; + } + if ((await handleChannelChoice(choice)) === "done") { + break; + } } } else { const doneValue = "__done__" as const; diff --git a/src/flows/provider-flow.test.ts b/src/flows/provider-flow.test.ts index a2ba0d9bb56..eed6bce0176 100644 --- a/src/flows/provider-flow.test.ts +++ b/src/flows/provider-flow.test.ts @@ -1,77 +1,261 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { - resolveProviderSetupFlowContributions, - resolveProviderModelPickerFlowContributions, -} from "./provider-flow.js"; -const resolveProviderWizardOptions = vi.hoisted(() => vi.fn(() => [])); -const resolveProviderModelPickerEntries = vi.hoisted(() => vi.fn(() => [])); -const resolvePluginProviders = vi.hoisted(() => vi.fn(() => [])); +type ResolveProviderInstallCatalogEntries = + typeof import("../plugins/provider-install-catalog.js").resolveProviderInstallCatalogEntries; +type ResolveProviderWizardOptions = + typeof import("../plugins/provider-wizard.js").resolveProviderWizardOptions; +type ResolveProviderModelPickerEntries = + typeof import("../plugins/provider-wizard.js").resolveProviderModelPickerEntries; +type ResolvePluginProviders = + typeof import("../plugins/providers.runtime.js").resolvePluginProviders; +const resolveProviderInstallCatalogEntries = vi.hoisted(() => + vi.fn(() => []), +); +vi.mock("../plugins/provider-install-catalog.js", () => ({ + resolveProviderInstallCatalogEntries, +})); + +const resolveProviderWizardOptions = vi.hoisted(() => + vi.fn(() => []), +); +const resolveProviderModelPickerEntries = vi.hoisted(() => + vi.fn(() => []), +); vi.mock("../plugins/provider-wizard.js", () => ({ resolveProviderWizardOptions, resolveProviderModelPickerEntries, })); +const resolvePluginProviders = vi.hoisted(() => vi.fn(() => [])); vi.mock("../plugins/providers.runtime.js", () => ({ resolvePluginProviders, })); -describe("provider flow", () => { +import { + resolveProviderModelPickerFlowContributions, + resolveProviderSetupFlowContributions, +} from "./provider-flow.js"; + +describe("provider flow install catalog contributions", () => { beforeEach(() => { vi.clearAllMocks(); }); - it("uses setup mode when resolving docs for setup contributions", () => { + it("surfaces install-catalog provider choices when runtime setup options are absent", () => { + resolveProviderInstallCatalogEntries.mockReturnValue([ + { + pluginId: "vllm", + providerId: "vllm", + methodId: "server", + choiceId: "vllm", + choiceLabel: "vLLM", + choiceHint: "Local server", + groupId: "vllm", + groupLabel: "vLLM", + onboardingScopes: ["text-inference"], + label: "vLLM", + origin: "bundled", + install: { + npmSpec: "@openclaw/vllm", + }, + }, + ]); + + expect(resolveProviderSetupFlowContributions()).toEqual([ + { + id: "provider:setup:vllm", + kind: "provider", + surface: "setup", + providerId: "vllm", + pluginId: "vllm", + option: { + value: "vllm", + label: "vLLM", + hint: "Local server", + group: { + id: "vllm", + label: "vLLM", + }, + }, + onboardingScopes: ["text-inference"], + source: "install-catalog", + }, + ]); + expect(resolveProviderInstallCatalogEntries).toHaveBeenCalledWith( + expect.objectContaining({ + includeUntrustedWorkspacePlugins: false, + }), + ); + }); + + it("adds a fallback group when install-catalog entries omit group metadata", () => { + resolveProviderInstallCatalogEntries.mockReturnValue([ + { + pluginId: "demo-provider", + providerId: "demo-provider", + methodId: "api-key", + choiceId: "demo-provider-api-key", + choiceLabel: "Demo Provider API key", + label: "Demo Provider API key", + origin: "global", + install: { + npmSpec: "@vendor/demo-provider", + }, + }, + ]); + + expect(resolveProviderSetupFlowContributions()).toEqual([ + { + id: "provider:setup:demo-provider-api-key", + kind: "provider", + surface: "setup", + providerId: "demo-provider", + pluginId: "demo-provider", + option: { + value: "demo-provider-api-key", + label: "Demo Provider API key", + group: { + id: "demo-provider", + label: "Demo Provider API key", + }, + }, + source: "install-catalog", + }, + ]); + }); + + it("hides install-catalog choices that cannot be enabled", () => { + resolveProviderInstallCatalogEntries.mockReturnValue([ + { + pluginId: "blocked-provider", + providerId: "blocked-provider", + methodId: "api-key", + choiceId: "blocked-provider-api-key", + choiceLabel: "Blocked Provider API key", + label: "Blocked Provider", + origin: "global", + install: { + npmSpec: "@vendor/blocked-provider", + }, + }, + ]); + + expect( + resolveProviderSetupFlowContributions({ + config: { + plugins: { + enabled: false, + }, + }, + }), + ).toEqual([]); + }); + + it("hides install-catalog choices outside a configured plugin allowlist", () => { + resolveProviderInstallCatalogEntries.mockReturnValue([ + { + pluginId: "blocked-provider", + providerId: "blocked-provider", + methodId: "api-key", + choiceId: "blocked-provider-api-key", + choiceLabel: "Blocked Provider API key", + label: "Blocked Provider", + origin: "global", + install: { + npmSpec: "@vendor/blocked-provider@1.2.3", + expectedIntegrity: "sha512-blocked", + }, + }, + ]); + + expect( + resolveProviderSetupFlowContributions({ + config: { + plugins: { + allow: ["openai"], + }, + }, + }), + ).toEqual([]); + }); + + it("prefers runtime setup contributions over duplicate install-catalog entries", () => { resolveProviderWizardOptions.mockReturnValue([ { - value: "provider-plugin:sglang:custom", - label: "SGLang", - groupId: "sglang", - groupLabel: "SGLang", + value: "openai-api-key", + label: "OpenAI API key", + groupId: "openai", + groupLabel: "OpenAI", }, - ] as never); - resolvePluginProviders.mockReturnValue([ - { id: "sglang", docsPath: "/providers/sglang" }, - ] as never); + ]); + resolveProviderInstallCatalogEntries.mockReturnValue([ + { + pluginId: "openai", + providerId: "openai", + methodId: "api-key", + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + groupId: "openai", + groupLabel: "OpenAI", + label: "OpenAI", + origin: "bundled", + install: { + npmSpec: "@openclaw/openai", + }, + }, + ]); - const contributions = resolveProviderSetupFlowContributions({ - config: {}, - workspaceDir: "/tmp/workspace", - env: process.env, - }); - - expect(resolvePluginProviders).toHaveBeenCalledWith({ - config: {}, - workspaceDir: "/tmp/workspace", - env: process.env, - mode: "setup", - }); - expect(contributions[0]?.option.docs).toEqual({ path: "/providers/sglang" }); - expect(contributions[0]?.source).toBe("runtime"); + expect(resolveProviderSetupFlowContributions()).toEqual([ + { + id: "provider:setup:openai-api-key", + kind: "provider", + surface: "setup", + providerId: "openai", + option: { + value: "openai-api-key", + label: "OpenAI API key", + group: { + id: "openai", + label: "OpenAI", + }, + }, + source: "runtime", + }, + ]); }); - it("uses setup mode when resolving docs for runtime model-picker contributions", () => { + it("keeps docs attached to runtime model-picker contributions", () => { + resolvePluginProviders.mockReturnValue([ + { + id: "openai", + label: "OpenAI", + docsPath: "/providers/openai", + auth: [], + }, + ]); resolveProviderModelPickerEntries.mockReturnValue([ { - value: "provider-plugin:vllm:custom", - label: "vLLM", + value: "provider-plugin:openai:gpt-5.4", + label: "GPT-5.4", }, - ] as never); - resolvePluginProviders.mockReturnValue([{ id: "vllm", docsPath: "/providers/vllm" }] as never); + ]); - const contributions = resolveProviderModelPickerFlowContributions({ - config: {}, - workspaceDir: "/tmp/workspace", - env: process.env, - }); - - expect(resolvePluginProviders).toHaveBeenCalledWith({ - config: {}, - workspaceDir: "/tmp/workspace", - env: process.env, - mode: "setup", - }); - expect(contributions[0]?.option.docs).toEqual({ path: "/providers/vllm" }); + expect(resolveProviderModelPickerFlowContributions()).toEqual([ + { + id: "provider:model-picker:provider-plugin:openai:gpt-5.4", + kind: "provider", + surface: "model-picker", + providerId: "openai", + option: { + value: "provider-plugin:openai:gpt-5.4", + label: "GPT-5.4", + docs: { + path: "/providers/openai", + }, + }, + source: "runtime", + }, + ]); }); }); diff --git a/src/flows/provider-flow.ts b/src/flows/provider-flow.ts index bb69b5816ab..0f16815b1a4 100644 --- a/src/flows/provider-flow.ts +++ b/src/flows/provider-flow.ts @@ -1,4 +1,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js"; +import { resolveProviderInstallCatalogEntries } from "../plugins/provider-install-catalog.js"; import { resolveProviderModelPickerEntries, resolveProviderWizardOptions, @@ -26,7 +28,7 @@ export type ProviderSetupFlowContribution = FlowContribution & { pluginId?: string; option: ProviderSetupFlowOption; onboardingScopes?: ProviderFlowScope[]; - source: "runtime"; + source: "runtime" | "install-catalog"; }; export type ProviderModelPickerFlowContribution = FlowContribution & { @@ -63,6 +65,62 @@ function resolveProviderDocsById(params?: { ); } +function resolveInstallCatalogProviderSetupFlowContributions(params?: { + config?: OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + scope?: ProviderFlowScope; +}): ProviderSetupFlowContribution[] { + const scope = params?.scope ?? DEFAULT_PROVIDER_FLOW_SCOPE; + const normalizedPluginsConfig = normalizePluginsConfig(params?.config?.plugins); + return resolveProviderInstallCatalogEntries({ + ...params, + includeUntrustedWorkspacePlugins: false, + }) + .filter( + (entry) => + includesProviderFlowScope(entry.onboardingScopes, scope) && + resolveEffectiveEnableState({ + id: entry.pluginId, + origin: entry.origin, + config: normalizedPluginsConfig, + rootConfig: params?.config, + enabledByDefault: true, + }).enabled, + ) + .map((entry) => { + const groupId = entry.groupId ?? entry.providerId; + const groupLabel = entry.groupLabel ?? entry.label; + return Object.assign( + { + id: `provider:setup:${entry.choiceId}`, + kind: `provider` as const, + surface: `setup` as const, + providerId: entry.providerId, + pluginId: entry.pluginId, + option: { + value: entry.choiceId, + label: entry.choiceLabel, + ...(entry.choiceHint ? { hint: entry.choiceHint } : {}), + ...(entry.assistantPriority !== undefined + ? { assistantPriority: entry.assistantPriority } + : {}), + ...(entry.assistantVisibility + ? { assistantVisibility: entry.assistantVisibility } + : {}), + group: { + id: groupId, + label: groupLabel, + ...(entry.groupHint ? { hint: entry.groupHint } : {}), + }, + }, + }, + entry.onboardingScopes ? { onboardingScopes: [...entry.onboardingScopes] } : {}, + { source: `install-catalog` as const }, + ); + }); +} + export function resolveProviderSetupFlowContributions(params?: { config?: OpenClawConfig; workspaceDir?: string; @@ -71,41 +129,47 @@ export function resolveProviderSetupFlowContributions(params?: { }): ProviderSetupFlowContribution[] { const scope = params?.scope ?? DEFAULT_PROVIDER_FLOW_SCOPE; const docsByProvider = resolveProviderDocsById(params ?? {}); - return sortFlowContributionsByLabel( - resolveProviderWizardOptions(params ?? {}) - .filter((option) => includesProviderFlowScope(option.onboardingScopes, scope)) - .map((option) => - Object.assign( - { - id: `provider:setup:${option.value}`, - kind: `provider` as const, - surface: `setup` as const, - providerId: option.groupId, - option: { - value: option.value, - label: option.label, - ...(option.hint ? { hint: option.hint } : {}), - ...(option.assistantPriority !== undefined - ? { assistantPriority: option.assistantPriority } - : {}), - ...(option.assistantVisibility - ? { assistantVisibility: option.assistantVisibility } - : {}), - group: { - id: option.groupId, - label: option.groupLabel, - ...(option.groupHint ? { hint: option.groupHint } : {}), - }, - ...(docsByProvider.get(option.groupId) - ? { docs: { path: docsByProvider.get(option.groupId)! } } - : {}), + const runtimeContributions = resolveProviderWizardOptions(params ?? {}) + .filter((option) => includesProviderFlowScope(option.onboardingScopes, scope)) + .map((option) => + Object.assign( + { + id: `provider:setup:${option.value}`, + kind: `provider` as const, + surface: `setup` as const, + providerId: option.groupId, + option: { + value: option.value, + label: option.label, + ...(option.hint ? { hint: option.hint } : {}), + ...(option.assistantPriority !== undefined + ? { assistantPriority: option.assistantPriority } + : {}), + ...(option.assistantVisibility + ? { assistantVisibility: option.assistantVisibility } + : {}), + group: { + id: option.groupId, + label: option.groupLabel, + ...(option.groupHint ? { hint: option.groupHint } : {}), }, + ...(docsByProvider.get(option.groupId) + ? { docs: { path: docsByProvider.get(option.groupId)! } } + : {}), }, - option.onboardingScopes ? { onboardingScopes: [...option.onboardingScopes] } : {}, - { source: `runtime` as const }, - ), + }, + option.onboardingScopes ? { onboardingScopes: [...option.onboardingScopes] } : {}, + { source: `runtime` as const }, ), + ); + const seenOptionValues = new Set( + runtimeContributions.map((contribution) => contribution.option.value), ); + const installCatalogContributions = resolveInstallCatalogProviderSetupFlowContributions({ + ...params, + scope, + }).filter((contribution) => !seenOptionValues.has(contribution.option.value)); + return sortFlowContributionsByLabel([...runtimeContributions, ...installCatalogContributions]); } export function resolveProviderModelPickerFlowEntries(params?: { diff --git a/src/infra/npm-integrity.test.ts b/src/infra/npm-integrity.test.ts index 6e1664b840d..2f9b370ace7 100644 --- a/src/infra/npm-integrity.test.ts +++ b/src/infra/npm-integrity.test.ts @@ -86,7 +86,7 @@ describe("resolveNpmIntegrityDrift", () => { }); }); - it("warns by default when no callback is provided", async () => { + it("warns and aborts by default when no callback is provided", async () => { const warn = vi.fn(); const result = await resolveNpmIntegrityDrift({ spec: "@openclaw/test@1.0.0", @@ -100,7 +100,7 @@ describe("resolveNpmIntegrityDrift", () => { }); expect(warn).toHaveBeenCalledWith({ spec: "@openclaw/test@1.0.0" }); - expect(result.proceed).toBe(true); + expect(result.proceed).toBe(false); }); it("formats default warning and abort error messages", async () => { @@ -115,7 +115,9 @@ describe("resolveNpmIntegrityDrift", () => { }, warn, }); - expect(warningResult.error).toBeUndefined(); + expect(warningResult.error).toBe( + "aborted: npm package integrity drift detected for @openclaw/test@1.0.0", + ); expect(warn).toHaveBeenCalledWith( "Integrity drift detected for @openclaw/test@1.0.0: expected sha512-old, got sha512-new", ); @@ -138,7 +140,7 @@ describe("resolveNpmIntegrityDrift", () => { it("falls back to the original spec when resolvedSpec is missing", async () => { const warn = vi.fn(); - await resolveNpmIntegrityDriftWithDefaultMessage({ + const result = await resolveNpmIntegrityDriftWithDefaultMessage({ spec: "@openclaw/test@1.0.0", expectedIntegrity: "sha512-old", resolution: { @@ -148,6 +150,9 @@ describe("resolveNpmIntegrityDrift", () => { warn, }); + expect(result.error).toBe( + "aborted: npm package integrity drift detected for @openclaw/test@1.0.0", + ); expect(warn).toHaveBeenCalledWith( "Integrity drift detected for @openclaw/test@1.0.0: expected sha512-old, got sha512-new", ); diff --git a/src/infra/npm-integrity.ts b/src/infra/npm-integrity.ts index b26c9d0a19e..fefa4f6c231 100644 --- a/src/infra/npm-integrity.ts +++ b/src/infra/npm-integrity.ts @@ -27,19 +27,26 @@ export type ResolveNpmIntegrityDriftResult = { payload?: TPayload; }; +function normalizeIntegrity(value: string | undefined): string | undefined { + const normalized = value?.trim(); + return normalized ? normalized : undefined; +} + export async function resolveNpmIntegrityDrift( params: ResolveNpmIntegrityDriftParams, ): Promise> { - if (!params.expectedIntegrity || !params.resolution.integrity) { + const expectedIntegrity = normalizeIntegrity(params.expectedIntegrity); + const actualIntegrity = normalizeIntegrity(params.resolution.integrity); + if (!expectedIntegrity || !actualIntegrity) { return { proceed: true }; } - if (params.expectedIntegrity === params.resolution.integrity) { + if (expectedIntegrity === actualIntegrity) { return { proceed: true }; } const integrityDrift: NpmIntegrityDrift = { - expectedIntegrity: params.expectedIntegrity, - actualIntegrity: params.resolution.integrity, + expectedIntegrity, + actualIntegrity, }; const payload = params.createPayload({ spec: params.spec, @@ -48,7 +55,7 @@ export async function resolveNpmIntegrityDrift( resolution: params.resolution, }); - let proceed = true; + let proceed = false; if (params.onIntegrityDrift) { proceed = await params.onIntegrityDrift(payload); } else { diff --git a/src/infra/npm-pack-install.test.ts b/src/infra/npm-pack-install.test.ts index e0e5f7e2c07..b80a6ce30ea 100644 --- a/src/infra/npm-pack-install.test.ts +++ b/src/infra/npm-pack-install.test.ts @@ -163,7 +163,7 @@ describe("installFromNpmSpecArchive", () => { expect(installFromArchive).not.toHaveBeenCalled(); }); - it("warns and proceeds on drift when no callback is configured", async () => { + it("warns and aborts on drift when no callback is configured", async () => { mockPackedSuccess({ integrity: "sha512-new" }); const warn = vi.fn(); const installFromArchive = vi.fn(async () => ({ ok: true as const, id: "plugin-1" })); @@ -174,14 +174,14 @@ describe("installFromNpmSpecArchive", () => { installFromArchive, }); - const okResult = expectWrappedOkResult(result, { ok: true, id: "plugin-1" }); - expect(okResult.integrityDrift).toEqual({ - expectedIntegrity: "sha512-old", - actualIntegrity: "sha512-new", + expect(result).toEqual({ + ok: false, + error: "aborted: npm package integrity drift detected for @openclaw/test@1.0.0", }); expect(warn).toHaveBeenCalledWith( "Integrity drift detected for @openclaw/test@1.0.0: expected sha512-old, got sha512-new", ); + expect(installFromArchive).not.toHaveBeenCalled(); }); it("returns installer failures to callers for domain-specific handling", async () => { diff --git a/src/plugins/enable.test.ts b/src/plugins/enable.test.ts index c66c724c61d..5c6849c8857 100644 --- a/src/plugins/enable.test.ts +++ b/src/plugins/enable.test.ts @@ -49,16 +49,31 @@ describe("enablePluginInConfig", () => { }, }, { - name: "adds plugin to allowlist when allowlist is configured", + name: "refuses enable when plugin is outside configured allowlist", cfg: { plugins: { allow: ["memory-core"], }, } as OpenClawConfig, pluginId: "google", + expectedEnabled: false, + assert: (result: ReturnType) => { + expect(result.reason).toBe("blocked by allowlist"); + expectEnabledAllowlist(result, ["memory-core"]); + }, + }, + { + name: "enables plugin already present in configured allowlist", + cfg: { + plugins: { + allow: ["google"], + }, + } as OpenClawConfig, + pluginId: "google", expectedEnabled: true, assert: (result: ReturnType) => { - expectEnabledAllowlist(result, ["memory-core", "google"]); + expect(result.config.plugins?.entries?.google?.enabled).toBe(true); + expectEnabledAllowlist(result, ["google"]); }, }, { @@ -82,16 +97,31 @@ describe("enablePluginInConfig", () => { assert: expectBuiltInChannelEnabled, }, { - name: "adds built-in channel id to allowlist when allowlist is configured", + name: "refuses built-in channel enable when channel is outside configured allowlist", cfg: { plugins: { allow: ["memory-core"], }, } as OpenClawConfig, pluginId: "telegram", + expectedEnabled: false, + assert: (result: ReturnType) => { + expect(result.reason).toBe("blocked by allowlist"); + expect(result.config.plugins?.allow).toEqual(["memory-core"]); + expect(result.config.channels?.telegram?.enabled).toBeUndefined(); + }, + }, + { + name: "enables built-in channel already present in configured allowlist", + cfg: { + plugins: { + allow: ["telegram"], + }, + } as OpenClawConfig, + pluginId: "telegram", expectedEnabled: true, assert: (result: ReturnType) => { - expectBuiltInChannelEnabledWithAllowlist(result, ["memory-core", "telegram"]); + expectBuiltInChannelEnabledWithAllowlist(result, ["telegram"]); }, }, { diff --git a/src/plugins/enable.ts b/src/plugins/enable.ts index ac1fc0e7d81..6de8ce0c5b0 100644 --- a/src/plugins/enable.ts +++ b/src/plugins/enable.ts @@ -1,5 +1,4 @@ import { normalizeChatChannelId } from "../channels/ids.js"; -import { ensurePluginAllowlisted } from "../config/plugins-allowlist.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { setPluginEnabledInConfig } from "./toggle-config.js"; @@ -18,7 +17,14 @@ export function enablePluginInConfig(cfg: OpenClawConfig, pluginId: string): Plu if (cfg.plugins?.deny?.includes(pluginId) || cfg.plugins?.deny?.includes(resolvedId)) { return { config: cfg, enabled: false, reason: "blocked by denylist" }; } - let next = setPluginEnabledInConfig(cfg, resolvedId, true); - next = ensurePluginAllowlisted(next, resolvedId); - return { config: next, enabled: true }; + const allow = cfg.plugins?.allow; + if ( + Array.isArray(allow) && + allow.length > 0 && + !allow.includes(pluginId) && + !allow.includes(resolvedId) + ) { + return { config: cfg, enabled: false, reason: "blocked by allowlist" }; + } + return { config: setPluginEnabledInConfig(cfg, resolvedId, true), enabled: true }; } diff --git a/src/plugins/manifest.json5-tolerance.test.ts b/src/plugins/manifest.json5-tolerance.test.ts index 3112a231306..e055fd72bf9 100644 --- a/src/plugins/manifest.json5-tolerance.test.ts +++ b/src/plugins/manifest.json5-tolerance.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; import { afterEach, describe, expect, it } from "vitest"; -import { loadPluginManifest } from "./manifest.js"; +import { loadPluginManifest, MAX_PLUGIN_MANIFEST_BYTES } from "./manifest.js"; import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; const tempDirs: string[] = []; @@ -169,4 +169,24 @@ describe("loadPluginManifest JSON5 tolerance", () => { expect(result.error).toContain("plugin manifest must be an object"); } }); + + it("rejects oversized manifests before parsing", () => { + const dir = makeTempDir(); + fs.writeFileSync( + path.join(dir, "openclaw.plugin.json"), + JSON.stringify({ + id: "too-large", + configSchema: { type: "object" }, + padding: "x".repeat(MAX_PLUGIN_MANIFEST_BYTES), + }), + "utf-8", + ); + + const result = loadPluginManifest(dir, false); + + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toContain("unsafe plugin manifest path"); + } + }); }); diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index f451e6a2c02..129b3bf258c 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -17,6 +17,7 @@ import type { PluginKind } from "./plugin-kind.types.js"; export const PLUGIN_MANIFEST_FILENAME = "openclaw.plugin.json"; export const PLUGIN_MANIFEST_FILENAMES = [PLUGIN_MANIFEST_FILENAME] as const; +export const MAX_PLUGIN_MANIFEST_BYTES = 256 * 1024; export type PluginManifestChannelConfig = { schema: JsonSchemaObject; @@ -806,6 +807,7 @@ export function loadPluginManifest( absolutePath: manifestPath, rootPath: rootDir, boundaryLabel: "plugin root", + maxBytes: MAX_PLUGIN_MANIFEST_BYTES, rejectHardlinks, }); if (!opened.ok) { @@ -982,6 +984,7 @@ export type PluginPackageInstall = { localPath?: string; defaultChoice?: "npm" | "local"; minHostVersion?: string; + expectedIntegrity?: string; allowInvalidConfigRecovery?: boolean; }; diff --git a/src/plugins/provider-auth-choice.ts b/src/plugins/provider-auth-choice.ts index afe07f98b7b..f2bdb5dc722 100644 --- a/src/plugins/provider-auth-choice.ts +++ b/src/plugins/provider-auth-choice.ts @@ -6,9 +6,12 @@ import { } from "../agents/agent-scope.js"; import { upsertAuthProfile } from "../agents/auth-profiles.js"; import { resolveDefaultAgentWorkspaceDir } from "../agents/workspace.js"; +import { ensureOnboardingPluginInstalled } from "../commands/onboarding-plugin-install.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { RuntimeEnv } from "../runtime.js"; +import { sanitizeTerminalText } from "../terminal/safe-text.js"; import type { WizardPrompter } from "../wizard/prompts.js"; +import { clearPluginDiscoveryCache } from "./discovery.js"; import { enablePluginInConfig } from "./enable.js"; import { applyProviderAuthConfigPatch, @@ -17,6 +20,7 @@ import { resolveProviderMatch, } from "./provider-auth-choice-helpers.js"; import { applyAuthProfileConfig } from "./provider-auth-helpers.js"; +import { resolveProviderInstallCatalogEntry } from "./provider-install-catalog.js"; import { createVpsAwareOAuthHandlers } from "./provider-oauth-flow.js"; import { isRemoteEnvironment, openUrl } from "./setup-browser.js"; import type { ProviderAuthMethod, ProviderAuthOptionBag } from "./types.js"; @@ -36,6 +40,7 @@ export type ApplyProviderAuthChoiceParams = { export type ApplyProviderAuthChoiceResult = { config: OpenClawConfig; agentModelOverride?: string; + retrySelection?: boolean; }; export type PluginProviderAuthChoiceOptions = { @@ -189,24 +194,76 @@ export async function applyAuthChoiceLoadedPluginProvider( const agentId = params.agentId ?? resolveDefaultAgentId(params.config); const workspaceDir = resolveAgentWorkspaceDir(params.config, agentId) ?? resolveDefaultAgentWorkspaceDir(); + let nextConfig = params.config; + let enabledConfig = params.config; const { resolvePluginProviders, resolveProviderPluginChoice, runProviderModelSelectedHook } = await loadPluginProviderRuntime(); - const providers = resolvePluginProviders({ - config: params.config, + const installCatalogEntry = resolveProviderInstallCatalogEntry(params.authChoice, { + config: nextConfig, + workspaceDir, + env: params.env, + includeUntrustedWorkspacePlugins: false, + }); + if (installCatalogEntry) { + const enableResult = enablePluginInConfig(nextConfig, installCatalogEntry.pluginId); + if (!enableResult.enabled) { + const safeLabel = sanitizeTerminalText(installCatalogEntry.label); + await params.prompter.note( + `${safeLabel} plugin is disabled (${enableResult.reason ?? "blocked"}).`, + safeLabel, + ); + return { config: nextConfig }; + } + enabledConfig = enableResult.config; + } + + let providers = resolvePluginProviders({ + config: enabledConfig, workspaceDir, env: params.env, mode: "setup", }); - const resolved = resolveProviderPluginChoice({ + let resolved = resolveProviderPluginChoice({ providers, choice: params.authChoice, }); + if (!resolved && installCatalogEntry) { + const installResult = await ensureOnboardingPluginInstalled({ + cfg: nextConfig, + entry: { + pluginId: installCatalogEntry.pluginId, + label: installCatalogEntry.label, + install: installCatalogEntry.install, + }, + prompter: params.prompter, + runtime: params.runtime, + workspaceDir, + }); + if (!installResult.installed) { + return { config: installResult.cfg, retrySelection: true }; + } + nextConfig = installResult.cfg; + clearPluginDiscoveryCache(); + providers = resolvePluginProviders({ + config: nextConfig, + workspaceDir, + env: params.env, + mode: "setup", + }); + resolved = resolveProviderPluginChoice({ + providers, + choice: params.authChoice, + }); + } if (!resolved) { - return null; + return nextConfig === params.config ? null : { config: nextConfig, retrySelection: true }; + } + if (nextConfig === params.config && enabledConfig !== params.config) { + nextConfig = enabledConfig; } const applied = await runProviderPluginAuthMethod({ - config: params.config, + config: nextConfig, env: params.env, runtime: params.runtime, prompter: params.prompter, @@ -219,7 +276,7 @@ export async function applyAuthChoiceLoadedPluginProvider( opts: params.opts, }); - let nextConfig = applied.config; + nextConfig = applied.config; let agentModelOverride: string | undefined; if (applied.defaultModel) { if (params.setDefaultModel) { diff --git a/src/plugins/provider-install-catalog.test.ts b/src/plugins/provider-install-catalog.test.ts new file mode 100644 index 00000000000..c7c4ed1d997 --- /dev/null +++ b/src/plugins/provider-install-catalog.test.ts @@ -0,0 +1,325 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type DiscoverOpenClawPlugins = typeof import("./discovery.js").discoverOpenClawPlugins; +type LoadPluginManifest = typeof import("./manifest.js").loadPluginManifest; +type ResolveManifestProviderAuthChoices = + typeof import("./provider-auth-choices.js").resolveManifestProviderAuthChoices; + +const discoverOpenClawPlugins = vi.hoisted(() => + vi.fn(() => ({ candidates: [], diagnostics: [] })), +); +vi.mock("./discovery.js", () => ({ + discoverOpenClawPlugins, +})); + +const loadPluginManifest = vi.hoisted(() => vi.fn()); +vi.mock("./manifest.js", async () => { + const actual = await vi.importActual("./manifest.js"); + return { + ...actual, + loadPluginManifest, + }; +}); + +const resolveManifestProviderAuthChoices = vi.hoisted(() => + vi.fn(() => []), +); +vi.mock("./provider-auth-choices.js", () => ({ + resolveManifestProviderAuthChoices, +})); + +import { + resolveProviderInstallCatalogEntries, + resolveProviderInstallCatalogEntry, +} from "./provider-install-catalog.js"; + +describe("provider install catalog", () => { + beforeEach(() => { + vi.clearAllMocks(); + discoverOpenClawPlugins.mockReturnValue({ + candidates: [], + diagnostics: [], + }); + resolveManifestProviderAuthChoices.mockReturnValue([]); + }); + + it("merges manifest auth-choice metadata with discovery install metadata", () => { + discoverOpenClawPlugins.mockReturnValue({ + candidates: [ + { + idHint: "openai", + origin: "bundled", + rootDir: "/repo/extensions/openai", + source: "/repo/extensions/openai/index.ts", + workspaceDir: "/repo", + packageName: "@openclaw/openai", + packageDir: "/repo/extensions/openai", + packageManifest: { + install: { + npmSpec: "@openclaw/openai@1.2.3", + defaultChoice: "npm", + expectedIntegrity: "sha512-openai", + }, + }, + }, + ], + diagnostics: [], + }); + loadPluginManifest.mockReturnValue({ + ok: true, + manifestPath: "/repo/extensions/openai/openclaw.plugin.json", + manifest: { + id: "openai", + configSchema: { + type: "object", + }, + }, + }); + resolveManifestProviderAuthChoices.mockReturnValue([ + { + pluginId: "openai", + providerId: "openai", + methodId: "api-key", + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + groupId: "openai", + groupLabel: "OpenAI", + }, + ]); + + expect(resolveProviderInstallCatalogEntries()).toEqual([ + { + pluginId: "openai", + providerId: "openai", + methodId: "api-key", + choiceId: "openai-api-key", + choiceLabel: "OpenAI API key", + groupId: "openai", + groupLabel: "OpenAI", + label: "OpenAI", + origin: "bundled", + install: { + npmSpec: "@openclaw/openai@1.2.3", + localPath: "extensions/openai", + defaultChoice: "npm", + expectedIntegrity: "sha512-openai", + }, + }, + ]); + }); + + it("falls back to workspace-relative local path when install metadata is sparse", () => { + discoverOpenClawPlugins.mockReturnValue({ + candidates: [ + { + idHint: "demo-provider", + origin: "workspace", + rootDir: "/repo/extensions/demo-provider", + source: "/repo/extensions/demo-provider/index.ts", + workspaceDir: "/repo", + packageName: "@vendor/demo-provider", + packageDir: "/repo/extensions/demo-provider", + packageManifest: {}, + }, + ], + diagnostics: [], + }); + loadPluginManifest.mockReturnValue({ + ok: true, + manifestPath: "/repo/extensions/demo-provider/openclaw.plugin.json", + manifest: { + id: "demo-provider", + configSchema: { + type: "object", + }, + }, + }); + resolveManifestProviderAuthChoices.mockReturnValue([ + { + pluginId: "demo-provider", + providerId: "demo-provider", + methodId: "api-key", + choiceId: "demo-provider-api-key", + choiceLabel: "Demo Provider API key", + }, + ]); + + expect(resolveProviderInstallCatalogEntries()).toEqual([ + { + pluginId: "demo-provider", + providerId: "demo-provider", + methodId: "api-key", + choiceId: "demo-provider-api-key", + choiceLabel: "Demo Provider API key", + label: "Demo Provider API key", + origin: "workspace", + install: { + localPath: "extensions/demo-provider", + defaultChoice: "local", + }, + }, + ]); + }); + + it("resolves one installable auth choice by id", () => { + discoverOpenClawPlugins.mockReturnValue({ + candidates: [ + { + idHint: "vllm", + origin: "config", + rootDir: "/Users/test/.openclaw/extensions/vllm", + source: "/Users/test/.openclaw/extensions/vllm/index.js", + packageName: "@openclaw/vllm", + packageDir: "/Users/test/.openclaw/extensions/vllm", + packageManifest: { + install: { + npmSpec: "@openclaw/vllm@2.0.0", + expectedIntegrity: "sha512-vllm", + }, + }, + }, + ], + diagnostics: [], + }); + loadPluginManifest.mockReturnValue({ + ok: true, + manifestPath: "/Users/test/.openclaw/extensions/vllm/openclaw.plugin.json", + manifest: { + id: "vllm", + configSchema: { + type: "object", + }, + }, + }); + resolveManifestProviderAuthChoices.mockReturnValue([ + { + pluginId: "vllm", + providerId: "vllm", + methodId: "server", + choiceId: "vllm", + choiceLabel: "vLLM", + groupLabel: "vLLM", + }, + ]); + + expect(resolveProviderInstallCatalogEntry("vllm")).toEqual({ + pluginId: "vllm", + providerId: "vllm", + methodId: "server", + choiceId: "vllm", + choiceLabel: "vLLM", + groupLabel: "vLLM", + label: "vLLM", + origin: "config", + install: { + npmSpec: "@openclaw/vllm@2.0.0", + expectedIntegrity: "sha512-vllm", + defaultChoice: "npm", + }, + }); + }); + + it("does not expose npm install specs from untrusted package metadata", () => { + discoverOpenClawPlugins.mockReturnValue({ + candidates: [ + { + idHint: "demo-provider", + origin: "global", + rootDir: "/Users/test/.openclaw/extensions/demo-provider", + source: "/Users/test/.openclaw/extensions/demo-provider/index.js", + packageName: "@vendor/demo-provider", + packageDir: "/Users/test/.openclaw/extensions/demo-provider", + packageManifest: { + install: { + npmSpec: "@vendor/demo-provider@1.2.3", + expectedIntegrity: "sha512-demo", + }, + }, + }, + ], + diagnostics: [], + }); + loadPluginManifest.mockReturnValue({ + ok: true, + manifestPath: "/Users/test/.openclaw/extensions/demo-provider/openclaw.plugin.json", + manifest: { + id: "demo-provider", + configSchema: { + type: "object", + }, + }, + }); + resolveManifestProviderAuthChoices.mockReturnValue([ + { + pluginId: "demo-provider", + providerId: "demo-provider", + methodId: "api-key", + choiceId: "demo-provider-api-key", + choiceLabel: "Demo Provider API key", + }, + ]); + + expect(resolveProviderInstallCatalogEntries()).toEqual([]); + }); + + it("skips untrusted workspace install candidates when requested", () => { + discoverOpenClawPlugins.mockReturnValue({ + candidates: [ + { + idHint: "demo-provider", + origin: "workspace", + rootDir: "/repo/extensions/demo-provider", + source: "/repo/extensions/demo-provider/index.ts", + workspaceDir: "/repo", + packageName: "@vendor/demo-provider", + packageDir: "/repo/extensions/demo-provider", + packageManifest: { + install: { + npmSpec: "@vendor/demo-provider", + }, + }, + }, + ], + diagnostics: [], + }); + + expect( + resolveProviderInstallCatalogEntries({ + config: { + plugins: { + enabled: false, + }, + }, + includeUntrustedWorkspacePlugins: false, + }), + ).toEqual([]); + expect(loadPluginManifest).not.toHaveBeenCalled(); + }); + + it("skips untrusted workspace candidates without id hints before manifest load", () => { + discoverOpenClawPlugins.mockReturnValue({ + candidates: [ + { + idHint: "", + origin: "workspace", + rootDir: "/repo/extensions/demo-provider", + source: "/repo/extensions/demo-provider/index.ts", + workspaceDir: "/repo", + packageName: "@vendor/demo-provider", + packageDir: "/repo/extensions/demo-provider", + packageManifest: { + install: { + npmSpec: "@vendor/demo-provider", + }, + }, + }, + ], + diagnostics: [], + }); + + expect( + resolveProviderInstallCatalogEntries({ includeUntrustedWorkspacePlugins: false }), + ).toEqual([]); + expect(loadPluginManifest).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/provider-install-catalog.ts b/src/plugins/provider-install-catalog.ts new file mode 100644 index 00000000000..c93810f6be6 --- /dev/null +++ b/src/plugins/provider-install-catalog.ts @@ -0,0 +1,200 @@ +import path from "node:path"; +import { parseRegistryNpmSpec } from "../infra/npm-registry-spec.js"; +import { normalizePluginsConfig, resolveEffectiveEnableState } from "./config-state.js"; +import { discoverOpenClawPlugins } from "./discovery.js"; +import { + loadPluginManifest, + type PluginPackageInstall, + type PluginManifestLoadResult, +} from "./manifest.js"; +import type { PluginOrigin } from "./plugin-origin.types.js"; +import { + resolveManifestProviderAuthChoices, + type ProviderAuthChoiceMetadata, +} from "./provider-auth-choices.js"; + +export type ProviderInstallCatalogEntry = ProviderAuthChoiceMetadata & { + label: string; + origin: PluginOrigin; + install: PluginPackageInstall; +}; + +type ProviderInstallCatalogParams = { + config?: import("../config/types.openclaw.js").OpenClawConfig; + workspaceDir?: string; + env?: NodeJS.ProcessEnv; + includeUntrustedWorkspacePlugins?: boolean; +}; + +type PreferredInstallSource = { + origin: PluginOrigin; + install: PluginPackageInstall; +}; + +const INSTALL_ORIGIN_PRIORITY: Readonly> = { + config: 0, + bundled: 1, + global: 2, + workspace: 3, +}; + +function isPreferredOrigin(candidate: PluginOrigin, current: PluginOrigin | undefined): boolean { + if (!current) { + return true; + } + return INSTALL_ORIGIN_PRIORITY[candidate] < INSTALL_ORIGIN_PRIORITY[current]; +} + +function resolvePluginManifest( + rootDir: Parameters[0], + rejectHardlinks: boolean, +): Extract | null { + const manifest = loadPluginManifest(rootDir, rejectHardlinks); + return manifest.ok ? manifest : null; +} + +function resolveTrustedPinnedNpmSpec(params: { + origin: PluginOrigin; + install?: PluginPackageInstall; +}): string | undefined { + if (params.origin !== "bundled" && params.origin !== "config") { + return undefined; + } + const npmSpec = params.install?.npmSpec?.trim(); + const expectedIntegrity = params.install?.expectedIntegrity?.trim(); + if (!npmSpec || !expectedIntegrity) { + return undefined; + } + const parsed = parseRegistryNpmSpec(npmSpec); + return parsed?.selectorKind === "exact-version" ? npmSpec : undefined; +} + +function resolveInstallInfo(params: { + origin: PluginOrigin; + install?: PluginPackageInstall; + packageDir?: string; + workspaceDir?: string; +}): PluginPackageInstall | null { + const npmSpec = resolveTrustedPinnedNpmSpec({ + origin: params.origin, + install: params.install, + }); + let localPath = params.install?.localPath?.trim(); + if (!localPath && params.workspaceDir && params.packageDir) { + const relative = path.relative(params.workspaceDir, params.packageDir); + localPath = relative || undefined; + } + if (!npmSpec && !localPath) { + return null; + } + const defaultChoice = + params.install?.defaultChoice ?? (localPath ? "local" : npmSpec ? "npm" : undefined); + return { + ...(npmSpec ? { npmSpec } : {}), + ...(localPath ? { localPath } : {}), + ...(defaultChoice ? { defaultChoice } : {}), + ...(params.install?.minHostVersion ? { minHostVersion: params.install.minHostVersion } : {}), + ...(npmSpec && params.install?.expectedIntegrity + ? { expectedIntegrity: params.install.expectedIntegrity } + : {}), + ...(params.install?.allowInvalidConfigRecovery === true + ? { allowInvalidConfigRecovery: true } + : {}), + }; +} + +function resolvePreferredInstallsByPluginId( + params: ProviderInstallCatalogParams, +): Map { + const preferredByPluginId = new Map(); + const normalizedConfig = normalizePluginsConfig(params.config?.plugins); + for (const candidate of discoverOpenClawPlugins({ + workspaceDir: params.workspaceDir, + env: params.env, + }).candidates) { + const idHint = candidate.idHint.trim(); + if (candidate.origin === "workspace" && params.includeUntrustedWorkspacePlugins === false) { + if (!idHint) { + continue; + } + if ( + !resolveEffectiveEnableState({ + id: idHint, + origin: candidate.origin, + config: normalizedConfig, + rootConfig: params.config, + }).enabled + ) { + continue; + } + } + const manifest = resolvePluginManifest(candidate.rootDir, candidate.origin !== "bundled"); + if (!manifest) { + continue; + } + if ( + candidate.origin === "workspace" && + params.includeUntrustedWorkspacePlugins === false && + !resolveEffectiveEnableState({ + id: manifest.manifest.id, + origin: candidate.origin, + config: normalizedConfig, + rootConfig: params.config, + }).enabled + ) { + continue; + } + const install = resolveInstallInfo({ + origin: candidate.origin, + install: candidate.packageManifest?.install, + packageDir: candidate.packageDir, + workspaceDir: candidate.workspaceDir, + }); + if (!install) { + continue; + } + const existing = preferredByPluginId.get(manifest.manifest.id); + if (!existing || isPreferredOrigin(candidate.origin, existing.origin)) { + preferredByPluginId.set(manifest.manifest.id, { + origin: candidate.origin, + install, + }); + } + } + return preferredByPluginId; +} + +export function resolveProviderInstallCatalogEntries( + params?: ProviderInstallCatalogParams, +): ProviderInstallCatalogEntry[] { + const installsByPluginId = resolvePreferredInstallsByPluginId(params ?? {}); + return resolveManifestProviderAuthChoices(params) + .flatMap((choice) => { + const install = installsByPluginId.get(choice.pluginId); + if (!install) { + return []; + } + return [ + { + ...choice, + label: choice.groupLabel ?? choice.choiceLabel, + origin: install.origin, + install: install.install, + } satisfies ProviderInstallCatalogEntry, + ]; + }) + .toSorted((left, right) => left.choiceLabel.localeCompare(right.choiceLabel)); +} + +export function resolveProviderInstallCatalogEntry( + choiceId: string, + params?: ProviderInstallCatalogParams, +): ProviderInstallCatalogEntry | undefined { + const normalizedChoiceId = choiceId.trim(); + if (!normalizedChoiceId) { + return undefined; + } + return resolveProviderInstallCatalogEntries(params).find( + (entry) => entry.choiceId === normalizedChoiceId, + ); +} diff --git a/src/wizard/setup.test.ts b/src/wizard/setup.test.ts index ec434948835..a38e5654e1a 100644 --- a/src/wizard/setup.test.ts +++ b/src/wizard/setup.test.ts @@ -15,10 +15,13 @@ type ResolveProviderPluginChoice = type ResolvePluginProvidersRuntime = typeof import("../plugins/provider-auth-choice.runtime.js").resolvePluginProviders; type PromptDefaultModel = typeof import("../commands/model-picker.js").promptDefaultModel; +type ApplyAuthChoice = typeof import("../commands/auth-choice.js").applyAuthChoice; const ensureAuthProfileStore = vi.hoisted(() => vi.fn(() => ({ profiles: {} }))); const promptAuthChoiceGrouped = vi.hoisted(() => vi.fn(async () => "skip")); -const applyAuthChoice = vi.hoisted(() => vi.fn(async (args) => ({ config: args.config }))); +const applyAuthChoice = vi.hoisted(() => + vi.fn(async (args) => ({ config: args.config })), +); const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => "demo-provider")); const resolveProviderPluginChoice = vi.hoisted(() => vi.fn(() => null), @@ -606,6 +609,74 @@ describe("runSetupWizard", () => { ); }); + it("re-prompts for auth when applyAuthChoice requests retry selection", async () => { + promptAuthChoiceGrouped.mockReset(); + promptAuthChoiceGrouped + .mockResolvedValueOnce("demo-provider-one") + .mockResolvedValueOnce("demo-provider-two"); + applyAuthChoice.mockReset(); + applyAuthChoice + .mockResolvedValueOnce({ + config: { + plugins: { + entries: { + "demo-provider-plugin": { + enabled: true, + }, + }, + }, + }, + retrySelection: true, + }) + .mockResolvedValueOnce({ + config: { + agents: { + defaults: { + model: { + primary: "demo-provider-two/model", + }, + }, + }, + }, + }); + + const prompter = buildWizardPrompter({}); + const runtime = createRuntime(); + + await runSetupWizard( + { + acceptRisk: true, + flow: "quickstart", + installDaemon: false, + skipChannels: true, + skipSkills: true, + skipSearch: true, + skipHealth: true, + skipUi: true, + }, + runtime, + prompter, + ); + + expect(promptAuthChoiceGrouped).toHaveBeenCalledTimes(2); + expect(applyAuthChoice).toHaveBeenCalledTimes(2); + expect(applyAuthChoice).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ + authChoice: "demo-provider-two", + config: { + plugins: { + entries: { + "demo-provider-plugin": { + enabled: true, + }, + }, + }, + }, + }), + ); + }); + it("shows plugin compatibility notices for an existing valid config", async () => { buildPluginCompatibilityNotices.mockReturnValue([ { diff --git a/src/wizard/setup.ts b/src/wizard/setup.ts index 787add61959..af77ce4fd9c 100644 --- a/src/wizard/setup.ts +++ b/src/wizard/setup.ts @@ -489,58 +489,71 @@ export async function runSetupWizard( const authChoiceFromPrompt = opts.authChoice === undefined; let authChoice: AuthChoice | undefined = opts.authChoice; + let authStore: + | ReturnType<(typeof import("../agents/auth-profiles.runtime.js"))["ensureAuthProfileStore"]> + | undefined; + let promptAuthChoiceGrouped: + | (typeof import("../commands/auth-choice-prompt.js"))["promptAuthChoiceGrouped"] + | undefined; if (authChoiceFromPrompt) { const { ensureAuthProfileStore } = await import("../agents/auth-profiles.runtime.js"); - const { promptAuthChoiceGrouped } = await import("../commands/auth-choice-prompt.js"); - const authStore = ensureAuthProfileStore(undefined, { + ({ promptAuthChoiceGrouped } = await import("../commands/auth-choice-prompt.js")); + authStore = ensureAuthProfileStore(undefined, { allowKeychainPrompt: false, }); - authChoice = await promptAuthChoiceGrouped({ - prompter, - store: authStore, - includeSkip: true, - config: nextConfig, - workspaceDir, - }); } - if (authChoice === undefined) { - throw new WizardCancelledError("auth choice is required"); - } - - if (authChoice === "custom-api-key") { - const { promptCustomApiConfig } = await import("../commands/onboard-custom.js"); - const customResult = await promptCustomApiConfig({ - prompter, - runtime, - config: nextConfig, - secretInputMode: opts.secretInputMode, - }); - nextConfig = customResult.config; - } else if (authChoice === "skip") { - // Explicit skip should stay cold: do not bootstrap auth/profile machinery - // or run model/auth checks when the caller already chose to skip setup. + while (true) { if (authChoiceFromPrompt) { - const { applyPrimaryModel, promptDefaultModel } = await loadModelPickerModule(); - const modelSelection = await promptDefaultModel({ - config: nextConfig, + authChoice = await promptAuthChoiceGrouped!({ prompter, - allowKeep: true, - ignoreAllowlist: true, - includeProviderPluginSetups: true, + store: authStore!, + includeSkip: true, + config: nextConfig, workspaceDir, - runtime, }); - if (modelSelection.config) { - nextConfig = modelSelection.config; - } - if (modelSelection.model) { - nextConfig = applyPrimaryModel(nextConfig, modelSelection.model); - } - - const { warnIfModelConfigLooksOff } = await loadAuthChoiceModule(); - await warnIfModelConfigLooksOff(nextConfig, prompter); } - } else { + if (authChoice === undefined) { + throw new WizardCancelledError("auth choice is required"); + } + + if (authChoice === "custom-api-key") { + const { promptCustomApiConfig } = await import("../commands/onboard-custom.js"); + const customResult = await promptCustomApiConfig({ + prompter, + runtime, + config: nextConfig, + secretInputMode: opts.secretInputMode, + }); + nextConfig = customResult.config; + break; + } + if (authChoice === "skip") { + // Explicit skip should stay cold: do not bootstrap auth/profile machinery + // or run model/auth checks when the caller already chose to skip setup. + if (authChoiceFromPrompt) { + const { applyPrimaryModel, promptDefaultModel } = await loadModelPickerModule(); + const modelSelection = await promptDefaultModel({ + config: nextConfig, + prompter, + allowKeep: true, + ignoreAllowlist: true, + includeProviderPluginSetups: true, + workspaceDir, + runtime, + }); + if (modelSelection.config) { + nextConfig = modelSelection.config; + } + if (modelSelection.model) { + nextConfig = applyPrimaryModel(nextConfig, modelSelection.model); + } + + const { warnIfModelConfigLooksOff } = await loadAuthChoiceModule(); + await warnIfModelConfigLooksOff(nextConfig, prompter); + } + break; + } + const [ { applyAuthChoice, resolvePreferredProviderForAuthChoice, warnIfModelConfigLooksOff }, { applyPrimaryModel, promptDefaultModel }, @@ -557,6 +570,12 @@ export async function runSetupWizard( }, }); nextConfig = authResult.config; + if (authResult.retrySelection) { + if (authChoiceFromPrompt) { + continue; + } + break; + } if (authResult.agentModelOverride) { nextConfig = applyPrimaryModel(nextConfig, authResult.agentModelOverride); } @@ -589,6 +608,7 @@ export async function runSetupWizard( } await warnIfModelConfigLooksOff(nextConfig, prompter); + break; } const { configureGatewayForSetup } = await import("./setup.gateway-config.js"); diff --git a/test/helpers/channels/channel-plugin-catalog-contract-suites.ts b/test/helpers/channels/channel-plugin-catalog-contract-suites.ts index 4aad2edf3c3..9fb21c1a77f 100644 --- a/test/helpers/channels/channel-plugin-catalog-contract-suites.ts +++ b/test/helpers/channels/channel-plugin-catalog-contract-suites.ts @@ -203,6 +203,71 @@ export function describeChannelPluginCatalogEntriesContract() { }; }, }, + { + name: "accepts rich external manifest entries with pinned npm metadata", + setup: () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-catalog-rich-")); + const catalogPath = path.join(dir, "catalog.json"); + fs.writeFileSync( + catalogPath, + JSON.stringify({ + $schema: "./manifest.schema.json", + schemaVersion: 1, + description: + "Extension manifest. Declares plugin packages that OpenClaw can discover during onboarding and install on demand via `openclaw plugins install`.", + entries: [ + { + name: "@wecom/wecom-openclaw-plugin", + description: + "OpenClaw WeCom (企业微信) channel plugin — community maintained, published on npm.", + source: "external", + kind: "channel", + openclaw: { + channel: { + id: "wecom", + label: "WeCom", + selectionLabel: "WeCom (企业微信)", + detailLabel: "WeCom", + docsPath: "/channels/wecom", + docsLabel: "wecom", + blurb: "企业微信 (WeCom) bot & conversation channel.", + aliases: ["qywx", "wework"], + order: 45, + }, + install: { + npmSpec: "@wecom/wecom-openclaw-plugin@1.2.3", + defaultChoice: "npm", + minHostVersion: ">=2026.4.10", + expectedIntegrity: "sha512-wecom", + }, + }, + }, + ], + }), + ); + return { + channelId: "wecom", + catalogPaths: [catalogPath], + expected: { + id: "wecom", + meta: { + label: "WeCom", + selectionLabel: "WeCom (企业微信)", + detailLabel: "WeCom", + docsPath: "/channels/wecom", + docsLabel: "wecom", + blurb: "企业微信 (WeCom) bot & conversation channel.", + }, + install: { + npmSpec: "@wecom/wecom-openclaw-plugin@1.2.3", + defaultChoice: "npm", + minHostVersion: ">=2026.4.10", + expectedIntegrity: "sha512-wecom", + }, + }, + }; + }, + }, ] as const)("$name", ({ setup }) => { const setupResult = setup(); const { channelId, expected } = setupResult;