mirror of
https://github.com/openclaw/openclaw.git
synced 2026-04-06 14:51:08 +00:00
374 lines
11 KiB
TypeScript
374 lines
11 KiB
TypeScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import * as tar from "tar";
|
|
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { runCommandWithTimeout } from "../process/exec.js";
|
|
import { expectSingleNpmInstallIgnoreScriptsCall } from "../test-utils/exec-assertions.js";
|
|
import { initializeGlobalHookRunner, resetGlobalHookRunner } from "./hook-runner-global.js";
|
|
import { createMockPluginRegistry } from "./hooks.test-helpers.js";
|
|
import {
|
|
installPluginFromFile,
|
|
installPluginFromPath,
|
|
PLUGIN_INSTALL_ERROR_CODE,
|
|
} from "./install.js";
|
|
|
|
vi.mock("../process/exec.js", () => ({
|
|
runCommandWithTimeout: vi.fn(),
|
|
}));
|
|
|
|
let suiteTempRoot = "";
|
|
let tempDirCounter = 0;
|
|
|
|
function ensureSuiteTempRoot() {
|
|
if (suiteTempRoot) {
|
|
return suiteTempRoot;
|
|
}
|
|
const bundleTempRoot = path.join(process.cwd(), ".tmp");
|
|
fs.mkdirSync(bundleTempRoot, { recursive: true });
|
|
suiteTempRoot = fs.mkdtempSync(path.join(bundleTempRoot, "openclaw-plugin-install-path-"));
|
|
return suiteTempRoot;
|
|
}
|
|
|
|
function makeTempDir() {
|
|
const dir = path.join(ensureSuiteTempRoot(), `case-${String(tempDirCounter)}`);
|
|
tempDirCounter += 1;
|
|
fs.mkdirSync(dir);
|
|
return dir;
|
|
}
|
|
|
|
async function packToArchive(params: {
|
|
pkgDir: string;
|
|
outDir: string;
|
|
outName: string;
|
|
flatRoot?: boolean;
|
|
}) {
|
|
const dest = path.join(params.outDir, params.outName);
|
|
fs.rmSync(dest, { force: true });
|
|
const entries = params.flatRoot ? fs.readdirSync(params.pkgDir) : [path.basename(params.pkgDir)];
|
|
await tar.c(
|
|
{
|
|
gzip: true,
|
|
file: dest,
|
|
cwd: params.flatRoot ? params.pkgDir : path.dirname(params.pkgDir),
|
|
},
|
|
entries,
|
|
);
|
|
return dest;
|
|
}
|
|
|
|
function setupBundleInstallFixture(params: {
|
|
bundleFormat: "codex" | "claude" | "cursor";
|
|
name: string;
|
|
}) {
|
|
const caseDir = makeTempDir();
|
|
const stateDir = path.join(caseDir, "state");
|
|
const pluginDir = path.join(caseDir, "plugin-src");
|
|
fs.mkdirSync(stateDir, { recursive: true });
|
|
fs.mkdirSync(path.join(pluginDir, "skills"), { recursive: true });
|
|
const manifestDir = path.join(
|
|
pluginDir,
|
|
params.bundleFormat === "codex"
|
|
? ".codex-plugin"
|
|
: params.bundleFormat === "cursor"
|
|
? ".cursor-plugin"
|
|
: ".claude-plugin",
|
|
);
|
|
fs.mkdirSync(manifestDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(manifestDir, "plugin.json"),
|
|
JSON.stringify({
|
|
name: params.name,
|
|
description: `${params.bundleFormat} bundle fixture`,
|
|
...(params.bundleFormat === "codex" ? { skills: "skills" } : {}),
|
|
}),
|
|
"utf-8",
|
|
);
|
|
if (params.bundleFormat === "cursor") {
|
|
fs.mkdirSync(path.join(pluginDir, ".cursor", "commands"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, ".cursor", "commands", "review.md"),
|
|
"---\ndescription: fixture\n---\n",
|
|
"utf-8",
|
|
);
|
|
}
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "skills", "SKILL.md"),
|
|
"---\ndescription: fixture\n---\n",
|
|
"utf-8",
|
|
);
|
|
return { pluginDir, extensionsDir: path.join(stateDir, "extensions") };
|
|
}
|
|
|
|
function setupDualFormatInstallFixture(params: { bundleFormat: "codex" | "claude" }) {
|
|
const caseDir = makeTempDir();
|
|
const stateDir = path.join(caseDir, "state");
|
|
const pluginDir = path.join(caseDir, "plugin-src");
|
|
fs.mkdirSync(path.join(pluginDir, "dist"), { recursive: true });
|
|
fs.mkdirSync(path.join(pluginDir, "skills"), { recursive: true });
|
|
const manifestDir = path.join(
|
|
pluginDir,
|
|
params.bundleFormat === "codex" ? ".codex-plugin" : ".claude-plugin",
|
|
);
|
|
fs.mkdirSync(manifestDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "package.json"),
|
|
JSON.stringify({
|
|
name: "@openclaw/native-dual",
|
|
version: "0.0.1",
|
|
openclaw: { extensions: ["./dist/index.js"] },
|
|
dependencies: { "left-pad": "1.3.0" },
|
|
}),
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(pluginDir, "openclaw.plugin.json"),
|
|
JSON.stringify({
|
|
id: "native-dual",
|
|
configSchema: { type: "object", properties: {} },
|
|
skills: ["skills"],
|
|
}),
|
|
"utf-8",
|
|
);
|
|
fs.writeFileSync(path.join(pluginDir, "dist", "index.js"), "export {};", "utf-8");
|
|
fs.writeFileSync(path.join(pluginDir, "skills", "SKILL.md"), "---\ndescription: fixture\n---\n");
|
|
fs.writeFileSync(
|
|
path.join(manifestDir, "plugin.json"),
|
|
JSON.stringify({
|
|
name: "Bundle Fallback",
|
|
...(params.bundleFormat === "codex" ? { skills: "skills" } : {}),
|
|
}),
|
|
"utf-8",
|
|
);
|
|
return { pluginDir, extensionsDir: path.join(stateDir, "extensions") };
|
|
}
|
|
|
|
async function installFromFileWithWarnings(params: {
|
|
extensionsDir: string;
|
|
filePath: string;
|
|
dangerouslyForceUnsafeInstall?: boolean;
|
|
}) {
|
|
const warnings: string[] = [];
|
|
const result = await installPluginFromFile({
|
|
dangerouslyForceUnsafeInstall: params.dangerouslyForceUnsafeInstall,
|
|
filePath: params.filePath,
|
|
extensionsDir: params.extensionsDir,
|
|
logger: {
|
|
info: () => {},
|
|
warn: (msg: string) => warnings.push(msg),
|
|
},
|
|
});
|
|
return { result, warnings };
|
|
}
|
|
|
|
afterAll(() => {
|
|
if (!suiteTempRoot) {
|
|
return;
|
|
}
|
|
try {
|
|
fs.rmSync(suiteTempRoot, { recursive: true, force: true });
|
|
} finally {
|
|
suiteTempRoot = "";
|
|
tempDirCounter = 0;
|
|
}
|
|
});
|
|
|
|
beforeEach(() => {
|
|
resetGlobalHookRunner();
|
|
vi.clearAllMocks();
|
|
vi.unstubAllEnvs();
|
|
});
|
|
|
|
describe("installPluginFromPath", () => {
|
|
it("runs before_install for plain file plugins with file provenance metadata", async () => {
|
|
const handler = vi.fn().mockReturnValue({
|
|
findings: [
|
|
{
|
|
ruleId: "manual-review",
|
|
severity: "warn",
|
|
file: "payload.js",
|
|
line: 1,
|
|
message: "Review single-file plugin before install",
|
|
},
|
|
],
|
|
});
|
|
initializeGlobalHookRunner(createMockPluginRegistry([{ hookName: "before_install", handler }]));
|
|
|
|
const baseDir = makeTempDir();
|
|
const extensionsDir = path.join(baseDir, "extensions");
|
|
fs.mkdirSync(extensionsDir, { recursive: true });
|
|
|
|
const sourcePath = path.join(baseDir, "payload.js");
|
|
fs.writeFileSync(sourcePath, "console.log('SAFE');\n", "utf-8");
|
|
|
|
const result = await installPluginFromFile({
|
|
filePath: sourcePath,
|
|
extensionsDir,
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(handler).toHaveBeenCalledTimes(1);
|
|
expect(handler.mock.calls[0]?.[0]).toMatchObject({
|
|
targetName: "payload",
|
|
targetType: "plugin",
|
|
origin: "plugin-file",
|
|
sourcePath,
|
|
sourcePathKind: "file",
|
|
request: {
|
|
kind: "plugin-file",
|
|
mode: "install",
|
|
requestedSpecifier: sourcePath,
|
|
},
|
|
builtinScan: {
|
|
status: "ok",
|
|
},
|
|
plugin: {
|
|
contentType: "file",
|
|
pluginId: "payload",
|
|
extensions: ["payload.js"],
|
|
},
|
|
});
|
|
expect(handler.mock.calls[0]?.[1]).toEqual({
|
|
origin: "plugin-file",
|
|
targetType: "plugin",
|
|
requestKind: "plugin-file",
|
|
});
|
|
});
|
|
|
|
it("blocks plain file installs when the scanner finds dangerous code patterns", async () => {
|
|
const baseDir = makeTempDir();
|
|
const extensionsDir = path.join(baseDir, "extensions");
|
|
fs.mkdirSync(extensionsDir, { recursive: true });
|
|
|
|
const sourcePath = path.join(baseDir, "payload.js");
|
|
fs.writeFileSync(sourcePath, "eval('danger');\n", "utf-8");
|
|
|
|
const { result, warnings } = await installFromFileWithWarnings({
|
|
filePath: sourcePath,
|
|
extensionsDir,
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.code).toBe(PLUGIN_INSTALL_ERROR_CODE.SECURITY_SCAN_BLOCKED);
|
|
expect(result.error).toContain('Plugin file "payload" installation blocked');
|
|
}
|
|
expect(warnings.some((w) => w.includes("dangerous code pattern"))).toBe(true);
|
|
});
|
|
|
|
it("allows plain file installs with dangerous code patterns when forced unsafe install is set", async () => {
|
|
const baseDir = makeTempDir();
|
|
const extensionsDir = path.join(baseDir, "extensions");
|
|
fs.mkdirSync(extensionsDir, { recursive: true });
|
|
|
|
const sourcePath = path.join(baseDir, "payload.js");
|
|
fs.writeFileSync(sourcePath, "eval('danger');\n", "utf-8");
|
|
|
|
const { result, warnings } = await installFromFileWithWarnings({
|
|
filePath: sourcePath,
|
|
extensionsDir,
|
|
dangerouslyForceUnsafeInstall: true,
|
|
});
|
|
|
|
expect(result.ok).toBe(true);
|
|
expect(
|
|
warnings.some((warning) =>
|
|
warning.includes(
|
|
"forced despite dangerous code patterns via --dangerously-force-unsafe-install",
|
|
),
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("blocks hardlink alias overwrites when installing a plain file plugin", async () => {
|
|
const baseDir = makeTempDir();
|
|
const extensionsDir = path.join(baseDir, "extensions");
|
|
const outsideDir = path.join(baseDir, "outside");
|
|
fs.mkdirSync(extensionsDir, { recursive: true });
|
|
fs.mkdirSync(outsideDir, { recursive: true });
|
|
|
|
const sourcePath = path.join(baseDir, "payload.js");
|
|
fs.writeFileSync(sourcePath, "console.log('SAFE');\n", "utf-8");
|
|
const victimPath = path.join(outsideDir, "victim.js");
|
|
fs.writeFileSync(victimPath, "ORIGINAL", "utf-8");
|
|
|
|
const targetPath = path.join(extensionsDir, "payload.js");
|
|
fs.linkSync(victimPath, targetPath);
|
|
|
|
const result = await installPluginFromPath({
|
|
path: sourcePath,
|
|
extensionsDir,
|
|
mode: "update",
|
|
});
|
|
|
|
expect(result.ok).toBe(false);
|
|
if (result.ok) {
|
|
return;
|
|
}
|
|
expect(result.error.toLowerCase()).toMatch(/hardlink|path alias escape/);
|
|
expect(fs.readFileSync(victimPath, "utf-8")).toBe("ORIGINAL");
|
|
});
|
|
|
|
it("installs Claude bundles from an archive path", async () => {
|
|
const { pluginDir, extensionsDir } = setupBundleInstallFixture({
|
|
bundleFormat: "claude",
|
|
name: "Claude Sample",
|
|
});
|
|
const archivePath = path.join(makeTempDir(), "claude-bundle.tgz");
|
|
|
|
await packToArchive({
|
|
pkgDir: pluginDir,
|
|
outDir: path.dirname(archivePath),
|
|
outName: path.basename(archivePath),
|
|
});
|
|
|
|
const result = await installPluginFromPath({
|
|
path: archivePath,
|
|
extensionsDir,
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
expect(result.pluginId).toBe("claude-sample");
|
|
expect(fs.existsSync(path.join(result.targetDir, ".claude-plugin", "plugin.json"))).toBe(true);
|
|
});
|
|
|
|
it("prefers native package installs over bundle installs for dual-format archives", async () => {
|
|
const { pluginDir, extensionsDir } = setupDualFormatInstallFixture({
|
|
bundleFormat: "claude",
|
|
});
|
|
const archivePath = path.join(makeTempDir(), "dual-format.tgz");
|
|
|
|
await packToArchive({
|
|
pkgDir: pluginDir,
|
|
outDir: path.dirname(archivePath),
|
|
outName: path.basename(archivePath),
|
|
});
|
|
|
|
const run = vi.mocked(runCommandWithTimeout);
|
|
run.mockResolvedValue({
|
|
code: 0,
|
|
stdout: "",
|
|
stderr: "",
|
|
signal: null,
|
|
killed: false,
|
|
termination: "exit",
|
|
});
|
|
|
|
const result = await installPluginFromPath({
|
|
path: archivePath,
|
|
extensionsDir,
|
|
});
|
|
expect(result.ok).toBe(true);
|
|
if (!result.ok) {
|
|
return;
|
|
}
|
|
expect(result.pluginId).toBe("native-dual");
|
|
expect(result.targetDir).toBe(path.join(extensionsDir, "native-dual"));
|
|
expectSingleNpmInstallIgnoreScriptsCall({
|
|
calls: run.mock.calls as Array<[unknown, { cwd?: string } | undefined]>,
|
|
expectedTargetDir: result.targetDir,
|
|
});
|
|
});
|
|
});
|