mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-06 15:18:58 +00:00
1918 lines
62 KiB
TypeScript
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",
|
|
}),
|
|
);
|
|
});
|
|
});
|