Files
moltbot/src/cli/plugins-cli.install.test.ts
2026-05-06 14:35:36 +02:00

1918 lines
62 KiB
TypeScript

import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { installedPluginRoot } from "openclaw/plugin-sdk/test-fixtures";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
listOfficialExternalPluginCatalogEntries,
resolveOfficialExternalPluginId,
resolveOfficialExternalPluginInstall,
} from "../plugins/official-external-plugin-catalog.js";
import {
applyExclusiveSlotSelection,
buildPluginSnapshotReport,
enablePluginInConfig,
findBundledPluginSourceMock,
installHooksFromNpmSpec,
installHooksFromPath,
installPluginFromNpmPackArchive,
installPluginFromClawHub,
installPluginFromGitSpec,
installPluginFromMarketplace,
installPluginFromNpmSpec,
installPluginFromPath,
loadConfig,
loadPluginManifestRegistry,
readConfigFileSnapshot,
parseClawHubPluginSpec,
recordHookInstall,
recordPluginInstall,
resetPluginsCliTestState,
replaceConfigFile,
runPluginsCommand,
runtimeErrors,
runtimeLogs,
writeConfigFile,
writePersistedInstalledPluginIndexInstallRecords,
} from "./plugins-cli-test-helpers.js";
const CLI_STATE_ROOT = "/tmp/openclaw-state";
const ORIGINAL_OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR;
const ORIGINAL_OPENCLAW_NIX_MODE = process.env.OPENCLAW_NIX_MODE;
const PROFILE_STATE_ROOT = "/tmp/openclaw-ledger-profile";
const OFFICIAL_EXTERNAL_NPM_INSTALLS_WITHOUT_INTEGRITY = listOfficialExternalPluginCatalogEntries()
.map((entry) => {
const pluginId = resolveOfficialExternalPluginId(entry);
const install = resolveOfficialExternalPluginInstall(entry);
const npmSpec = install?.npmSpec?.trim();
if (!pluginId || !npmSpec || install?.expectedIntegrity) {
return null;
}
return { pluginId, npmSpec };
})
.filter((entry): entry is { pluginId: string; npmSpec: string } => Boolean(entry))
.toSorted((left, right) => left.pluginId.localeCompare(right.pluginId));
function cliInstallPath(pluginId: string): string {
return installedPluginRoot(CLI_STATE_ROOT, pluginId);
}
function useProfileExtensionsDir(): string {
process.env.OPENCLAW_STATE_DIR = PROFILE_STATE_ROOT;
return path.join(PROFILE_STATE_ROOT, "extensions");
}
function createEnabledPluginConfig(pluginId: string): OpenClawConfig {
return {
plugins: {
entries: {
[pluginId]: {
enabled: true,
},
},
},
} as OpenClawConfig;
}
function createEmptyPluginConfig(): OpenClawConfig {
return {
plugins: {
entries: {},
},
} as OpenClawConfig;
}
function createClawHubInstallResult(params: {
pluginId: string;
packageName: string;
version: string;
channel: string;
}): Awaited<ReturnType<typeof installPluginFromClawHub>> {
return {
ok: true,
pluginId: params.pluginId,
targetDir: cliInstallPath(params.pluginId),
version: params.version,
packageName: params.packageName,
clawhub: {
source: "clawhub",
clawhubUrl: "https://clawhub.ai",
clawhubPackage: params.packageName,
clawhubFamily: "code-plugin",
clawhubChannel: params.channel,
version: params.version,
integrity: "sha256-abc",
resolvedAt: "2026-03-22T00:00:00.000Z",
clawpackSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
clawpackSpecVersion: 1,
clawpackManifestSha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
clawpackSize: 4096,
},
};
}
function createNpmPluginInstallResult(
pluginId = "demo",
): Awaited<ReturnType<typeof installPluginFromNpmSpec>> {
return {
ok: true,
pluginId,
targetDir: cliInstallPath(pluginId),
version: "1.2.3",
npmResolution: {
packageName: pluginId,
resolvedVersion: "1.2.3",
tarballUrl: `https://registry.npmjs.org/${pluginId}/-/${pluginId}-1.2.3.tgz`,
},
};
}
function createNpmPackPluginInstallResult(
pluginId = "demo",
): Awaited<ReturnType<typeof installPluginFromNpmPackArchive>> {
return {
ok: true,
pluginId,
targetDir: cliInstallPath(pluginId),
version: "1.2.3",
extensions: ["dist/index.js"],
manifestName: `@openclaw/${pluginId}`,
npmTarballName: `openclaw-${pluginId}-1.2.3.tgz`,
npmResolution: {
name: `@openclaw/${pluginId}`,
version: "1.2.3",
resolvedSpec: `@openclaw/${pluginId}@1.2.3`,
integrity: "sha512-pack-demo",
shasum: "packdemosha",
resolvedAt: "2026-05-06T00:00:00.000Z",
},
};
}
function createGitPluginInstallResult(
pluginId = "demo",
): Awaited<ReturnType<typeof installPluginFromGitSpec>> {
return {
ok: true,
pluginId,
targetDir: cliInstallPath(pluginId),
version: "1.2.3",
extensions: ["index.js"],
git: {
url: "https://github.com/acme/demo.git",
ref: "v1.2.3",
commit: "abc123",
resolvedAt: "2026-04-30T00:00:00.000Z",
},
};
}
function mockClawHubPackageNotFound(packageName: string) {
installPluginFromClawHub.mockResolvedValue({
ok: false,
error: `ClawHub /api/v1/packages/${packageName} failed (404): Package not found`,
code: "package_not_found",
});
}
function primeNpmPluginFallback(pluginId = "demo") {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig(pluginId);
loadConfig.mockReturnValue(cfg);
mockClawHubPackageNotFound(pluginId);
installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult(pluginId));
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
return { cfg, enabledCfg };
}
function createPathHookPackInstalledConfig(tmpRoot: string): OpenClawConfig {
return {
hooks: {
internal: {
installs: {
"demo-hooks": {
source: "path",
sourcePath: tmpRoot,
installPath: tmpRoot,
},
},
},
},
} as OpenClawConfig;
}
function createNpmHookPackInstalledConfig(): OpenClawConfig {
return {
hooks: {
internal: {
installs: {
"demo-hooks": {
source: "npm",
spec: "@acme/demo-hooks@1.2.3",
},
},
},
},
} as OpenClawConfig;
}
function createHookPackInstallResult(targetDir: string): {
ok: true;
hookPackId: string;
hooks: string[];
targetDir: string;
version: string;
} {
return {
ok: true,
hookPackId: "demo-hooks",
hooks: ["command-audit"],
targetDir,
version: "1.2.3",
};
}
function primeHookPackNpmFallback() {
const cfg = {} as OpenClawConfig;
const installedCfg = createNpmHookPackInstalledConfig();
loadConfig.mockReturnValue(cfg);
mockClawHubPackageNotFound("@acme/demo-hooks");
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error: "package.json missing openclaw.plugin.json",
});
installHooksFromNpmSpec.mockResolvedValue({
...createHookPackInstallResult("/tmp/hooks/demo-hooks"),
npmResolution: {
name: "@acme/demo-hooks",
spec: "@acme/demo-hooks@1.2.3",
integrity: "sha256-demo",
},
});
recordHookInstall.mockReturnValue(installedCfg);
return { cfg, installedCfg };
}
function primeBlockedNpmPluginInstall(params: {
spec: string;
pluginId: string;
code?: "security_scan_blocked" | "security_scan_failed";
}) {
loadConfig.mockReturnValue({} as OpenClawConfig);
mockClawHubPackageNotFound(params.spec);
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error: `Plugin "${params.pluginId}" installation blocked: dangerous code patterns detected: finding details`,
code: params.code ?? "security_scan_blocked",
});
}
function primeHookPackPathFallback(params: {
tmpRoot: string;
pluginInstallError: string;
}): OpenClawConfig {
const installedCfg = createPathHookPackInstalledConfig(params.tmpRoot);
loadConfig.mockReturnValue({} as OpenClawConfig);
installPluginFromPath.mockResolvedValueOnce({
ok: false,
error: params.pluginInstallError,
});
installHooksFromPath.mockResolvedValueOnce(createHookPackInstallResult(params.tmpRoot));
recordHookInstall.mockReturnValue(installedCfg);
return installedCfg;
}
describe("plugins cli install", () => {
beforeEach(() => {
resetPluginsCliTestState();
});
afterEach(() => {
if (ORIGINAL_OPENCLAW_STATE_DIR === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = ORIGINAL_OPENCLAW_STATE_DIR;
}
if (ORIGINAL_OPENCLAW_NIX_MODE === undefined) {
delete process.env.OPENCLAW_NIX_MODE;
} else {
process.env.OPENCLAW_NIX_MODE = ORIGINAL_OPENCLAW_NIX_MODE;
}
});
it("shows the force overwrite option in install help", async () => {
const { Command } = await import("commander");
const { registerPluginsCli } = await import("./plugins-cli.js");
const program = new Command();
registerPluginsCli(program);
const pluginsCommand = program.commands.find((command) => command.name() === "plugins");
const installCommand = pluginsCommand?.commands.find((command) => command.name() === "install");
const helpText = installCommand?.helpInformation() ?? "";
expect(helpText).toContain("--force");
expect(helpText).toContain("Overwrite an existing installed plugin or");
expect(helpText).toContain("hook pack");
});
it("refuses plugin installs in Nix mode before installer side effects", async () => {
process.env.OPENCLAW_NIX_MODE = "1";
await expect(runPluginsCommand(["plugins", "install", "@acme/demo"])).rejects.toThrow(
"OPENCLAW_NIX_MODE=1",
);
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(installPluginFromPath).not.toHaveBeenCalled();
expect(installPluginFromMarketplace).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
});
it("exits when --marketplace is combined with --link", async () => {
await expect(
runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo", "--link"]),
).rejects.toThrow("__exit__:1");
expect(runtimeErrors.at(-1)).toContain("`--link` is not supported with `--marketplace`.");
expect(installPluginFromMarketplace).not.toHaveBeenCalled();
});
it("exits when --force is combined with --link", async () => {
await expect(
runPluginsCommand(["plugins", "install", "./plugin", "--link", "--force"]),
).rejects.toThrow("__exit__:1");
expect(runtimeErrors.at(-1)).toContain("`--force` is not supported with `--link`.");
expect(installPluginFromMarketplace).not.toHaveBeenCalled();
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
});
it("exits when marketplace install fails", async () => {
await expect(
runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]),
).rejects.toThrow("__exit__:1");
expect(installPluginFromMarketplace).toHaveBeenCalledWith(
expect.objectContaining({
marketplace: "local/repo",
plugin: "alpha",
}),
);
expect(writeConfigFile).not.toHaveBeenCalled();
});
it("passes the active profile extensions dir to marketplace installs", async () => {
const extensionsDir = useProfileExtensionsDir();
await expect(
runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]),
).rejects.toThrow("__exit__:1");
expect(installPluginFromMarketplace).toHaveBeenCalledWith(
expect.objectContaining({
extensionsDir,
marketplace: "local/repo",
plugin: "alpha",
}),
);
});
it("fails closed for unrelated invalid config before installer side effects", async () => {
const invalidConfigErr = new Error("config invalid");
(invalidConfigErr as { code?: string }).code = "INVALID_CONFIG";
loadConfig.mockImplementation(() => {
throw invalidConfigErr;
});
readConfigFileSnapshot.mockResolvedValue({
path: "/tmp/openclaw-config.json5",
exists: true,
raw: '{ "models": { "default": 123 } }',
parsed: { models: { default: 123 } },
resolved: { models: { default: 123 } },
valid: false,
config: { models: { default: 123 } },
hash: "mock",
issues: [{ path: "models.default", message: "invalid model ref" }],
warnings: [],
legacyIssues: [],
});
await expect(runPluginsCommand(["plugins", "install", "alpha"])).rejects.toThrow("__exit__:1");
expect(runtimeErrors.at(-1)).toContain(
"Config invalid; run `openclaw doctor --fix` before installing plugins.",
);
expect(installPluginFromMarketplace).not.toHaveBeenCalled();
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
});
it("installs marketplace plugins and persists plugin index", async () => {
const cfg = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledCfg = {
plugins: {
entries: {
alpha: {
enabled: true,
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
installPluginFromMarketplace.mockResolvedValue({
ok: true,
pluginId: "alpha",
targetDir: cliInstallPath("alpha"),
extensions: ["index.js"],
version: "1.2.3",
marketplaceName: "Claude",
marketplaceSource: "local/repo",
marketplacePlugin: "alpha",
});
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
buildPluginSnapshotReport.mockReturnValue({
plugins: [{ id: "alpha", kind: "provider" }],
diagnostics: [],
});
loadPluginManifestRegistry.mockReturnValue({
plugins: [{ id: "alpha", kind: "memory" }],
diagnostics: [],
});
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: ["slot adjusted"],
});
await runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo"]);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
alpha: expect.objectContaining({
source: "marketplace",
installPath: cliInstallPath("alpha"),
}),
});
expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg);
expect(replaceConfigFile).toHaveBeenCalledWith(
expect.objectContaining({
baseHash: "mock",
nextConfig: enabledCfg,
}),
);
expect(runtimeLogs.some((line) => line.includes("slot adjusted"))).toBe(true);
expect(runtimeLogs.some((line) => line.includes("Installed plugin: alpha"))).toBe(true);
});
it("passes force through as overwrite mode for marketplace installs", async () => {
await expect(
runPluginsCommand(["plugins", "install", "alpha", "--marketplace", "local/repo", "--force"]),
).rejects.toThrow("__exit__:1");
expect(installPluginFromMarketplace).toHaveBeenCalledWith(
expect.objectContaining({
marketplace: "local/repo",
plugin: "alpha",
mode: "update",
}),
);
});
it("installs ClawHub plugins and persists source metadata", async () => {
const cfg = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
installPluginFromClawHub.mockResolvedValue(
createClawHubInstallResult({
pluginId: "demo",
packageName: "demo",
version: "1.2.3",
channel: "official",
}),
);
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "clawhub:demo"]);
expect(installPluginFromClawHub).toHaveBeenCalledWith(
expect.objectContaining({
spec: "clawhub:demo",
}),
);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
demo: expect.objectContaining({
source: "clawhub",
spec: "clawhub:demo",
installPath: cliInstallPath("demo"),
version: "1.2.3",
clawhubPackage: "demo",
clawhubFamily: "code-plugin",
clawhubChannel: "official",
clawpackSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
clawpackSpecVersion: 1,
clawpackManifestSha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
clawpackSize: 4096,
}),
});
expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg);
expect(runtimeLogs.some((line) => line.includes("Installed plugin: demo"))).toBe(true);
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
});
it("passes the active profile extensions dir to ClawHub installs", async () => {
const extensionsDir = useProfileExtensionsDir();
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
installPluginFromClawHub.mockResolvedValue(
createClawHubInstallResult({
pluginId: "demo",
packageName: "demo",
version: "1.2.3",
channel: "official",
}),
);
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "clawhub:demo"]);
expect(installPluginFromClawHub).toHaveBeenCalledWith(
expect.objectContaining({
extensionsDir,
spec: "clawhub:demo",
}),
);
});
it("does not persist incomplete config entries for config-gated bundled installs", async () => {
const pluginId = "config-required-plugin";
const cfg = {
plugins: {
entries: {
[pluginId]: {
config: {},
},
},
load: {
paths: ["/existing/plugin"],
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
findBundledPluginSourceMock.mockReturnValue({
pluginId,
localPath: `/app/dist/extensions/${pluginId}`,
configSchema: {
type: "object",
required: ["token"],
properties: {
token: {
type: "string",
},
},
},
requiresConfig: true,
});
await runPluginsCommand(["plugins", "install", pluginId]);
const writtenConfig = writeConfigFile.mock.calls.at(-1)?.[0] as OpenClawConfig;
expect(writtenConfig.plugins?.entries?.[pluginId]).toBeUndefined();
expect(writtenConfig.plugins?.load?.paths).toEqual(["/existing/plugin"]);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
[pluginId]: expect.objectContaining({
source: "path",
sourcePath: expect.stringContaining(pluginId),
installPath: expect.stringContaining(pluginId),
}),
});
expect(enablePluginInConfig).not.toHaveBeenCalled();
expect(applyExclusiveSlotSelection).not.toHaveBeenCalled();
expect(runtimeLogs.some((line) => line.includes("requires configuration first"))).toBe(true);
});
it("enables config-gated bundled installs when provider-backed config is explicit", async () => {
const pluginId = "config-required-plugin";
const cfg = {
plugins: {
entries: {
[pluginId]: {
config: {
token: "sk-test",
},
},
},
},
} as OpenClawConfig;
const enabledCfg = createEnabledPluginConfig(pluginId);
loadConfig.mockReturnValue(cfg);
findBundledPluginSourceMock.mockReturnValue({
pluginId,
localPath: `/app/dist/extensions/${pluginId}`,
configSchema: {
type: "object",
required: ["token"],
properties: {
token: {
type: "string",
},
},
},
requiresConfig: true,
});
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
await runPluginsCommand(["plugins", "install", pluginId]);
expect(enablePluginInConfig).toHaveBeenCalled();
expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg);
expect(runtimeLogs.some((line) => line.includes("requires configuration first"))).toBe(false);
});
it("passes force through as overwrite mode for ClawHub installs", async () => {
const cfg = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
installPluginFromClawHub.mockResolvedValue(
createClawHubInstallResult({
pluginId: "demo",
packageName: "demo",
version: "1.2.3",
channel: "official",
}),
);
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "clawhub:demo", "--force"]);
expect(installPluginFromClawHub).toHaveBeenCalledWith(
expect.objectContaining({
spec: "clawhub:demo",
mode: "update",
}),
);
});
it("keeps explicit ClawHub versions pinned in install records", async () => {
const cfg = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
parseClawHubPluginSpec.mockReturnValue({ name: "demo", version: "1.2.3" });
installPluginFromClawHub.mockResolvedValue(
createClawHubInstallResult({
pluginId: "demo",
packageName: "demo",
version: "1.2.3",
channel: "official",
}),
);
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "clawhub:demo@1.2.3"]);
expect(installPluginFromClawHub).toHaveBeenCalledWith(
expect.objectContaining({
spec: "clawhub:demo@1.2.3",
}),
);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
demo: expect.objectContaining({
source: "clawhub",
spec: "clawhub:demo@1.2.3",
installPath: cliInstallPath("demo"),
version: "1.2.3",
clawhubPackage: "demo",
clawpackSha256: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
clawpackSpecVersion: 1,
clawpackManifestSha256: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
clawpackSize: 4096,
}),
});
});
it("resolves exact official external plugin ids through their npm package", async () => {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("brave");
loadConfig.mockReturnValue(cfg);
findBundledPluginSourceMock.mockReturnValue(undefined);
installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("brave"));
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "brave"]);
expect(findBundledPluginSourceMock).toHaveBeenCalledWith({
lookup: { kind: "pluginId", value: "brave" },
});
expect(installPluginFromClawHub).not.toHaveBeenCalled();
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/brave-plugin",
expectedPluginId: "brave",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
brave: expect.objectContaining({
source: "npm",
spec: "@openclaw/brave-plugin",
installPath: cliInstallPath("brave"),
version: "1.2.3",
}),
});
expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg);
});
it("passes third-party external catalog integrity with catalog install trust", async () => {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("wecom-openclaw-plugin");
loadConfig.mockReturnValue(cfg);
findBundledPluginSourceMock.mockReturnValue(undefined);
installPluginFromNpmSpec.mockResolvedValue(
createNpmPluginInstallResult("wecom-openclaw-plugin"),
);
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "wecom"]);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@wecom/wecom-openclaw-plugin@2026.4.23",
expectedPluginId: "wecom-openclaw-plugin",
expectedIntegrity:
"sha512-bnzfdIEEu1/LFvcdyjaTkyxt27w6c7dqhkPezU62OWaqmcdFsUGR3T55USK/O9pIKsNcnL1Tnu1pqKYCWHFgWQ==",
trustedSourceLinkedOfficialInstall: true,
}),
);
});
it.each(OFFICIAL_EXTERNAL_NPM_INSTALLS_WITHOUT_INTEGRITY)(
"keeps official external npm installs trusted without integrity for $pluginId",
async ({ pluginId, npmSpec }) => {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig(pluginId);
loadConfig.mockReturnValue(cfg);
findBundledPluginSourceMock.mockReturnValue(undefined);
installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult(pluginId));
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", pluginId]);
expect(findBundledPluginSourceMock).toHaveBeenCalledWith({
lookup: { kind: "pluginId", value: pluginId },
});
expect(installPluginFromClawHub).not.toHaveBeenCalled();
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: npmSpec,
expectedPluginId: pluginId,
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.not.objectContaining({
expectedIntegrity: expect.any(String),
}),
);
},
);
it("passes third-party external catalog integrity to hook-pack fallback", async () => {
loadConfig.mockReturnValue(createEmptyPluginConfig());
findBundledPluginSourceMock.mockReturnValue(undefined);
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error: "package.json missing openclaw.extensions",
code: "missing_openclaw_extensions",
});
installHooksFromNpmSpec.mockResolvedValue({
ok: false,
error:
"aborted: npm package integrity drift detected for @wecom/wecom-openclaw-plugin@2026.4.23",
});
await expect(runPluginsCommand(["plugins", "install", "wecom"])).rejects.toThrow("__exit__:1");
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(installHooksFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@wecom/wecom-openclaw-plugin@2026.4.23",
expectedIntegrity:
"sha512-bnzfdIEEu1/LFvcdyjaTkyxt27w6c7dqhkPezU62OWaqmcdFsUGR3T55USK/O9pIKsNcnL1Tnu1pqKYCWHFgWQ==",
}),
);
});
it("installs ordinary bare plugin specs through npm without ClawHub lookup", async () => {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("demo"));
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "demo"]);
expect(installPluginFromClawHub).not.toHaveBeenCalled();
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "demo",
}),
);
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
demo: expect.objectContaining({
source: "npm",
spec: "demo",
installPath: cliInstallPath("demo"),
version: "1.2.3",
}),
});
expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg);
});
it("passes bare npm selectors through npm without ClawHub lookup", async () => {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("demo"));
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "demo@beta"]);
expect(installPluginFromClawHub).not.toHaveBeenCalled();
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "demo@beta",
}),
);
});
it("installs directly from npm when npm: prefix is used", async () => {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("demo"));
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "npm:demo"]);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "demo",
mode: "install",
}),
);
expect(installPluginFromClawHub).not.toHaveBeenCalled();
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
demo: expect.objectContaining({
source: "npm",
spec: "demo",
installPath: cliInstallPath("demo"),
}),
});
expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg);
});
it("installs npm-pack archives through npm install semantics", async () => {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("demo");
const archivePath = "/tmp/openclaw-demo-1.2.3.tgz";
loadConfig.mockReturnValue(cfg);
installPluginFromNpmPackArchive.mockResolvedValue(createNpmPackPluginInstallResult("demo"));
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", `npm-pack:${archivePath}`]);
expect(installPluginFromNpmPackArchive).toHaveBeenCalledWith(
expect.objectContaining({
archivePath,
mode: "install",
}),
);
expect(installPluginFromPath).not.toHaveBeenCalled();
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
demo: expect.objectContaining({
source: "npm",
spec: "@openclaw/demo@1.2.3",
sourcePath: archivePath,
installPath: cliInstallPath("demo"),
version: "1.2.3",
artifactKind: "npm-pack",
artifactFormat: "tgz",
npmIntegrity: "sha512-pack-demo",
npmShasum: "packdemosha",
npmTarballName: "openclaw-demo-1.2.3.tgz",
}),
});
expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg);
});
it("keeps npm-prefixed official plugin ids on explicit npm semantics", async () => {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("brave");
loadConfig.mockReturnValue(cfg);
installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("brave"));
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "npm:brave"]);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "brave",
}),
);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.not.objectContaining({
expectedPluginId: "brave",
}),
);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.not.objectContaining({
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(installPluginFromClawHub).not.toHaveBeenCalled();
});
it("marks explicit official npm package installs as trusted", async () => {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("discord");
loadConfig.mockReturnValue(cfg);
installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("discord"));
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "npm:@openclaw/discord"]);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/discord",
expectedPluginId: "discord",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(installPluginFromClawHub).not.toHaveBeenCalled();
});
it("marks scoped official npm package installs as trusted", async () => {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("discord");
loadConfig.mockReturnValue(cfg);
installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("discord"));
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "@openclaw/discord"]);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@openclaw/discord",
expectedPluginId: "discord",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(installPluginFromClawHub).not.toHaveBeenCalled();
});
it("marks catalog npm package installs with alternate selectors as trusted", async () => {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("wecom-openclaw-plugin");
loadConfig.mockReturnValue(cfg);
installPluginFromNpmSpec.mockResolvedValue(
createNpmPluginInstallResult("wecom-openclaw-plugin"),
);
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "@wecom/wecom-openclaw-plugin@latest"]);
// Alternate selectors stay trusted by catalog package name, but must not
// inherit catalog integrity unless the install spec matches exactly.
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@wecom/wecom-openclaw-plugin@latest",
expectedPluginId: "wecom-openclaw-plugin",
trustedSourceLinkedOfficialInstall: true,
}),
);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.not.objectContaining({
expectedIntegrity: expect.any(String),
}),
);
expect(installPluginFromClawHub).not.toHaveBeenCalled();
});
it("passes the active profile extensions dir to npm installs", async () => {
const extensionsDir = useProfileExtensionsDir();
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("demo"));
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "npm:demo"]);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
extensionsDir,
spec: "demo",
}),
);
});
it("passes npm: prefix installs through npm options without ClawHub lookup", async () => {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
installPluginFromNpmSpec.mockResolvedValue(createNpmPluginInstallResult("demo"));
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
await runPluginsCommand([
"plugins",
"install",
"npm:demo",
"--force",
"--dangerously-force-unsafe-install",
]);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "demo",
mode: "update",
dangerouslyForceUnsafeInstall: true,
}),
);
expect(installPluginFromClawHub).not.toHaveBeenCalled();
});
it("reports npm install failures without trying ClawHub when npm: prefix is used", async () => {
loadConfig.mockReturnValue({} as OpenClawConfig);
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error: "npm install failed",
});
installHooksFromNpmSpec.mockResolvedValue({
ok: false,
error: "package.json missing openclaw.hooks",
});
await expect(runPluginsCommand(["plugins", "install", "npm:demo"])).rejects.toThrow(
"__exit__:1",
);
expect(installPluginFromClawHub).not.toHaveBeenCalled();
expect(runtimeErrors.at(-1)).toContain("npm install failed");
});
it("adds a Git PATH hint when npm plugin dependency install cannot spawn git", async () => {
loadConfig.mockReturnValue({} as OpenClawConfig);
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error: [
"npm install failed:",
"npm error code ENOENT",
"npm error syscall spawn git",
"npm error path git",
].join("\n"),
});
installHooksFromNpmSpec.mockResolvedValue({
ok: false,
error: "package.json missing openclaw.hooks",
});
await expect(
runPluginsCommand(["plugins", "install", "npm:@openclaw/whatsapp"]),
).rejects.toThrow("__exit__:1");
expect(installPluginFromClawHub).not.toHaveBeenCalled();
expect(runtimeErrors.at(-1)).toContain(
"one of this plugin's npm dependencies is fetched from a git URL",
);
expect(runtimeErrors.at(-1)).toContain("winget install --id Git.Git -e");
expect(runtimeErrors.at(-1)).toContain("Also not a valid hook pack");
});
it("does not resolve npm: prefixed bundled plugin ids through bundled installs", async () => {
loadConfig.mockReturnValue({ plugins: { load: { paths: [] } } } as OpenClawConfig);
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error: "Package not found on npm: memory-lancedb.",
code: "npm_package_not_found",
});
installHooksFromNpmSpec.mockResolvedValue({
ok: false,
error: "package.json missing openclaw.hooks",
});
await expect(runPluginsCommand(["plugins", "install", "npm:memory-lancedb"])).rejects.toThrow(
"__exit__:1",
);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "memory-lancedb",
}),
);
expect(installPluginFromClawHub).not.toHaveBeenCalled();
expect(writeConfigFile).not.toHaveBeenCalled();
expect(runtimeErrors.at(-1)).toContain("Package not found on npm: memory-lancedb.");
});
it("rejects empty npm: prefix installs before resolver lookup", async () => {
loadConfig.mockReturnValue({} as OpenClawConfig);
await expect(runPluginsCommand(["plugins", "install", "npm:"])).rejects.toThrow("__exit__:1");
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(installPluginFromClawHub).not.toHaveBeenCalled();
expect(runtimeErrors.at(-1)).toContain("unsupported npm: spec: missing package");
});
it("installs directly from git when git: prefix is used", async () => {
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
installPluginFromGitSpec.mockResolvedValue(createGitPluginInstallResult("demo"));
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
await runPluginsCommand(["plugins", "install", "git:github.com/acme/demo@v1.2.3"]);
expect(installPluginFromGitSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "git:github.com/acme/demo@v1.2.3",
mode: "install",
}),
);
expect(installPluginFromClawHub).not.toHaveBeenCalled();
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(writePersistedInstalledPluginIndexInstallRecords).toHaveBeenCalledWith({
demo: expect.objectContaining({
source: "git",
spec: "git:github.com/acme/demo@v1.2.3",
installPath: cliInstallPath("demo"),
gitUrl: "https://github.com/acme/demo.git",
gitRef: "v1.2.3",
gitCommit: "abc123",
}),
});
expect(writeConfigFile).toHaveBeenCalledWith(enabledCfg);
});
it("rejects --pin for git installs and points at git refs", async () => {
loadConfig.mockReturnValue({} as OpenClawConfig);
await expect(
runPluginsCommand(["plugins", "install", "git:github.com/acme/demo", "--pin"]),
).rejects.toThrow("__exit__:1");
expect(installPluginFromGitSpec).not.toHaveBeenCalled();
expect(runtimeErrors.at(-1)).toContain("use `git:<repo>@<ref>`");
});
it("passes dangerous force unsafe install to marketplace installs", async () => {
await expect(
runPluginsCommand([
"plugins",
"install",
"alpha",
"--marketplace",
"local/repo",
"--dangerously-force-unsafe-install",
]),
).rejects.toThrow("__exit__:1");
expect(installPluginFromMarketplace).toHaveBeenCalledWith(
expect.objectContaining({
marketplace: "local/repo",
plugin: "alpha",
dangerouslyForceUnsafeInstall: true,
}),
);
});
it("passes dangerous force unsafe install to npm installs", async () => {
primeNpmPluginFallback();
await runPluginsCommand(["plugins", "install", "demo", "--dangerously-force-unsafe-install"]);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "demo",
dangerouslyForceUnsafeInstall: true,
}),
);
});
it("passes dangerous force unsafe install to linked path probe installs", async () => {
const cfg = {
plugins: {
entries: {},
},
} as OpenClawConfig;
const enabledCfg = createEnabledPluginConfig("demo");
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-plugin-link-"));
loadConfig.mockReturnValue(cfg);
installPluginFromPath.mockResolvedValueOnce({
ok: true,
pluginId: "demo",
targetDir: tmpRoot,
version: "1.2.3",
extensions: ["./dist/index.js"],
});
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
try {
await runPluginsCommand([
"plugins",
"install",
tmpRoot,
"--link",
"--dangerously-force-unsafe-install",
]);
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
expect(installPluginFromPath).toHaveBeenCalledWith(
expect.objectContaining({
path: tmpRoot,
dryRun: true,
dangerouslyForceUnsafeInstall: true,
}),
);
});
it("passes dangerous force unsafe install to linked hook-pack probe fallback", async () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hook-link-"));
primeHookPackPathFallback({
tmpRoot,
pluginInstallError: "plugin install probe failed",
});
try {
await runPluginsCommand([
"plugins",
"install",
tmpRoot,
"--link",
"--dangerously-force-unsafe-install",
]);
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
expect(installHooksFromPath).toHaveBeenCalledWith(
expect.objectContaining({
path: tmpRoot,
dryRun: true,
dangerouslyForceUnsafeInstall: true,
}),
);
});
it("does not fall back to hook pack for linked path when a no-flag security scan blocks", async () => {
const localPluginDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-link-plugin-"));
const pluginInstallError = "plugin blocked by security scan";
loadConfig.mockReturnValue({} as OpenClawConfig);
installPluginFromPath.mockResolvedValue({
ok: false,
error: pluginInstallError,
code: "security_scan_blocked",
});
try {
await expect(
runPluginsCommand(["plugins", "install", localPluginDir, "--link"]),
).rejects.toThrow("__exit__:1");
} finally {
fs.rmSync(localPluginDir, { recursive: true, force: true });
}
expect(installHooksFromPath).not.toHaveBeenCalled();
expect(runtimeErrors.at(-1)).toContain(pluginInstallError);
expect(runtimeErrors.at(-1)).not.toContain("Also not a valid hook pack");
});
it("passes dangerous force unsafe install to local hook-pack fallback installs", async () => {
const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-hook-install-"));
primeHookPackPathFallback({
tmpRoot,
pluginInstallError: "plugin install failed",
});
try {
await runPluginsCommand([
"plugins",
"install",
tmpRoot,
"--dangerously-force-unsafe-install",
]);
} finally {
fs.rmSync(tmpRoot, { recursive: true, force: true });
}
expect(installHooksFromPath).toHaveBeenCalledWith(
expect.objectContaining({
path: tmpRoot,
mode: "install",
dangerouslyForceUnsafeInstall: true,
}),
);
});
it("passes the active profile extensions dir to local path installs", async () => {
const extensionsDir = useProfileExtensionsDir();
const localPluginDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-local-plugin-"));
const cfg = createEmptyPluginConfig();
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
installPluginFromPath.mockResolvedValue({
ok: true,
pluginId: "demo",
targetDir: path.join(extensionsDir, "demo"),
version: "1.2.3",
extensions: ["./dist/index.js"],
});
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
try {
await runPluginsCommand(["plugins", "install", localPluginDir]);
} finally {
fs.rmSync(localPluginDir, { recursive: true, force: true });
}
expect(installPluginFromPath).toHaveBeenCalledWith(
expect.objectContaining({
extensionsDir,
path: localPluginDir,
}),
);
});
it("passes force through as overwrite mode for npm installs", async () => {
primeNpmPluginFallback();
await runPluginsCommand(["plugins", "install", "demo", "--force"]);
expect(installPluginFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "demo",
mode: "update",
}),
);
});
it("suggests update or --force when npm plugin install target already exists", async () => {
loadConfig.mockReturnValue({} as OpenClawConfig);
mockClawHubPackageNotFound("@example/lossless-claw");
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error:
"plugin already exists: /home/openclaw/.openclaw/extensions/lossless-claw (delete it first)",
});
installHooksFromNpmSpec.mockResolvedValue({
ok: false,
error: "package.json missing openclaw.hooks",
});
await expect(
runPluginsCommand(["plugins", "install", "@example/lossless-claw"]),
).rejects.toThrow("__exit__:1");
expect(runtimeErrors.at(-1)).toContain(
"Use `openclaw plugins update <id-or-npm-spec>` to upgrade the tracked plugin, or rerun install with `--force` to replace it.",
);
expect(runtimeErrors.at(-1)).not.toContain("Also not a valid hook pack");
});
it("does not append hook-pack fallback details for managed extensions boundary failures", async () => {
const localPluginDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-local-plugin-"));
loadConfig.mockReturnValue({} as OpenClawConfig);
installPluginFromPath.mockResolvedValue({
ok: false,
error: "Invalid path: must stay within extensions directory",
});
installHooksFromPath.mockResolvedValue({
ok: false,
error: "package.json missing openclaw.hooks",
});
try {
await expect(runPluginsCommand(["plugins", "install", localPluginDir])).rejects.toThrow(
"__exit__:1",
);
} finally {
fs.rmSync(localPluginDir, { recursive: true, force: true });
}
expect(runtimeErrors.at(-1)).toBe("Invalid path: must stay within extensions directory");
expect(runtimeErrors.at(-1)).not.toContain("Also not a valid hook pack");
});
it("passes the install logger to the --link dry-run probe", async () => {
const localPluginDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-link-plugin-"));
const cfg = {
plugins: {
entries: {},
load: {
paths: [],
},
},
} as OpenClawConfig;
const enabledCfg = createEnabledPluginConfig("demo");
loadConfig.mockReturnValue(cfg);
installPluginFromPath.mockImplementation(async (...args: unknown[]) => {
const [params] = args as [
{
logger?: { warn?: (message: string) => void };
path: string;
dryRun?: boolean;
dangerouslyForceUnsafeInstall?: boolean;
},
];
params.logger?.warn?.(
'WARNING: Plugin "demo" forced despite dangerous code patterns via --dangerously-force-unsafe-install: index.js:1',
);
return {
ok: true,
pluginId: "demo",
targetDir: localPluginDir,
version: "1.0.0",
extensions: [],
};
});
enablePluginInConfig.mockReturnValue({ config: enabledCfg });
recordPluginInstall.mockReturnValue(enabledCfg);
applyExclusiveSlotSelection.mockReturnValue({
config: enabledCfg,
warnings: [],
});
try {
await runPluginsCommand([
"plugins",
"install",
localPluginDir,
"--link",
"--dangerously-force-unsafe-install",
]);
} finally {
fs.rmSync(localPluginDir, { recursive: true, force: true });
}
expect(installPluginFromPath).toHaveBeenCalledWith(
expect.objectContaining({
path: localPluginDir,
dryRun: true,
dangerouslyForceUnsafeInstall: true,
logger: expect.objectContaining({
info: expect.any(Function),
warn: expect.any(Function),
}),
}),
);
expect(
runtimeLogs.some((line) =>
line.includes(
"forced despite dangerous code patterns via --dangerously-force-unsafe-install",
),
),
).toBe(true);
});
it("does not fall back to hook pack for local path when a no-flag security scan fails", async () => {
const localPluginDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-local-plugin-"));
const pluginInstallError = "plugin security scan failed";
loadConfig.mockReturnValue({} as OpenClawConfig);
installPluginFromPath.mockResolvedValue({
ok: false,
error: pluginInstallError,
code: "security_scan_failed",
});
try {
await expect(runPluginsCommand(["plugins", "install", localPluginDir])).rejects.toThrow(
"__exit__:1",
);
} finally {
fs.rmSync(localPluginDir, { recursive: true, force: true });
}
expect(installHooksFromPath).not.toHaveBeenCalled();
expect(runtimeErrors.at(-1)).toContain(pluginInstallError);
expect(runtimeErrors.at(-1)).not.toContain("Also not a valid hook pack");
});
it("does not fall back to hook pack for local path when dangerous force unsafe install is set", async () => {
const localPluginDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-local-plugin-"));
const cfg = {} as OpenClawConfig;
const pluginInstallError = "plugin blocked by security scan";
loadConfig.mockReturnValue(cfg);
installPluginFromPath.mockResolvedValue({
ok: false,
error: pluginInstallError,
code: "security_scan_blocked",
});
try {
await expect(
runPluginsCommand([
"plugins",
"install",
localPluginDir,
"--dangerously-force-unsafe-install",
]),
).rejects.toThrow("__exit__:1");
} finally {
fs.rmSync(localPluginDir, { recursive: true, force: true });
}
expect(installHooksFromPath).not.toHaveBeenCalled();
expect(runtimeErrors.at(-1)).toContain(pluginInstallError);
});
it("does not fall back to hook pack for local path when security scan fails under dangerous force unsafe install", async () => {
const localPluginDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-local-plugin-"));
const cfg = {} as OpenClawConfig;
const pluginInstallError = "plugin security scan failed";
loadConfig.mockReturnValue(cfg);
installPluginFromPath.mockResolvedValue({
ok: false,
error: pluginInstallError,
code: "security_scan_failed",
});
try {
await expect(
runPluginsCommand([
"plugins",
"install",
localPluginDir,
"--dangerously-force-unsafe-install",
]),
).rejects.toThrow("__exit__:1");
} finally {
fs.rmSync(localPluginDir, { recursive: true, force: true });
}
expect(installHooksFromPath).not.toHaveBeenCalled();
expect(runtimeErrors.at(-1)).toContain(pluginInstallError);
});
it("does not fall back to hook pack for npm installs when dangerous force unsafe install is set", async () => {
const cfg = {} as OpenClawConfig;
const pluginInstallError = "plugin blocked by security scan";
loadConfig.mockReturnValue(cfg);
installPluginFromClawHub.mockResolvedValue({
ok: false,
error: "ClawHub /api/v1/packages/demo failed (404): Package not found",
code: "package_not_found",
});
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error: pluginInstallError,
code: "security_scan_blocked",
});
await expect(
runPluginsCommand(["plugins", "install", "demo", "--dangerously-force-unsafe-install"]),
).rejects.toThrow("__exit__:1");
expect(installHooksFromNpmSpec).not.toHaveBeenCalled();
expect(runtimeErrors.at(-1)).toContain(pluginInstallError);
});
it("does not fall back to hook pack for npm installs when a no-flag security scan blocks", async () => {
primeBlockedNpmPluginInstall({
spec: "@acme/unsafe-plugin",
pluginId: "unsafe-plugin",
});
await expect(runPluginsCommand(["plugins", "install", "@acme/unsafe-plugin"])).rejects.toThrow(
"__exit__:1",
);
expect(installHooksFromNpmSpec).not.toHaveBeenCalled();
expect(runtimeErrors.at(-1)).toContain('Plugin "unsafe-plugin" installation blocked');
expect(runtimeErrors.at(-1)).not.toContain("Also not a valid hook pack");
});
it("does not fall back to hook pack for npm installs when security scan fails under dangerous force unsafe install", async () => {
const cfg = {} as OpenClawConfig;
const pluginInstallError = "plugin security scan failed";
loadConfig.mockReturnValue(cfg);
installPluginFromClawHub.mockResolvedValue({
ok: false,
error: "ClawHub /api/v1/packages/demo failed (404): Package not found",
code: "package_not_found",
});
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error: pluginInstallError,
code: "security_scan_failed",
});
await expect(
runPluginsCommand(["plugins", "install", "demo", "--dangerously-force-unsafe-install"]),
).rejects.toThrow("__exit__:1");
expect(installHooksFromNpmSpec).not.toHaveBeenCalled();
expect(runtimeErrors.at(-1)).toContain(pluginInstallError);
});
it("still falls back to local hook pack when dangerous force unsafe install is set for non-security errors", async () => {
const localHookDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-local-hook-pack-"));
const cfg = {} as OpenClawConfig;
const installedCfg = {
hooks: {
internal: {
installs: {
"demo-hooks": {
source: "path",
sourcePath: localHookDir,
},
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
installPluginFromPath.mockResolvedValue({
ok: false,
error: "package.json missing openclaw.plugin.json",
code: "missing_openclaw_extensions",
});
installHooksFromPath.mockResolvedValue({
ok: true,
hookPackId: "demo-hooks",
hooks: ["command-audit"],
targetDir: "/tmp/hooks/demo-hooks",
version: "1.2.3",
});
recordHookInstall.mockReturnValue(installedCfg);
try {
await runPluginsCommand([
"plugins",
"install",
localHookDir,
"--dangerously-force-unsafe-install",
]);
} finally {
fs.rmSync(localHookDir, { recursive: true, force: true });
}
expect(installHooksFromPath).toHaveBeenCalledWith(
expect.objectContaining({
path: localHookDir,
}),
);
expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true);
});
it("still falls back to npm hook pack when dangerous force unsafe install is set for non-security errors", async () => {
const cfg = {} as OpenClawConfig;
const installedCfg = {
hooks: {
internal: {
installs: {
"demo-hooks": {
source: "npm",
spec: "@acme/demo-hooks@1.2.3",
},
},
},
},
} as OpenClawConfig;
loadConfig.mockReturnValue(cfg);
installPluginFromClawHub.mockResolvedValue({
ok: false,
error: "ClawHub /api/v1/packages/@acme/demo-hooks failed (404): Package not found",
code: "package_not_found",
});
installPluginFromNpmSpec.mockResolvedValue({
ok: false,
error: "package.json missing openclaw.plugin.json",
code: "missing_openclaw_extensions",
});
installHooksFromNpmSpec.mockResolvedValue({
ok: true,
hookPackId: "demo-hooks",
hooks: ["command-audit"],
targetDir: "/tmp/hooks/demo-hooks",
version: "1.2.3",
npmResolution: {
name: "@acme/demo-hooks",
spec: "@acme/demo-hooks@1.2.3",
integrity: "sha256-demo",
},
});
recordHookInstall.mockReturnValue(installedCfg);
await runPluginsCommand([
"plugins",
"install",
"@acme/demo-hooks",
"--dangerously-force-unsafe-install",
]);
expect(installHooksFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@acme/demo-hooks",
}),
);
expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true);
});
it("does not fall back to npm when explicit ClawHub rejects a real package", async () => {
parseClawHubPluginSpec.mockReturnValue({ name: "demo" });
installPluginFromClawHub.mockResolvedValue({
ok: false,
error: 'Use "openclaw skills install demo" instead.',
code: "skill_package",
});
await expect(runPluginsCommand(["plugins", "install", "clawhub:demo"])).rejects.toThrow(
"__exit__:1",
);
expect(installPluginFromNpmSpec).not.toHaveBeenCalled();
expect(runtimeErrors.at(-1)).toContain('Use "openclaw skills install demo" instead.');
});
it("falls back to installing hook packs from npm specs", async () => {
const { installedCfg } = primeHookPackNpmFallback();
await runPluginsCommand(["plugins", "install", "@acme/demo-hooks"]);
expect(installHooksFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@acme/demo-hooks",
}),
);
expect(recordHookInstall).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
hookId: "demo-hooks",
hooks: ["command-audit"],
}),
);
expect(writeConfigFile).toHaveBeenCalledWith(installedCfg);
expect(runtimeLogs.some((line) => line.includes("Installed hook pack: demo-hooks"))).toBe(true);
});
it("passes force through as overwrite mode for hook-pack npm fallback installs", async () => {
primeHookPackNpmFallback();
await runPluginsCommand(["plugins", "install", "@acme/demo-hooks", "--force"]);
expect(installHooksFromNpmSpec).toHaveBeenCalledWith(
expect.objectContaining({
spec: "@acme/demo-hooks",
mode: "update",
}),
);
});
});