feat(onboarding): auto-install missing provider and channel plugins

Squash-merge PR 70012.
This commit is contained in:
Vincent Koc
2026-04-22 22:05:00 -07:00
committed by GitHub
parent 86ace805b7
commit f67e48e6a0
33 changed files with 2830 additions and 446 deletions

View File

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

View File

@@ -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.<id>`

View File

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

View File

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

View File

@@ -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<ResolveProviderInstallCatalogEntry>(() => undefined),
);
vi.mock("../plugins/provider-install-catalog.js", () => ({
resolveProviderInstallCatalogEntry,
}));
const ensureOnboardingPluginInstalled = vi.hoisted(() =>
vi.fn<EnsureOnboardingPluginInstalled>(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: {

View File

@@ -18,4 +18,5 @@ export type ApplyAuthChoiceParams = {
export type ApplyAuthChoiceResult = {
config: OpenClawConfig;
agentModelOverride?: string;
retrySelection?: boolean;
};

View File

@@ -1015,8 +1015,8 @@ describe("applyAuthChoice", () => {
expect(resolvePluginProviders).toHaveBeenCalledWith(
expect.objectContaining({
config: {},
env,
mode: "setup",
}),
);
expect(confirm).toHaveBeenCalledWith(

View File

@@ -8,16 +8,31 @@ import {
vi.mock("node:fs", async () => {
const actual = await vi.importActual<typeof import("node:fs")>("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<typeof import("node:child_process")>("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<typeof fs.statSync>;
}
return {
isDirectory: () => true,
} as ReturnType<typeof fs.statSync>;
}) 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`)
);
});

View File

@@ -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<string>();
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<string>();
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<InstallChoice> {
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<InstallChoice>({
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<InstallResult> {
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: {

View File

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

View File

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

View File

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

View File

@@ -99,27 +99,53 @@ export async function promptAuthConfig(
runtime: RuntimeEnv,
prompter: WizardPrompter,
): Promise<OpenClawConfig> {
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") {

View File

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

View File

@@ -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 <T>(promise: Promise<T>) => await promise));
vi.mock("../utils/with-timeout.js", () => ({
withTimeout,
}));
import { ensureOnboardingPluginInstalled } from "./onboarding-plugin-install.js";
describe("ensureOnboardingPluginInstalled", () => {
beforeEach(() => {
vi.clearAllMocks();
withTimeout.mockImplementation(async <T>(promise: Promise<T>) => 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<ReturnType<typeof ensureOnboardingPluginInstalled>> | 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();
}
});
});
});

View File

@@ -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<string>();
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<InstallChoice> {
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<InstallChoice>({
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<PluginEnableResult> {
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<ReturnType<typeof installPluginFromNpmSpec>>;
}
> {
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<OnboardingPluginInstallResult> {
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",
};
}

View File

@@ -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<LoadChannelSetupPluginRegistrySnapshotForChannel>;
@@ -88,6 +90,14 @@ const listActiveChannelSetupPlugins = vi.hoisted(() => vi.fn((): unknown[] => []
const loadChannelSetupPluginRegistrySnapshotForChannel = vi.hoisted(() =>
vi.fn<LoadChannelSetupPluginRegistrySnapshotForChannel>((_params) => makePluginRegistry()),
);
const ensureChannelSetupPluginInstalled = vi.hoisted(() =>
vi.fn<EnsureChannelSetupPluginInstalled>(async ({ cfg, entry }) => ({
cfg,
installed: true,
pluginId: entry?.pluginId,
status: "installed",
})),
);
const resolveChannelSetupEntries = vi.hoisted(() =>
vi.fn<ResolveChannelSetupEntries>((_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<EnsureChannelSetupPluginInstalled>[0]) =>
ensureChannelSetupPluginInstalled(params),
loadChannelSetupPluginRegistrySnapshotForChannel: (
params: Parameters<LoadChannelSetupPluginRegistrySnapshotForChannel>[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<string, unknown> }) => ({ 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",

View File

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

View File

@@ -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<ResolveProviderInstallCatalogEntries>(() => []),
);
vi.mock("../plugins/provider-install-catalog.js", () => ({
resolveProviderInstallCatalogEntries,
}));
const resolveProviderWizardOptions = vi.hoisted(() =>
vi.fn<ResolveProviderWizardOptions>(() => []),
);
const resolveProviderModelPickerEntries = vi.hoisted(() =>
vi.fn<ResolveProviderModelPickerEntries>(() => []),
);
vi.mock("../plugins/provider-wizard.js", () => ({
resolveProviderWizardOptions,
resolveProviderModelPickerEntries,
}));
const resolvePluginProviders = vi.hoisted(() => vi.fn<ResolvePluginProviders>(() => []));
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",
},
]);
});
});

View File

@@ -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?: {

View File

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

View File

@@ -27,19 +27,26 @@ export type ResolveNpmIntegrityDriftResult<TPayload> = {
payload?: TPayload;
};
function normalizeIntegrity(value: string | undefined): string | undefined {
const normalized = value?.trim();
return normalized ? normalized : undefined;
}
export async function resolveNpmIntegrityDrift<TPayload>(
params: ResolveNpmIntegrityDriftParams<TPayload>,
): Promise<ResolveNpmIntegrityDriftResult<TPayload>> {
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<TPayload>(
resolution: params.resolution,
});
let proceed = true;
let proceed = false;
if (params.onIntegrityDrift) {
proceed = await params.onIntegrityDrift(payload);
} else {

View File

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

View File

@@ -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<typeof enablePluginInConfig>) => {
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<typeof enablePluginInConfig>) => {
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<typeof enablePluginInConfig>) => {
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<typeof enablePluginInConfig>) => {
expectBuiltInChannelEnabledWithAllowlist(result, ["memory-core", "telegram"]);
expectBuiltInChannelEnabledWithAllowlist(result, ["telegram"]);
},
},
{

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<DiscoverOpenClawPlugins>(() => ({ candidates: [], diagnostics: [] })),
);
vi.mock("./discovery.js", () => ({
discoverOpenClawPlugins,
}));
const loadPluginManifest = vi.hoisted(() => vi.fn<LoadPluginManifest>());
vi.mock("./manifest.js", async () => {
const actual = await vi.importActual<typeof import("./manifest.js")>("./manifest.js");
return {
...actual,
loadPluginManifest,
};
});
const resolveManifestProviderAuthChoices = vi.hoisted(() =>
vi.fn<ResolveManifestProviderAuthChoices>(() => []),
);
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();
});
});

View File

@@ -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<Record<PluginOrigin, number>> = {
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<typeof loadPluginManifest>[0],
rejectHardlinks: boolean,
): Extract<PluginManifestLoadResult, { ok: true }> | 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<string, PreferredInstallSource> {
const preferredByPluginId = new Map<string, PreferredInstallSource>();
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,
);
}

View File

@@ -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<ApplyAuthChoice>(async (args) => ({ config: args.config })),
);
const resolvePreferredProviderForAuthChoice = vi.hoisted(() => vi.fn(async () => "demo-provider"));
const resolveProviderPluginChoice = vi.hoisted(() =>
vi.fn<ResolveProviderPluginChoice>(() => 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([
{

View File

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

View File

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