mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 09:10:45 +00:00
feat(onboarding): auto-install missing provider and channel plugins
Squash-merge PR 70012.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>`
|
||||
|
||||
@@ -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 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -18,4 +18,5 @@ export type ApplyAuthChoiceParams = {
|
||||
export type ApplyAuthChoiceResult = {
|
||||
config: OpenClawConfig;
|
||||
agentModelOverride?: string;
|
||||
retrySelection?: boolean;
|
||||
};
|
||||
|
||||
@@ -1015,8 +1015,8 @@ describe("applyAuthChoice", () => {
|
||||
|
||||
expect(resolvePluginProviders).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
config: {},
|
||||
env,
|
||||
mode: "setup",
|
||||
}),
|
||||
);
|
||||
expect(confirm).toHaveBeenCalledWith(
|
||||
|
||||
@@ -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`)
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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();
|
||||
|
||||
515
src/commands/onboarding-plugin-install.test.ts
Normal file
515
src/commands/onboarding-plugin-install.test.ts
Normal 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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
574
src/commands/onboarding-plugin-install.ts
Normal file
574
src/commands/onboarding-plugin-install.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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?: {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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"]);
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
325
src/plugins/provider-install-catalog.test.ts
Normal file
325
src/plugins/provider-install-catalog.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
200
src/plugins/provider-install-catalog.ts
Normal file
200
src/plugins/provider-install-catalog.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
@@ -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([
|
||||
{
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user