mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:30:42 +00:00
2127 lines
69 KiB
TypeScript
2127 lines
69 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { createHash } from "node:crypto";
|
|
import fs from "node:fs";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import {
|
|
__testing as bundledRuntimeDepsActivityTesting,
|
|
getActiveBundledRuntimeDepsInstallCount,
|
|
waitForBundledRuntimeDepsInstallIdle,
|
|
} from "./bundled-runtime-deps-activity.js";
|
|
import {
|
|
__testing as bundledRuntimeDepsTesting,
|
|
createBundledRuntimeDependencyAliasMap,
|
|
createBundledRuntimeDepsInstallArgs,
|
|
createBundledRuntimeDepsInstallEnv,
|
|
ensureBundledPluginRuntimeDeps,
|
|
installBundledRuntimeDeps,
|
|
isWritableDirectory,
|
|
materializeBundledRuntimeMirrorDistFile,
|
|
repairBundledRuntimeDepsInstallRootAsync,
|
|
resolveBundledRuntimeDependencyInstallRoot,
|
|
resolveBundledRuntimeDepsNpmRunner,
|
|
scanBundledPluginRuntimeDeps,
|
|
type BundledRuntimeDepsInstallParams,
|
|
} from "./bundled-runtime-deps.js";
|
|
|
|
vi.mock("node:child_process", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("node:child_process")>()),
|
|
spawnSync: vi.fn(),
|
|
}));
|
|
|
|
const spawnSyncMock = vi.mocked(spawnSync);
|
|
const tempDirs: string[] = [];
|
|
|
|
function makeTempDir(): string {
|
|
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-runtime-deps-test-"));
|
|
tempDirs.push(dir);
|
|
return dir;
|
|
}
|
|
|
|
function writeInstalledPackage(rootDir: string, packageName: string, version: string): void {
|
|
const packageDir = path.join(rootDir, "node_modules", ...packageName.split("/"));
|
|
fs.mkdirSync(packageDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(packageDir, "package.json"),
|
|
JSON.stringify({ name: packageName, version }),
|
|
"utf8",
|
|
);
|
|
}
|
|
|
|
function writeBundledPluginPackage(params: {
|
|
packageRoot: string;
|
|
pluginId: string;
|
|
deps: Record<string, string>;
|
|
enabledByDefault?: boolean;
|
|
channels?: string[];
|
|
}): string {
|
|
const pluginRoot = path.join(params.packageRoot, "dist", "extensions", params.pluginId);
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({ dependencies: params.deps }),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "openclaw.plugin.json"),
|
|
JSON.stringify({
|
|
id: params.pluginId,
|
|
enabledByDefault: params.enabledByDefault === true,
|
|
...(params.channels ? { channels: params.channels } : {}),
|
|
}),
|
|
);
|
|
return pluginRoot;
|
|
}
|
|
|
|
function statfsFixture(params: {
|
|
bavail: number;
|
|
bsize?: number;
|
|
blocks?: number;
|
|
}): ReturnType<typeof fs.statfsSync> {
|
|
return {
|
|
type: 0,
|
|
bsize: params.bsize ?? 1024,
|
|
blocks: params.blocks ?? 2_000_000,
|
|
bfree: params.bavail,
|
|
bavail: params.bavail,
|
|
files: 0,
|
|
ffree: 0,
|
|
};
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
spawnSyncMock.mockReset();
|
|
bundledRuntimeDepsActivityTesting.resetBundledRuntimeDepsInstallActivity();
|
|
for (const dir of tempDirs.splice(0)) {
|
|
fs.rmSync(dir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
describe("resolveBundledRuntimeDepsNpmRunner", () => {
|
|
it("uses npm_execpath through node on Windows when available", () => {
|
|
const runner = resolveBundledRuntimeDepsNpmRunner({
|
|
env: { npm_execpath: "C:\\node\\node_modules\\npm\\bin\\npm-cli.js" },
|
|
execPath: "C:\\Program Files\\nodejs\\node.exe",
|
|
existsSync: (candidate) => candidate === "C:\\node\\node_modules\\npm\\bin\\npm-cli.js",
|
|
npmArgs: ["install", "acpx@0.5.3"],
|
|
platform: "win32",
|
|
});
|
|
|
|
expect(runner).toEqual({
|
|
command: "C:\\Program Files\\nodejs\\node.exe",
|
|
args: ["C:\\node\\node_modules\\npm\\bin\\npm-cli.js", "install", "acpx@0.5.3"],
|
|
});
|
|
});
|
|
|
|
it("uses package-manager-neutral install args with npm config env", () => {
|
|
expect(createBundledRuntimeDepsInstallArgs(["acpx@0.5.3"])).toEqual([
|
|
"install",
|
|
"--ignore-scripts",
|
|
"acpx@0.5.3",
|
|
]);
|
|
expect(
|
|
createBundledRuntimeDepsInstallEnv(
|
|
{
|
|
PATH: "/usr/bin:/bin",
|
|
NPM_CONFIG_CACHE: "/Users/alice/.npm-uppercase",
|
|
NPM_CONFIG_GLOBAL: "true",
|
|
NPM_CONFIG_LOCATION: "global",
|
|
NPM_CONFIG_PREFIX: "/Users/alice",
|
|
npm_config_cache: "/Users/alice/.npm",
|
|
npm_config_global: "true",
|
|
npm_config_location: "global",
|
|
npm_config_prefix: "/opt/homebrew",
|
|
},
|
|
{ cacheDir: "/opt/openclaw/runtime-cache" },
|
|
),
|
|
).toEqual({
|
|
PATH: "/usr/bin:/bin",
|
|
npm_config_cache: "/opt/openclaw/runtime-cache",
|
|
npm_config_global: "false",
|
|
npm_config_legacy_peer_deps: "true",
|
|
npm_config_location: "project",
|
|
npm_config_package_lock: "false",
|
|
npm_config_save: "false",
|
|
});
|
|
});
|
|
|
|
it("uses the Node-adjacent npm CLI on Windows", () => {
|
|
const execPath = "C:\\Program Files\\nodejs\\node.exe";
|
|
const npmCliPath = path.win32.resolve(
|
|
path.win32.dirname(execPath),
|
|
"node_modules/npm/bin/npm-cli.js",
|
|
);
|
|
|
|
const runner = resolveBundledRuntimeDepsNpmRunner({
|
|
env: {},
|
|
execPath,
|
|
existsSync: (candidate) => candidate === npmCliPath,
|
|
npmArgs: ["install", "acpx@0.5.3"],
|
|
platform: "win32",
|
|
});
|
|
|
|
expect(runner).toEqual({
|
|
command: execPath,
|
|
args: [npmCliPath, "install", "acpx@0.5.3"],
|
|
});
|
|
});
|
|
|
|
it("ignores pnpm npm_execpath and falls back to npm", () => {
|
|
const execPath = "/opt/node/bin/node";
|
|
const npmCliPath = "/opt/node/lib/node_modules/npm/bin/npm-cli.js";
|
|
const runner = resolveBundledRuntimeDepsNpmRunner({
|
|
env: {
|
|
npm_execpath: "/home/runner/setup-pnpm/node_modules/.bin/pnpm.cjs",
|
|
},
|
|
execPath,
|
|
existsSync: (candidate) => candidate === npmCliPath,
|
|
npmArgs: ["install", "acpx@0.5.3"],
|
|
platform: "linux",
|
|
});
|
|
|
|
expect(runner).toEqual({
|
|
command: execPath,
|
|
args: [npmCliPath, "install", "acpx@0.5.3"],
|
|
});
|
|
});
|
|
|
|
it("refuses Windows shell fallback when no safe npm executable is available", () => {
|
|
expect(() =>
|
|
resolveBundledRuntimeDepsNpmRunner({
|
|
env: {},
|
|
execPath: "C:\\Program Files\\nodejs\\node.exe",
|
|
existsSync: () => false,
|
|
npmArgs: ["install"],
|
|
platform: "win32",
|
|
}),
|
|
).toThrow("Unable to resolve a safe npm executable on Windows");
|
|
});
|
|
|
|
it("prefixes PATH with the active Node directory on POSIX", () => {
|
|
const runner = resolveBundledRuntimeDepsNpmRunner({
|
|
env: {
|
|
PATH: "/usr/bin:/bin",
|
|
},
|
|
execPath: "/opt/node/bin/node",
|
|
existsSync: () => false,
|
|
npmArgs: ["install", "acpx@0.5.3"],
|
|
platform: "linux",
|
|
});
|
|
|
|
expect(runner).toEqual({
|
|
command: "npm",
|
|
args: ["install", "acpx@0.5.3"],
|
|
env: {
|
|
PATH: `/opt/node/bin${path.delimiter}/usr/bin:/bin`,
|
|
},
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("installBundledRuntimeDeps", () => {
|
|
it("keeps already-materialized mirror chunks when source and target match", () => {
|
|
const tempDir = makeTempDir();
|
|
const chunkPath = path.join(tempDir, "dist", "accounts.js");
|
|
fs.mkdirSync(path.dirname(chunkPath), { recursive: true });
|
|
fs.writeFileSync(
|
|
chunkPath,
|
|
[
|
|
`//#region extensions/slack/src/accounts.ts`,
|
|
`export const marker = "same-file";`,
|
|
`//#endregion`,
|
|
"",
|
|
].join("\n"),
|
|
"utf8",
|
|
);
|
|
|
|
materializeBundledRuntimeMirrorDistFile(chunkPath, chunkPath);
|
|
|
|
expect(fs.readFileSync(chunkPath, "utf8")).toContain("same-file");
|
|
});
|
|
|
|
it("uses a real write probe for runtime dependency roots", () => {
|
|
const accessSpy = vi.spyOn(fs, "accessSync").mockImplementation(() => undefined);
|
|
const mkdirSpy = vi.spyOn(fs, "mkdtempSync").mockImplementation(() => {
|
|
const error = new Error("read-only file system") as NodeJS.ErrnoException;
|
|
error.code = "EROFS";
|
|
throw error;
|
|
});
|
|
|
|
expect(isWritableDirectory("/usr/lib/node_modules/openclaw")).toBe(false);
|
|
expect(accessSpy).not.toHaveBeenCalled();
|
|
expect(mkdirSpy).toHaveBeenCalledWith(
|
|
path.join("/usr/lib/node_modules/openclaw", ".openclaw-write-probe-"),
|
|
);
|
|
});
|
|
|
|
it("uses the npm cmd shim on Windows", () => {
|
|
const installRoot = makeTempDir();
|
|
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
|
|
vi.spyOn(fs, "existsSync").mockImplementation(
|
|
(candidate) => candidate === "C:\\node\\node_modules\\npm\\bin\\npm-cli.js",
|
|
);
|
|
spawnSyncMock.mockImplementation((_command, _args, options) => {
|
|
writeInstalledPackage(String(options?.cwd ?? ""), "acpx", "0.5.3");
|
|
return {
|
|
pid: 123,
|
|
output: [],
|
|
stdout: "",
|
|
stderr: "",
|
|
signal: null,
|
|
status: 0,
|
|
};
|
|
});
|
|
|
|
installBundledRuntimeDeps({
|
|
installRoot,
|
|
missingSpecs: ["acpx@0.5.3"],
|
|
env: {
|
|
npm_config_prefix: "C:\\prefix",
|
|
PATH: "C:\\node",
|
|
npm_execpath: "C:\\node\\node_modules\\npm\\bin\\npm-cli.js",
|
|
},
|
|
});
|
|
|
|
expect(spawnSyncMock).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
["C:\\node\\node_modules\\npm\\bin\\npm-cli.js", "install", "--ignore-scripts", "acpx@0.5.3"],
|
|
expect.objectContaining({
|
|
cwd: installRoot,
|
|
env: expect.objectContaining({
|
|
npm_config_legacy_peer_deps: "true",
|
|
npm_config_package_lock: "false",
|
|
npm_config_save: "false",
|
|
}),
|
|
}),
|
|
);
|
|
expect(spawnSyncMock).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.any(Array),
|
|
expect.objectContaining({
|
|
env: expect.not.objectContaining({
|
|
npm_config_prefix: expect.any(String),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("anchors non-isolated external install roots with a package manifest", () => {
|
|
const parentRoot = makeTempDir();
|
|
const installRoot = path.join(parentRoot, ".openclaw", "plugin-runtime-deps", "openclaw-test");
|
|
fs.mkdirSync(path.join(parentRoot, "node_modules", "@grammyjs"), { recursive: true });
|
|
spawnSyncMock.mockImplementation((_command, _args, options) => {
|
|
const cwd = String(options?.cwd ?? "");
|
|
expect(cwd).toBe(installRoot);
|
|
expect(JSON.parse(fs.readFileSync(path.join(cwd, "package.json"), "utf8"))).toEqual({
|
|
name: "openclaw-runtime-deps-install",
|
|
private: true,
|
|
});
|
|
writeInstalledPackage(cwd, "@grammyjs/runner", "2.0.3");
|
|
return {
|
|
pid: 123,
|
|
output: [],
|
|
stdout: "",
|
|
stderr: "",
|
|
signal: null,
|
|
status: 0,
|
|
};
|
|
});
|
|
|
|
installBundledRuntimeDeps({
|
|
installRoot,
|
|
missingSpecs: ["@grammyjs/runner@^2.0.3"],
|
|
env: {
|
|
HOME: parentRoot,
|
|
},
|
|
});
|
|
|
|
expect(spawnSyncMock).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.any(Array),
|
|
expect.objectContaining({
|
|
cwd: installRoot,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("warns but still installs bundled runtime deps when disk space looks low", () => {
|
|
const installRoot = makeTempDir();
|
|
const warn = vi.fn();
|
|
vi.spyOn(fs, "statfsSync").mockReturnValue(
|
|
statfsFixture({
|
|
bavail: 256,
|
|
bsize: 1024 * 1024,
|
|
}),
|
|
);
|
|
spawnSyncMock.mockImplementation((_command, _args, options) => {
|
|
writeInstalledPackage(String(options?.cwd ?? ""), "acpx", "0.5.3");
|
|
return {
|
|
pid: 123,
|
|
output: [],
|
|
stdout: "",
|
|
stderr: "",
|
|
signal: null,
|
|
status: 0,
|
|
};
|
|
});
|
|
|
|
installBundledRuntimeDeps({
|
|
installRoot,
|
|
missingSpecs: ["acpx@0.5.3"],
|
|
env: {},
|
|
warn,
|
|
});
|
|
|
|
expect(warn).toHaveBeenCalledWith(expect.stringContaining("Low disk space near"));
|
|
expect(spawnSyncMock).toHaveBeenCalled();
|
|
expect(fs.existsSync(path.join(installRoot, "node_modules", "acpx", "package.json"))).toBe(
|
|
true,
|
|
);
|
|
});
|
|
|
|
it("uses an isolated execution root and copies node_modules back when requested", () => {
|
|
const installRoot = makeTempDir();
|
|
const installExecutionRoot = makeTempDir();
|
|
spawnSyncMock.mockImplementation((_command, _args, options) => {
|
|
const cwd = String(options?.cwd ?? "");
|
|
fs.mkdirSync(path.join(cwd, "node_modules", "tokenjuice"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(cwd, "node_modules", "tokenjuice", "package.json"),
|
|
JSON.stringify({ name: "tokenjuice", version: "0.6.1" }),
|
|
);
|
|
return {
|
|
pid: 123,
|
|
output: [],
|
|
stdout: "",
|
|
stderr: "",
|
|
signal: null,
|
|
status: 0,
|
|
};
|
|
});
|
|
|
|
installBundledRuntimeDeps({
|
|
installRoot,
|
|
installExecutionRoot,
|
|
missingSpecs: ["tokenjuice@0.6.1"],
|
|
env: {},
|
|
});
|
|
|
|
expect(
|
|
JSON.parse(fs.readFileSync(path.join(installExecutionRoot, "package.json"), "utf8")),
|
|
).toEqual({
|
|
name: "openclaw-runtime-deps-install",
|
|
private: true,
|
|
});
|
|
expect(
|
|
JSON.parse(
|
|
fs.readFileSync(
|
|
path.join(installRoot, "node_modules", "tokenjuice", "package.json"),
|
|
"utf8",
|
|
),
|
|
),
|
|
).toEqual({
|
|
name: "tokenjuice",
|
|
version: "0.6.1",
|
|
});
|
|
expect(spawnSyncMock).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.any(Array),
|
|
expect.objectContaining({
|
|
cwd: installExecutionRoot,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("uses an OpenClaw-owned npm cache for runtime dependency installs", () => {
|
|
const installRoot = makeTempDir();
|
|
spawnSyncMock.mockImplementation((_command, _args, options) => {
|
|
writeInstalledPackage(String(options?.cwd ?? ""), "tokenjuice", "0.6.1");
|
|
return {
|
|
pid: 123,
|
|
output: [],
|
|
stdout: "",
|
|
stderr: "",
|
|
signal: null,
|
|
status: 0,
|
|
};
|
|
});
|
|
|
|
installBundledRuntimeDeps({
|
|
installRoot,
|
|
missingSpecs: ["tokenjuice@0.6.1"],
|
|
env: {
|
|
HOME: "/Users/alice",
|
|
NPM_CONFIG_CACHE: "/Users/alice/.npm-uppercase",
|
|
NPM_CONFIG_GLOBAL: "true",
|
|
NPM_CONFIG_LOCATION: "global",
|
|
NPM_CONFIG_PREFIX: "/Users/alice",
|
|
npm_config_cache: "/Users/alice/.npm",
|
|
npm_config_global: "true",
|
|
npm_config_location: "global",
|
|
npm_config_prefix: "/opt/homebrew",
|
|
},
|
|
});
|
|
|
|
expect(JSON.parse(fs.readFileSync(path.join(installRoot, "package.json"), "utf8"))).toEqual({
|
|
name: "openclaw-runtime-deps-install",
|
|
private: true,
|
|
});
|
|
expect(spawnSyncMock).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.any(Array),
|
|
expect.objectContaining({
|
|
cwd: installRoot,
|
|
env: expect.objectContaining({
|
|
HOME: "/Users/alice",
|
|
npm_config_cache: path.join(installRoot, ".openclaw-npm-cache"),
|
|
}),
|
|
}),
|
|
);
|
|
expect(spawnSyncMock).toHaveBeenCalledWith(
|
|
expect.any(String),
|
|
expect.any(Array),
|
|
expect.objectContaining({
|
|
env: expect.not.objectContaining({
|
|
NPM_CONFIG_CACHE: expect.any(String),
|
|
NPM_CONFIG_GLOBAL: expect.any(String),
|
|
NPM_CONFIG_LOCATION: expect.any(String),
|
|
NPM_CONFIG_PREFIX: expect.any(String),
|
|
npm_config_global: expect.any(String),
|
|
npm_config_location: expect.any(String),
|
|
npm_config_prefix: expect.any(String),
|
|
}),
|
|
}),
|
|
);
|
|
});
|
|
|
|
it("fails when npm exits cleanly without installing requested packages", () => {
|
|
const installRoot = makeTempDir();
|
|
spawnSyncMock.mockReturnValue({
|
|
pid: 123,
|
|
output: [],
|
|
stdout: "",
|
|
stderr: "",
|
|
signal: null,
|
|
status: 0,
|
|
});
|
|
|
|
expect(() =>
|
|
installBundledRuntimeDeps({
|
|
installRoot,
|
|
missingSpecs: ["tokenjuice@0.6.1"],
|
|
env: {},
|
|
}),
|
|
).toThrow(`npm install did not place bundled runtime deps in ${installRoot}: tokenjuice@0.6.1`);
|
|
});
|
|
|
|
it("cleans an owned isolated execution root after copying node_modules back", () => {
|
|
const installRoot = makeTempDir();
|
|
const installExecutionRoot = path.join(installRoot, ".openclaw-install-stage");
|
|
spawnSyncMock.mockImplementation((_command, _args, options) => {
|
|
const cwd = String(options?.cwd ?? "");
|
|
fs.mkdirSync(path.join(cwd, "node_modules", "tokenjuice"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(cwd, "node_modules", "tokenjuice", "package.json"),
|
|
JSON.stringify({ name: "tokenjuice", version: "0.6.1" }),
|
|
);
|
|
return {
|
|
pid: 123,
|
|
output: [],
|
|
stdout: "",
|
|
stderr: "",
|
|
signal: null,
|
|
status: 0,
|
|
};
|
|
});
|
|
|
|
installBundledRuntimeDeps({
|
|
installRoot,
|
|
installExecutionRoot,
|
|
missingSpecs: ["tokenjuice@0.6.1"],
|
|
env: {},
|
|
});
|
|
|
|
expect(fs.existsSync(installExecutionRoot)).toBe(false);
|
|
expect(
|
|
JSON.parse(
|
|
fs.readFileSync(
|
|
path.join(installRoot, "node_modules", "tokenjuice", "package.json"),
|
|
"utf8",
|
|
),
|
|
),
|
|
).toEqual({
|
|
name: "tokenjuice",
|
|
version: "0.6.1",
|
|
});
|
|
});
|
|
|
|
it("does not fail an isolated runtime deps install when temp cleanup races", () => {
|
|
const installRoot = makeTempDir();
|
|
const installExecutionRoot = makeTempDir();
|
|
const realRmSync = fs.rmSync.bind(fs);
|
|
let blockedCleanup = false;
|
|
vi.spyOn(fs, "rmSync").mockImplementation((target, options) => {
|
|
if (
|
|
!blockedCleanup &&
|
|
path.basename(String(target)).startsWith(".openclaw-runtime-deps-copy-")
|
|
) {
|
|
blockedCleanup = true;
|
|
const error = new Error("Directory not empty") as NodeJS.ErrnoException;
|
|
error.code = "ENOTEMPTY";
|
|
throw error;
|
|
}
|
|
return realRmSync(target, options);
|
|
});
|
|
spawnSyncMock.mockImplementation((_command, _args, options) => {
|
|
const cwd = String(options?.cwd ?? "");
|
|
fs.mkdirSync(path.join(cwd, "node_modules", "tokenjuice"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(cwd, "node_modules", "tokenjuice", "package.json"),
|
|
JSON.stringify({ name: "tokenjuice", version: "0.6.1" }),
|
|
);
|
|
return {
|
|
pid: 123,
|
|
output: [],
|
|
stdout: "",
|
|
stderr: "",
|
|
signal: null,
|
|
status: 0,
|
|
};
|
|
});
|
|
|
|
expect(() =>
|
|
installBundledRuntimeDeps({
|
|
installRoot,
|
|
installExecutionRoot,
|
|
missingSpecs: ["tokenjuice@0.6.1"],
|
|
env: {},
|
|
}),
|
|
).not.toThrow();
|
|
|
|
expect(blockedCleanup).toBe(true);
|
|
expect(
|
|
JSON.parse(
|
|
fs.readFileSync(
|
|
path.join(installRoot, "node_modules", "tokenjuice", "package.json"),
|
|
"utf8",
|
|
),
|
|
),
|
|
).toEqual({
|
|
name: "tokenjuice",
|
|
version: "0.6.1",
|
|
});
|
|
});
|
|
|
|
it("rejects invalid install specs before spawning npm", () => {
|
|
expect(() =>
|
|
createBundledRuntimeDepsInstallArgs(["tokenjuice@https://evil.example/t.tgz"]),
|
|
).toThrow("Unsupported bundled runtime dependency spec for tokenjuice");
|
|
});
|
|
|
|
it("includes spawn errors in install failures", () => {
|
|
spawnSyncMock.mockReturnValue({
|
|
pid: 0,
|
|
output: [],
|
|
stdout: "",
|
|
stderr: "",
|
|
signal: null,
|
|
status: null,
|
|
error: new Error("spawn npm ENOENT"),
|
|
});
|
|
|
|
expect(() =>
|
|
installBundledRuntimeDeps({
|
|
installRoot: "/tmp/openclaw",
|
|
missingSpecs: ["browser-runtime@1.0.0"],
|
|
env: {},
|
|
}),
|
|
).toThrow("spawn npm ENOENT");
|
|
});
|
|
});
|
|
|
|
describe("scanBundledPluginRuntimeDeps config policy", () => {
|
|
type RuntimeDepsConfigCase = {
|
|
name: string;
|
|
config: Parameters<typeof scanBundledPluginRuntimeDeps>[0]["config"];
|
|
includeConfiguredChannels: boolean;
|
|
expectedDeps: string[];
|
|
};
|
|
|
|
function setupPolicyPackageRoot(): string {
|
|
const packageRoot = makeTempDir();
|
|
writeBundledPluginPackage({
|
|
packageRoot,
|
|
pluginId: "alpha",
|
|
deps: { "alpha-runtime": "1.0.0" },
|
|
enabledByDefault: true,
|
|
});
|
|
writeBundledPluginPackage({
|
|
packageRoot,
|
|
pluginId: "telegram",
|
|
deps: { "telegram-runtime": "2.0.0" },
|
|
channels: ["telegram"],
|
|
});
|
|
return packageRoot;
|
|
}
|
|
|
|
const cases: RuntimeDepsConfigCase[] = [
|
|
{
|
|
name: "includes default-enabled bundled plugins",
|
|
config: {},
|
|
includeConfiguredChannels: false,
|
|
expectedDeps: ["alpha-runtime@1.0.0"],
|
|
},
|
|
{
|
|
name: "keeps default-enabled bundled plugins behind restrictive allowlists",
|
|
config: { plugins: { allow: ["browser"] } },
|
|
includeConfiguredChannels: false,
|
|
expectedDeps: [],
|
|
},
|
|
{
|
|
name: "does not let explicit plugin entries bypass restrictive allowlists",
|
|
config: { plugins: { allow: ["browser"], entries: { alpha: { enabled: true } } } },
|
|
includeConfiguredChannels: false,
|
|
expectedDeps: [],
|
|
},
|
|
{
|
|
name: "lets deny override default-enabled bundled plugins",
|
|
config: { plugins: { deny: ["alpha"] } },
|
|
includeConfiguredChannels: false,
|
|
expectedDeps: [],
|
|
},
|
|
{
|
|
name: "lets disabled entries override default-enabled bundled plugins",
|
|
config: { plugins: { entries: { alpha: { enabled: false } } } },
|
|
includeConfiguredChannels: false,
|
|
expectedDeps: [],
|
|
},
|
|
{
|
|
name: "lets plugin deny override explicit bundled channel enablement",
|
|
config: {
|
|
plugins: { deny: ["telegram"] },
|
|
channels: { telegram: { enabled: true } },
|
|
},
|
|
includeConfiguredChannels: false,
|
|
expectedDeps: ["alpha-runtime@1.0.0"],
|
|
},
|
|
{
|
|
name: "lets the plugin master toggle suppress explicit bundled channel enablement",
|
|
config: {
|
|
plugins: { enabled: false },
|
|
channels: { telegram: { enabled: true } },
|
|
},
|
|
includeConfiguredChannels: false,
|
|
expectedDeps: [],
|
|
},
|
|
{
|
|
name: "lets plugin entry disablement override explicit bundled channel enablement",
|
|
config: {
|
|
plugins: { entries: { telegram: { enabled: false } } },
|
|
channels: { telegram: { enabled: true } },
|
|
},
|
|
includeConfiguredChannels: false,
|
|
expectedDeps: ["alpha-runtime@1.0.0"],
|
|
},
|
|
{
|
|
name: "lets explicit bundled channel enablement bypass restrictive allowlists",
|
|
config: {
|
|
plugins: { allow: ["browser"] },
|
|
channels: { telegram: { enabled: true } },
|
|
},
|
|
includeConfiguredChannels: false,
|
|
expectedDeps: ["telegram-runtime@2.0.0"],
|
|
},
|
|
{
|
|
name: "keeps channel recovery behind restrictive allowlists",
|
|
config: {
|
|
plugins: { allow: ["browser"] },
|
|
channels: { telegram: { botToken: "123:abc" } },
|
|
},
|
|
includeConfiguredChannels: true,
|
|
expectedDeps: [],
|
|
},
|
|
{
|
|
name: "includes configured channels during recovery without restrictive allowlists",
|
|
config: { channels: { telegram: { botToken: "123:abc" } } },
|
|
includeConfiguredChannels: true,
|
|
expectedDeps: ["alpha-runtime@1.0.0", "telegram-runtime@2.0.0"],
|
|
},
|
|
{
|
|
name: "lets explicit channel disable override recovery",
|
|
config: { channels: { telegram: { botToken: "123:abc", enabled: false } } },
|
|
includeConfiguredChannels: true,
|
|
expectedDeps: ["alpha-runtime@1.0.0"],
|
|
},
|
|
];
|
|
|
|
it.each(cases)("$name", ({ config, includeConfiguredChannels, expectedDeps }) => {
|
|
const result = scanBundledPluginRuntimeDeps({
|
|
packageRoot: setupPolicyPackageRoot(),
|
|
config,
|
|
includeConfiguredChannels,
|
|
});
|
|
|
|
expect(result.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual(expectedDeps);
|
|
expect(result.conflicts).toEqual([]);
|
|
});
|
|
|
|
it("honors deny and disabled entries when scanning an explicit effective plugin set", () => {
|
|
const packageRoot = setupPolicyPackageRoot();
|
|
|
|
const denied = scanBundledPluginRuntimeDeps({
|
|
packageRoot,
|
|
pluginIds: ["telegram"],
|
|
config: {
|
|
plugins: { deny: ["telegram"] },
|
|
channels: { telegram: { enabled: true } },
|
|
},
|
|
});
|
|
const disabled = scanBundledPluginRuntimeDeps({
|
|
packageRoot,
|
|
pluginIds: ["telegram"],
|
|
config: {
|
|
plugins: { entries: { telegram: { enabled: false } } },
|
|
channels: { telegram: { enabled: true } },
|
|
},
|
|
});
|
|
const allowed = scanBundledPluginRuntimeDeps({
|
|
packageRoot,
|
|
pluginIds: ["telegram"],
|
|
config: {
|
|
plugins: { entries: { telegram: { enabled: true } } },
|
|
channels: { telegram: { enabled: true } },
|
|
},
|
|
});
|
|
|
|
expect(denied.deps).toEqual([]);
|
|
expect(disabled.deps).toEqual([]);
|
|
expect(allowed.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual([
|
|
"telegram-runtime@2.0.0",
|
|
]);
|
|
});
|
|
|
|
it("trusts preselected startup plugin ids without reapplying config policy", () => {
|
|
const result = scanBundledPluginRuntimeDeps({
|
|
packageRoot: setupPolicyPackageRoot(),
|
|
selectedPluginIds: ["telegram"],
|
|
config: {
|
|
plugins: { allow: ["browser"] },
|
|
channels: { telegram: { botToken: "123:abc" } },
|
|
},
|
|
});
|
|
|
|
expect(result.deps.map((dep) => `${dep.name}@${dep.version}`)).toEqual([
|
|
"telegram-runtime@2.0.0",
|
|
]);
|
|
expect(result.conflicts).toEqual([]);
|
|
});
|
|
|
|
it("reads each bundled plugin manifest once per runtime-deps scan", () => {
|
|
const packageRoot = makeTempDir();
|
|
const pluginRoot = writeBundledPluginPackage({
|
|
packageRoot,
|
|
pluginId: "alpha",
|
|
deps: { "alpha-runtime": "1.0.0" },
|
|
enabledByDefault: true,
|
|
channels: ["alpha"],
|
|
});
|
|
const manifestPath = path.join(pluginRoot, "openclaw.plugin.json");
|
|
const readFileSyncSpy = vi.spyOn(fs, "readFileSync");
|
|
|
|
scanBundledPluginRuntimeDeps({ packageRoot, config: {} });
|
|
|
|
expect(
|
|
readFileSyncSpy.mock.calls.filter((call) => path.resolve(String(call[0])) === manifestPath),
|
|
).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe("ensureBundledPluginRuntimeDeps", () => {
|
|
it("installs plugin-local runtime deps when one is missing", () => {
|
|
const packageRoot = makeTempDir();
|
|
const extensionsRoot = path.join(packageRoot, "dist", "extensions");
|
|
const pluginRoot = path.join(extensionsRoot, "bedrock");
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
"already-present": "1.0.0",
|
|
missing: "2.0.0",
|
|
},
|
|
}),
|
|
);
|
|
fs.mkdirSync(path.join(pluginRoot, "node_modules", "already-present"), {
|
|
recursive: true,
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "node_modules", "already-present", "package.json"),
|
|
JSON.stringify({ name: "already-present", version: "1.0.0" }),
|
|
);
|
|
|
|
const calls: Array<{
|
|
installRoot: string;
|
|
installExecutionRoot?: string;
|
|
missingSpecs: string[];
|
|
installSpecs?: string[];
|
|
}> = [];
|
|
|
|
const result = ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
installDeps: (params) => {
|
|
calls.push(params);
|
|
},
|
|
pluginId: "bedrock",
|
|
pluginRoot,
|
|
retainSpecs: ["previous@3.0.0"],
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
installedSpecs: ["already-present@1.0.0", "missing@2.0.0"],
|
|
retainSpecs: ["already-present@1.0.0", "missing@2.0.0", "previous@3.0.0"],
|
|
});
|
|
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} });
|
|
expect(calls).toEqual([
|
|
{
|
|
installRoot,
|
|
missingSpecs: ["already-present@1.0.0", "missing@2.0.0"],
|
|
installSpecs: ["already-present@1.0.0", "missing@2.0.0", "previous@3.0.0"],
|
|
},
|
|
]);
|
|
expect(installRoot).not.toBe(pluginRoot);
|
|
});
|
|
|
|
it("skips workspace-only runtime deps before npm install", () => {
|
|
const packageRoot = makeTempDir();
|
|
const extensionsRoot = path.join(packageRoot, "dist", "extensions");
|
|
const pluginRoot = path.join(extensionsRoot, "qa-channel");
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
"@openclaw/plugin-sdk": "workspace:*",
|
|
"external-runtime": "^1.2.3",
|
|
openclaw: "workspace:*",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const calls: BundledRuntimeDepsInstallParams[] = [];
|
|
const result = ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
installDeps: (params) => {
|
|
calls.push(params);
|
|
},
|
|
pluginId: "qa-channel",
|
|
pluginRoot,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
installedSpecs: ["external-runtime@^1.2.3"],
|
|
retainSpecs: ["external-runtime@^1.2.3"],
|
|
});
|
|
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} });
|
|
expect(calls).toEqual([
|
|
{
|
|
installRoot,
|
|
missingSpecs: ["external-runtime@^1.2.3"],
|
|
installSpecs: ["external-runtime@^1.2.3"],
|
|
},
|
|
]);
|
|
expect(installRoot).not.toBe(pluginRoot);
|
|
});
|
|
|
|
it("uses external staging when a packaged plugin declares workspace:* deps", () => {
|
|
// Regression guard for packaged/Docker bundled plugins whose `package.json`
|
|
// still lists `"@openclaw/plugin-sdk": "workspace:*"` (and similar) alongside
|
|
// concrete runtime deps. Without a distinct execution root, `npm install`
|
|
// would resolve the plugin's own cwd manifest and fail with
|
|
// EUNSUPPORTEDPROTOCOL on the `workspace:` protocol.
|
|
const packageRoot = makeTempDir();
|
|
const extensionsRoot = path.join(packageRoot, "dist", "extensions");
|
|
const pluginRoot = path.join(extensionsRoot, "anthropic");
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
"@openclaw/plugin-sdk": "workspace:*",
|
|
"@anthropic-ai/sdk": "^0.50.0",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const calls: BundledRuntimeDepsInstallParams[] = [];
|
|
const result = ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
installDeps: (params) => {
|
|
calls.push(params);
|
|
},
|
|
pluginId: "anthropic",
|
|
pluginRoot,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
installedSpecs: ["@anthropic-ai/sdk@^0.50.0"],
|
|
retainSpecs: ["@anthropic-ai/sdk@^0.50.0"],
|
|
});
|
|
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} });
|
|
expect(calls).toEqual([
|
|
{
|
|
installRoot,
|
|
missingSpecs: ["@anthropic-ai/sdk@^0.50.0"],
|
|
installSpecs: ["@anthropic-ai/sdk@^0.50.0"],
|
|
},
|
|
]);
|
|
expect(installRoot).not.toBe(pluginRoot);
|
|
});
|
|
|
|
it("installs runtime deps into an external stage dir and exposes loader aliases", () => {
|
|
const packageRoot = makeTempDir();
|
|
const stageDir = makeTempDir();
|
|
fs.writeFileSync(
|
|
path.join(packageRoot, "package.json"),
|
|
JSON.stringify({ name: "openclaw", version: "2026.4.22" }),
|
|
);
|
|
const pluginRoot = path.join(packageRoot, "dist", "extensions", "slack");
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
"@slack/web-api": "7.15.1",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir };
|
|
const calls: BundledRuntimeDepsInstallParams[] = [];
|
|
const result = ensureBundledPluginRuntimeDeps({
|
|
env,
|
|
installDeps: (params) => {
|
|
calls.push(params);
|
|
fs.mkdirSync(path.join(params.installRoot, "node_modules", "@slack", "web-api"), {
|
|
recursive: true,
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(params.installRoot, "node_modules", "@slack", "web-api", "package.json"),
|
|
JSON.stringify({ name: "@slack/web-api", version: "7.15.1" }),
|
|
);
|
|
},
|
|
pluginId: "slack",
|
|
pluginRoot,
|
|
});
|
|
|
|
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env });
|
|
expect(result).toEqual({
|
|
installedSpecs: ["@slack/web-api@7.15.1"],
|
|
retainSpecs: ["@slack/web-api@7.15.1"],
|
|
});
|
|
expect(calls).toEqual([
|
|
{
|
|
installRoot,
|
|
missingSpecs: ["@slack/web-api@7.15.1"],
|
|
installSpecs: ["@slack/web-api@7.15.1"],
|
|
},
|
|
]);
|
|
expect(installRoot).toContain(stageDir);
|
|
expect(installRoot).not.toBe(pluginRoot);
|
|
expect(createBundledRuntimeDependencyAliasMap({ pluginRoot, installRoot })).toEqual({
|
|
"@slack/web-api": path.join(installRoot, "node_modules", "@slack", "web-api"),
|
|
});
|
|
|
|
const second = ensureBundledPluginRuntimeDeps({
|
|
env,
|
|
installDeps: () => {
|
|
throw new Error("external staged deps should not reinstall");
|
|
},
|
|
pluginId: "slack",
|
|
pluginRoot,
|
|
});
|
|
expect(second).toEqual({ installedSpecs: [], retainSpecs: [] });
|
|
});
|
|
|
|
it("retains external staged deps across separate loader passes", () => {
|
|
const packageRoot = makeTempDir();
|
|
const stageDir = makeTempDir();
|
|
fs.writeFileSync(
|
|
path.join(packageRoot, "package.json"),
|
|
JSON.stringify({ name: "openclaw", version: "2026.4.22" }),
|
|
);
|
|
const alphaRoot = path.join(packageRoot, "dist", "extensions", "alpha");
|
|
const betaRoot = path.join(packageRoot, "dist", "extensions", "beta");
|
|
fs.mkdirSync(alphaRoot, { recursive: true });
|
|
fs.mkdirSync(betaRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(alphaRoot, "package.json"),
|
|
JSON.stringify({ dependencies: { "alpha-runtime": "1.0.0" } }),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(betaRoot, "package.json"),
|
|
JSON.stringify({ dependencies: { "beta-runtime": "2.0.0" } }),
|
|
);
|
|
|
|
const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir };
|
|
const calls: BundledRuntimeDepsInstallParams[] = [];
|
|
const installDeps = (params: BundledRuntimeDepsInstallParams) => {
|
|
calls.push(params);
|
|
for (const spec of params.installSpecs ?? params.missingSpecs) {
|
|
const name = spec.slice(0, spec.lastIndexOf("@"));
|
|
fs.mkdirSync(path.join(params.installRoot, "node_modules", name), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(params.installRoot, "node_modules", name, "package.json"),
|
|
JSON.stringify({ name, version: spec.slice(spec.lastIndexOf("@") + 1) }),
|
|
);
|
|
}
|
|
};
|
|
|
|
ensureBundledPluginRuntimeDeps({
|
|
env,
|
|
installDeps,
|
|
pluginId: "alpha",
|
|
pluginRoot: alphaRoot,
|
|
});
|
|
ensureBundledPluginRuntimeDeps({
|
|
env,
|
|
installDeps,
|
|
pluginId: "beta",
|
|
pluginRoot: betaRoot,
|
|
});
|
|
|
|
const installRoot = resolveBundledRuntimeDependencyInstallRoot(alphaRoot, { env });
|
|
expect(calls).toEqual([
|
|
{
|
|
installRoot,
|
|
missingSpecs: ["alpha-runtime@1.0.0"],
|
|
installSpecs: ["alpha-runtime@1.0.0"],
|
|
},
|
|
{
|
|
installRoot,
|
|
missingSpecs: ["beta-runtime@2.0.0"],
|
|
installSpecs: ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("does not derive a second-generation stage root from external runtime mirrors", () => {
|
|
const packageRoot = makeTempDir();
|
|
const stageDir = makeTempDir();
|
|
fs.writeFileSync(
|
|
path.join(packageRoot, "package.json"),
|
|
JSON.stringify({ name: "openclaw", version: "2026.4.25" }),
|
|
);
|
|
const pluginRoot = path.join(packageRoot, "dist", "extensions", "telegram");
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({ dependencies: { grammy: "^1.42.0" } }),
|
|
);
|
|
const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir };
|
|
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env });
|
|
const mirroredPluginRoot = path.join(installRoot, "dist", "extensions", "telegram");
|
|
fs.mkdirSync(mirroredPluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(mirroredPluginRoot, "package.json"),
|
|
JSON.stringify({ dependencies: { grammy: "^1.42.0" } }),
|
|
);
|
|
fs.mkdirSync(path.join(installRoot, "node_modules", "grammy"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(installRoot, "node_modules", "grammy", "package.json"),
|
|
JSON.stringify({ name: "grammy", version: "1.42.0" }),
|
|
);
|
|
|
|
const nestedUnknownRoot = path.join(
|
|
stageDir,
|
|
`openclaw-unknown-${createHash("sha256").update(path.resolve(installRoot)).digest("hex").slice(0, 12)}`,
|
|
);
|
|
|
|
expect(resolveBundledRuntimeDependencyInstallRoot(mirroredPluginRoot, { env })).toBe(
|
|
installRoot,
|
|
);
|
|
expect(resolveBundledRuntimeDependencyInstallRoot(mirroredPluginRoot, { env })).not.toBe(
|
|
nestedUnknownRoot,
|
|
);
|
|
expect(
|
|
ensureBundledPluginRuntimeDeps({
|
|
env,
|
|
installDeps: () => {
|
|
throw new Error("mirrored staged deps should not reinstall into a nested stage root");
|
|
},
|
|
pluginId: "telegram",
|
|
pluginRoot: mirroredPluginRoot,
|
|
}),
|
|
).toEqual({ installedSpecs: [], retainSpecs: [] });
|
|
});
|
|
|
|
it("links source-checkout runtime deps from the cache instead of copying them", () => {
|
|
const packageRoot = makeTempDir();
|
|
fs.writeFileSync(
|
|
path.join(packageRoot, "package.json"),
|
|
JSON.stringify({ name: "openclaw", version: "2026.4.25" }),
|
|
);
|
|
fs.writeFileSync(path.join(packageRoot, "pnpm-workspace.yaml"), "packages: []\n");
|
|
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
|
|
fs.mkdirSync(path.join(packageRoot, "extensions"), { recursive: true });
|
|
const pluginRoot = path.join(packageRoot, "dist", "extensions", "voice-call");
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({ dependencies: { "voice-runtime": "1.0.0" } }),
|
|
);
|
|
spawnSyncMock.mockImplementation((_command, _args, options) => {
|
|
const cwd = String(options?.cwd);
|
|
expect(cwd).toContain(path.join(".local", "bundled-plugin-runtime-deps"));
|
|
const depRoot = path.join(cwd, "node_modules", "voice-runtime");
|
|
fs.mkdirSync(depRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(depRoot, "package.json"),
|
|
JSON.stringify({ name: "voice-runtime", version: "1.0.0" }),
|
|
);
|
|
return { status: 0, stdout: "", stderr: "" } as ReturnType<typeof spawnSync>;
|
|
});
|
|
|
|
expect(
|
|
ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
pluginId: "voice-call",
|
|
pluginRoot,
|
|
}),
|
|
).toEqual({
|
|
installedSpecs: ["voice-runtime@1.0.0"],
|
|
retainSpecs: ["voice-runtime@1.0.0"],
|
|
});
|
|
expect(spawnSyncMock).toHaveBeenCalledTimes(1);
|
|
expect(fs.lstatSync(path.join(pluginRoot, "node_modules")).isSymbolicLink()).toBe(true);
|
|
|
|
fs.rmSync(path.join(pluginRoot, "node_modules"), { recursive: true, force: true });
|
|
expect(
|
|
ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
installDeps: () => {
|
|
throw new Error("cache restore should not reinstall");
|
|
},
|
|
pluginId: "voice-call",
|
|
pluginRoot,
|
|
}),
|
|
).toEqual({ installedSpecs: [], retainSpecs: [] });
|
|
expect(fs.lstatSync(path.join(pluginRoot, "node_modules")).isSymbolicLink()).toBe(true);
|
|
});
|
|
|
|
it("retains existing staged deps without a retained manifest before shared installs", () => {
|
|
const packageRoot = makeTempDir();
|
|
const stageDir = makeTempDir();
|
|
fs.writeFileSync(
|
|
path.join(packageRoot, "package.json"),
|
|
JSON.stringify({ name: "openclaw", version: "2026.4.22" }),
|
|
);
|
|
const alphaRoot = path.join(packageRoot, "dist", "extensions", "alpha");
|
|
const betaRoot = path.join(packageRoot, "dist", "extensions", "beta");
|
|
fs.mkdirSync(alphaRoot, { recursive: true });
|
|
fs.mkdirSync(betaRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(alphaRoot, "package.json"),
|
|
JSON.stringify({ dependencies: { "alpha-runtime": "1.0.0" } }),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(betaRoot, "package.json"),
|
|
JSON.stringify({ dependencies: { "beta-runtime": "2.0.0" } }),
|
|
);
|
|
|
|
const env = { OPENCLAW_PLUGIN_STAGE_DIR: stageDir };
|
|
const installRoot = resolveBundledRuntimeDependencyInstallRoot(alphaRoot, { env });
|
|
fs.mkdirSync(path.join(installRoot, "node_modules", "alpha-runtime"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(installRoot, "node_modules", "alpha-runtime", "package.json"),
|
|
JSON.stringify({ name: "alpha-runtime", version: "1.0.0" }),
|
|
);
|
|
expect(fs.existsSync(path.join(installRoot, ".openclaw-runtime-deps.json"))).toBe(false);
|
|
|
|
const calls: BundledRuntimeDepsInstallParams[] = [];
|
|
const result = ensureBundledPluginRuntimeDeps({
|
|
env,
|
|
installDeps: (params) => {
|
|
calls.push(params);
|
|
},
|
|
pluginId: "beta",
|
|
pluginRoot: betaRoot,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
installedSpecs: ["beta-runtime@2.0.0"],
|
|
retainSpecs: ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"],
|
|
});
|
|
expect(calls).toEqual([
|
|
{
|
|
installRoot,
|
|
missingSpecs: ["beta-runtime@2.0.0"],
|
|
installSpecs: ["alpha-runtime@1.0.0", "beta-runtime@2.0.0"],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("tracks active runtime-deps installs until the installer returns", async () => {
|
|
const packageRoot = makeTempDir();
|
|
const pluginRoot = path.join(packageRoot, "dist", "extensions", "browser");
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({ dependencies: { "browser-runtime": "1.0.0" } }),
|
|
);
|
|
|
|
let idleWait: Promise<{ drained: boolean; active: number }> | null = null;
|
|
expect(getActiveBundledRuntimeDepsInstallCount()).toBe(0);
|
|
const result = ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
installDeps: (params) => {
|
|
expect(getActiveBundledRuntimeDepsInstallCount()).toBe(1);
|
|
idleWait = waitForBundledRuntimeDepsInstallIdle();
|
|
writeInstalledPackage(params.installRoot, "browser-runtime", "1.0.0");
|
|
},
|
|
pluginId: "browser",
|
|
pluginRoot,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
installedSpecs: ["browser-runtime@1.0.0"],
|
|
retainSpecs: ["browser-runtime@1.0.0"],
|
|
});
|
|
expect(getActiveBundledRuntimeDepsInstallCount()).toBe(0);
|
|
await expect(idleWait).resolves.toEqual({ drained: true, active: 0 });
|
|
});
|
|
|
|
it("keeps async repair locks and activity active until npm staging settles", async () => {
|
|
const installRoot = makeTempDir();
|
|
const lockDir = path.join(installRoot, ".openclaw-runtime-deps.lock");
|
|
let releaseInstall!: () => void;
|
|
const repair = repairBundledRuntimeDepsInstallRootAsync({
|
|
installRoot,
|
|
missingSpecs: ["browser-runtime@1.0.0"],
|
|
installSpecs: ["browser-runtime@1.0.0"],
|
|
env: {},
|
|
installDeps: async (params) => {
|
|
expect(fs.existsSync(lockDir)).toBe(true);
|
|
expect(getActiveBundledRuntimeDepsInstallCount()).toBe(1);
|
|
await new Promise<void>((resolve) => {
|
|
releaseInstall = () => {
|
|
writeInstalledPackage(params.installRoot, "browser-runtime", "1.0.0");
|
|
resolve();
|
|
};
|
|
});
|
|
},
|
|
});
|
|
|
|
await Promise.resolve();
|
|
expect(fs.existsSync(lockDir)).toBe(true);
|
|
expect(getActiveBundledRuntimeDepsInstallCount()).toBe(1);
|
|
|
|
releaseInstall();
|
|
await expect(repair).resolves.toEqual({ installSpecs: ["browser-runtime@1.0.0"] });
|
|
expect(fs.existsSync(lockDir)).toBe(false);
|
|
expect(getActiveBundledRuntimeDepsInstallCount()).toBe(0);
|
|
});
|
|
|
|
it("does not expire active runtime-deps install locks by age alone", () => {
|
|
expect(
|
|
bundledRuntimeDepsTesting.shouldRemoveRuntimeDepsLock(
|
|
{ pid: 123, createdAtMs: 0 },
|
|
Number.MAX_SAFE_INTEGER,
|
|
() => true,
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does not expire fresh ownerless runtime-deps install locks", () => {
|
|
expect(
|
|
bundledRuntimeDepsTesting.shouldRemoveRuntimeDepsLock(
|
|
{ lockDirMtimeMs: 1_000 },
|
|
31_000,
|
|
() => true,
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("does not expire ownerless runtime-deps install locks when the owner file changed recently", () => {
|
|
expect(
|
|
bundledRuntimeDepsTesting.shouldRemoveRuntimeDepsLock(
|
|
{ lockDirMtimeMs: 1_000, ownerFileMtimeMs: 31_000 },
|
|
61_000,
|
|
() => true,
|
|
),
|
|
).toBe(false);
|
|
});
|
|
|
|
it("expires ownerless runtime-deps install locks after the owner write grace window", () => {
|
|
expect(
|
|
bundledRuntimeDepsTesting.shouldRemoveRuntimeDepsLock(
|
|
{ lockDirMtimeMs: 1_000 },
|
|
31_001,
|
|
() => true,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("expires ownerless runtime-deps install locks when lock and owner file are stale", () => {
|
|
expect(
|
|
bundledRuntimeDepsTesting.shouldRemoveRuntimeDepsLock(
|
|
{ lockDirMtimeMs: 1_000, ownerFileMtimeMs: 2_000 },
|
|
32_001,
|
|
() => true,
|
|
),
|
|
).toBe(true);
|
|
});
|
|
|
|
it("includes runtime-deps lock owner details in timeout messages", () => {
|
|
const message = bundledRuntimeDepsTesting.formatRuntimeDepsLockTimeoutMessage({
|
|
lockDir: "/tmp/openclaw-plugin/.openclaw-runtime-deps.lock",
|
|
owner: {
|
|
pid: 0,
|
|
createdAtMs: 1_000,
|
|
ownerFileState: "invalid",
|
|
ownerFilePath: "/tmp/openclaw-plugin/.openclaw-runtime-deps.lock/owner.json",
|
|
ownerFileMtimeMs: 2_500,
|
|
ownerFileIsSymlink: true,
|
|
lockDirMtimeMs: 2_000,
|
|
},
|
|
waitedMs: 300_123,
|
|
nowMs: 303_000,
|
|
});
|
|
|
|
expect(message).toContain("waited=300123ms");
|
|
expect(message).toContain("ownerFile=invalid");
|
|
expect(message).toContain("ownerFileSymlink=true");
|
|
expect(message).toContain("pid=0 alive=false");
|
|
expect(message).toContain("ownerAge=302000ms");
|
|
expect(message).toContain("ownerFileAge=300500ms");
|
|
expect(message).toContain("lockAge=301000ms");
|
|
expect(message).toContain(".openclaw-runtime-deps.lock/owner.json");
|
|
});
|
|
|
|
it("removes stale runtime-deps install locks before repairing deps", () => {
|
|
const packageRoot = makeTempDir();
|
|
const pluginRoot = path.join(packageRoot, "dist", "extensions", "openai");
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
"@mariozechner/pi-ai": "0.70.2",
|
|
},
|
|
}),
|
|
);
|
|
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} });
|
|
const lockDir = path.join(installRoot, ".openclaw-runtime-deps.lock");
|
|
fs.mkdirSync(lockDir, { recursive: true });
|
|
fs.writeFileSync(path.join(lockDir, "owner.json"), JSON.stringify({ pid: 0, createdAtMs: 0 }));
|
|
|
|
const calls: BundledRuntimeDepsInstallParams[] = [];
|
|
const result = ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
installDeps: (params) => {
|
|
calls.push(params);
|
|
fs.mkdirSync(path.join(params.installRoot, "node_modules", "@mariozechner", "pi-ai"), {
|
|
recursive: true,
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(params.installRoot, "node_modules", "@mariozechner", "pi-ai", "package.json"),
|
|
JSON.stringify({ name: "@mariozechner/pi-ai", version: "0.70.2" }),
|
|
);
|
|
},
|
|
pluginId: "openai",
|
|
pluginRoot,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
installedSpecs: ["@mariozechner/pi-ai@0.70.2"],
|
|
retainSpecs: ["@mariozechner/pi-ai@0.70.2"],
|
|
});
|
|
expect(calls).toHaveLength(1);
|
|
expect(fs.existsSync(lockDir)).toBe(false);
|
|
});
|
|
|
|
it("removes stale malformed runtime-deps install locks before repairing deps", () => {
|
|
const packageRoot = makeTempDir();
|
|
const pluginRoot = path.join(packageRoot, "dist", "extensions", "browser");
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
"browser-runtime": "1.0.0",
|
|
},
|
|
}),
|
|
);
|
|
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} });
|
|
const lockDir = path.join(installRoot, ".openclaw-runtime-deps.lock");
|
|
fs.mkdirSync(lockDir, { recursive: true });
|
|
const ownerPath = path.join(lockDir, "owner.json");
|
|
fs.writeFileSync(ownerPath, "{", "utf8");
|
|
fs.utimesSync(ownerPath, new Date(0), new Date(0));
|
|
fs.utimesSync(lockDir, new Date(0), new Date(0));
|
|
|
|
const calls: BundledRuntimeDepsInstallParams[] = [];
|
|
const result = ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
installDeps: (params) => {
|
|
calls.push(params);
|
|
fs.mkdirSync(path.join(params.installRoot, "node_modules", "browser-runtime"), {
|
|
recursive: true,
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(params.installRoot, "node_modules", "browser-runtime", "package.json"),
|
|
JSON.stringify({ name: "browser-runtime", version: "1.0.0" }),
|
|
);
|
|
},
|
|
pluginId: "browser",
|
|
pluginRoot,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
installedSpecs: ["browser-runtime@1.0.0"],
|
|
retainSpecs: ["browser-runtime@1.0.0"],
|
|
});
|
|
expect(calls).toHaveLength(1);
|
|
expect(fs.existsSync(lockDir)).toBe(false);
|
|
});
|
|
|
|
const itSupportsSymlinks = process.platform === "win32" ? it.skip : it;
|
|
itSupportsSymlinks(
|
|
"removes stale runtime-deps install locks with broken owner symlinks before repairing deps",
|
|
() => {
|
|
const packageRoot = makeTempDir();
|
|
const pluginRoot = path.join(packageRoot, "dist-runtime", "extensions", "browser");
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
"browser-runtime": "1.0.0",
|
|
},
|
|
}),
|
|
);
|
|
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} });
|
|
const lockDir = path.join(installRoot, ".openclaw-runtime-deps.lock");
|
|
fs.mkdirSync(lockDir, { recursive: true });
|
|
const ownerPath = path.join(lockDir, "owner.json");
|
|
fs.symlinkSync("../missing-owner.json", ownerPath);
|
|
fs.lutimesSync(ownerPath, new Date(0), new Date(0));
|
|
fs.utimesSync(lockDir, new Date(0), new Date(0));
|
|
|
|
const calls: BundledRuntimeDepsInstallParams[] = [];
|
|
const result = ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
installDeps: (params) => {
|
|
calls.push(params);
|
|
fs.mkdirSync(path.join(params.installRoot, "node_modules", "browser-runtime"), {
|
|
recursive: true,
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(params.installRoot, "node_modules", "browser-runtime", "package.json"),
|
|
JSON.stringify({ name: "browser-runtime", version: "1.0.0" }),
|
|
);
|
|
},
|
|
pluginId: "browser",
|
|
pluginRoot,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
installedSpecs: ["browser-runtime@1.0.0"],
|
|
retainSpecs: ["browser-runtime@1.0.0"],
|
|
});
|
|
expect(calls).toHaveLength(1);
|
|
expect(fs.existsSync(lockDir)).toBe(false);
|
|
},
|
|
);
|
|
|
|
it("does not install when runtime deps are only workspace links", () => {
|
|
const packageRoot = makeTempDir();
|
|
const extensionsRoot = path.join(packageRoot, "dist", "extensions");
|
|
const pluginRoot = path.join(extensionsRoot, "qa-channel");
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
"@openclaw/plugin-sdk": "workspace:*",
|
|
openclaw: "workspace:*",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
installDeps: () => {
|
|
throw new Error("workspace-only runtime deps should not install");
|
|
},
|
|
pluginId: "qa-channel",
|
|
pluginRoot,
|
|
});
|
|
|
|
expect(result).toEqual({ installedSpecs: [], retainSpecs: [] });
|
|
});
|
|
|
|
it("installs missing runtime deps for source-checkout bundled plugins", () => {
|
|
const packageRoot = makeTempDir();
|
|
const stageDir = makeTempDir();
|
|
fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true });
|
|
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
|
|
const pluginRoot = path.join(packageRoot, "extensions", "tokenjuice");
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
tokenjuice: "0.6.1",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const calls: BundledRuntimeDepsInstallParams[] = [];
|
|
const result = ensureBundledPluginRuntimeDeps({
|
|
env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir },
|
|
installDeps: (params) => {
|
|
calls.push(params);
|
|
},
|
|
pluginId: "tokenjuice",
|
|
pluginRoot,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
installedSpecs: ["tokenjuice@0.6.1"],
|
|
retainSpecs: ["tokenjuice@0.6.1"],
|
|
});
|
|
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, {
|
|
env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir },
|
|
});
|
|
expect(calls).toEqual([
|
|
{
|
|
installRoot,
|
|
missingSpecs: ["tokenjuice@0.6.1"],
|
|
installSpecs: ["tokenjuice@0.6.1"],
|
|
},
|
|
]);
|
|
expect(installRoot).toContain(stageDir);
|
|
expect(installRoot).not.toBe(pluginRoot);
|
|
expect(
|
|
JSON.parse(fs.readFileSync(path.join(installRoot, ".openclaw-runtime-deps.json"), "utf8")),
|
|
).toEqual({ specs: ["tokenjuice@0.6.1"] });
|
|
});
|
|
|
|
it("keeps source-checkout bundled runtime deps in the plugin root without manifest churn", () => {
|
|
const packageRoot = makeTempDir();
|
|
fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true });
|
|
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
|
|
const pluginRoot = path.join(packageRoot, "extensions", "tokenjuice");
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, ".openclaw-runtime-deps.json"),
|
|
JSON.stringify({ specs: ["stale@9.9.9"] }),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
tokenjuice: "0.6.1",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const calls: BundledRuntimeDepsInstallParams[] = [];
|
|
const result = ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
installDeps: (params) => {
|
|
calls.push(params);
|
|
},
|
|
pluginId: "tokenjuice",
|
|
pluginRoot,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
installedSpecs: ["tokenjuice@0.6.1"],
|
|
retainSpecs: ["tokenjuice@0.6.1"],
|
|
});
|
|
expect(calls).toEqual([
|
|
{
|
|
installRoot: pluginRoot,
|
|
installExecutionRoot: expect.stringContaining(
|
|
path.join(".local", "bundled-plugin-runtime-deps"),
|
|
),
|
|
linkNodeModulesFromExecutionRoot: true,
|
|
missingSpecs: ["tokenjuice@0.6.1"],
|
|
installSpecs: ["tokenjuice@0.6.1"],
|
|
},
|
|
]);
|
|
expect(resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} })).toBe(pluginRoot);
|
|
expect(fs.existsSync(path.join(pluginRoot, ".openclaw-runtime-deps.json"))).toBe(false);
|
|
});
|
|
|
|
it("removes stale source-checkout manifests even when runtime deps are present", () => {
|
|
const packageRoot = makeTempDir();
|
|
fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true });
|
|
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
|
|
const pluginRoot = path.join(packageRoot, "extensions", "tokenjuice");
|
|
fs.mkdirSync(path.join(pluginRoot, "node_modules", "tokenjuice"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
tokenjuice: "0.6.1",
|
|
},
|
|
}),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "node_modules", "tokenjuice", "package.json"),
|
|
JSON.stringify({ name: "tokenjuice", version: "0.6.1" }),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, ".openclaw-runtime-deps.json"),
|
|
JSON.stringify({ specs: ["stale@9.9.9"] }),
|
|
);
|
|
|
|
const result = ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
installDeps: () => {
|
|
throw new Error("present source-checkout runtime deps should not reinstall");
|
|
},
|
|
pluginId: "tokenjuice",
|
|
pluginRoot,
|
|
});
|
|
|
|
expect(result).toEqual({ installedSpecs: [], retainSpecs: [] });
|
|
expect(fs.existsSync(path.join(pluginRoot, ".openclaw-runtime-deps.json"))).toBe(false);
|
|
});
|
|
|
|
it("treats Docker build source trees without .git as source checkouts", () => {
|
|
const packageRoot = makeTempDir();
|
|
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
|
|
fs.writeFileSync(path.join(packageRoot, "pnpm-workspace.yaml"), "packages:\n - .\n");
|
|
const pluginRoot = path.join(packageRoot, "extensions", "acpx");
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
acpx: "0.5.3",
|
|
},
|
|
devDependencies: {
|
|
"@openclaw/plugin-sdk": "workspace:*",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const calls: BundledRuntimeDepsInstallParams[] = [];
|
|
const result = ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
installDeps: (params) => {
|
|
calls.push(params);
|
|
},
|
|
pluginId: "acpx",
|
|
pluginRoot,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
installedSpecs: ["acpx@0.5.3"],
|
|
retainSpecs: ["acpx@0.5.3"],
|
|
});
|
|
expect(calls).toEqual([
|
|
{
|
|
installRoot: pluginRoot,
|
|
installExecutionRoot: expect.stringContaining(
|
|
path.join(".local", "bundled-plugin-runtime-deps"),
|
|
),
|
|
linkNodeModulesFromExecutionRoot: true,
|
|
missingSpecs: ["acpx@0.5.3"],
|
|
installSpecs: ["acpx@0.5.3"],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("does not trust package-root runtime deps for source-checkout bundled plugins", () => {
|
|
const packageRoot = makeTempDir();
|
|
const stageDir = makeTempDir();
|
|
fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true });
|
|
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
|
|
const pluginRoot = path.join(packageRoot, "extensions", "tokenjuice");
|
|
fs.mkdirSync(path.join(packageRoot, "node_modules", "tokenjuice"), {
|
|
recursive: true,
|
|
});
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
tokenjuice: "0.6.1",
|
|
},
|
|
}),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(packageRoot, "node_modules", "tokenjuice", "package.json"),
|
|
JSON.stringify({ name: "tokenjuice", version: "0.6.1" }),
|
|
);
|
|
const calls: BundledRuntimeDepsInstallParams[] = [];
|
|
|
|
const result = ensureBundledPluginRuntimeDeps({
|
|
env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir },
|
|
installDeps: (params) => {
|
|
calls.push(params);
|
|
},
|
|
pluginId: "tokenjuice",
|
|
pluginRoot,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
installedSpecs: ["tokenjuice@0.6.1"],
|
|
retainSpecs: ["tokenjuice@0.6.1"],
|
|
});
|
|
expect(calls).toEqual([
|
|
{
|
|
installRoot: resolveBundledRuntimeDependencyInstallRoot(pluginRoot, {
|
|
env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir },
|
|
}),
|
|
missingSpecs: ["tokenjuice@0.6.1"],
|
|
installSpecs: ["tokenjuice@0.6.1"],
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("does not reuse mismatched package-root runtime deps for source-checkout bundled plugins", () => {
|
|
const packageRoot = makeTempDir();
|
|
const stageDir = makeTempDir();
|
|
fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true });
|
|
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
|
|
const pluginRoot = path.join(packageRoot, "extensions", "tokenjuice");
|
|
fs.mkdirSync(path.join(packageRoot, "node_modules", "tokenjuice"), {
|
|
recursive: true,
|
|
});
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
tokenjuice: "0.6.1",
|
|
},
|
|
}),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(packageRoot, "node_modules", "tokenjuice", "package.json"),
|
|
JSON.stringify({ name: "tokenjuice", version: "0.6.0" }),
|
|
);
|
|
|
|
const calls: BundledRuntimeDepsInstallParams[] = [];
|
|
const result = ensureBundledPluginRuntimeDeps({
|
|
env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir },
|
|
installDeps: (params) => {
|
|
calls.push(params);
|
|
},
|
|
pluginId: "tokenjuice",
|
|
pluginRoot,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
installedSpecs: ["tokenjuice@0.6.1"],
|
|
retainSpecs: ["tokenjuice@0.6.1"],
|
|
});
|
|
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, {
|
|
env: { OPENCLAW_PLUGIN_STAGE_DIR: stageDir },
|
|
});
|
|
expect(calls).toEqual([
|
|
{
|
|
installRoot,
|
|
missingSpecs: ["tokenjuice@0.6.1"],
|
|
installSpecs: ["tokenjuice@0.6.1"],
|
|
},
|
|
]);
|
|
expect(installRoot).toContain(stageDir);
|
|
expect(installRoot).not.toBe(pluginRoot);
|
|
});
|
|
|
|
it("repairs external staged deps even when packaged plugin-local deps are present", () => {
|
|
const packageRoot = makeTempDir();
|
|
const extensionsRoot = path.join(packageRoot, "dist", "extensions");
|
|
const pluginRoot = path.join(extensionsRoot, "discord");
|
|
fs.mkdirSync(path.join(pluginRoot, "node_modules", "@buape", "carbon"), {
|
|
recursive: true,
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
"@buape/carbon": "0.16.0",
|
|
},
|
|
}),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "node_modules", "@buape", "carbon", "package.json"),
|
|
JSON.stringify({ name: "@buape/carbon", version: "0.16.0" }),
|
|
);
|
|
|
|
const calls: BundledRuntimeDepsInstallParams[] = [];
|
|
const result = ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
installDeps: (params) => {
|
|
calls.push(params);
|
|
fs.mkdirSync(path.join(params.installRoot, "node_modules", "@buape", "carbon"), {
|
|
recursive: true,
|
|
});
|
|
fs.writeFileSync(
|
|
path.join(params.installRoot, "node_modules", "@buape", "carbon", "package.json"),
|
|
JSON.stringify({ name: "@buape/carbon", version: "0.16.0" }),
|
|
);
|
|
},
|
|
pluginId: "discord",
|
|
pluginRoot,
|
|
});
|
|
|
|
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} });
|
|
expect(result).toEqual({
|
|
installedSpecs: ["@buape/carbon@0.16.0"],
|
|
retainSpecs: ["@buape/carbon@0.16.0"],
|
|
});
|
|
expect(calls).toEqual([
|
|
{
|
|
installRoot,
|
|
missingSpecs: ["@buape/carbon@0.16.0"],
|
|
installSpecs: ["@buape/carbon@0.16.0"],
|
|
},
|
|
]);
|
|
expect(installRoot).not.toBe(pluginRoot);
|
|
});
|
|
|
|
it("does not trust runtime deps that only resolve from the package root", () => {
|
|
const packageRoot = makeTempDir();
|
|
const pluginRoot = path.join(packageRoot, "dist", "extensions", "openai");
|
|
fs.mkdirSync(path.join(packageRoot, "node_modules", "@mariozechner", "pi-ai"), {
|
|
recursive: true,
|
|
});
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
"@mariozechner/pi-ai": "0.68.1",
|
|
},
|
|
}),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(packageRoot, "node_modules", "@mariozechner", "pi-ai", "package.json"),
|
|
JSON.stringify({ name: "@mariozechner/pi-ai", version: "0.68.1" }),
|
|
);
|
|
const calls: BundledRuntimeDepsInstallParams[] = [];
|
|
|
|
const result = ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
installDeps: (params) => {
|
|
calls.push(params);
|
|
},
|
|
pluginId: "openai",
|
|
pluginRoot,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
installedSpecs: ["@mariozechner/pi-ai@0.68.1"],
|
|
retainSpecs: ["@mariozechner/pi-ai@0.68.1"],
|
|
});
|
|
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} });
|
|
expect(calls).toEqual([
|
|
{
|
|
installRoot,
|
|
missingSpecs: ["@mariozechner/pi-ai@0.68.1"],
|
|
installSpecs: ["@mariozechner/pi-ai@0.68.1"],
|
|
},
|
|
]);
|
|
expect(installRoot).not.toBe(pluginRoot);
|
|
});
|
|
|
|
it("installs deps that are only present in the package root", () => {
|
|
const packageRoot = makeTempDir();
|
|
const pluginRoot = path.join(packageRoot, "dist", "extensions", "codex");
|
|
fs.mkdirSync(path.join(packageRoot, "node_modules", "ws"), { recursive: true });
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
ws: "^8.20.0",
|
|
zod: "^4.3.6",
|
|
},
|
|
}),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(packageRoot, "node_modules", "ws", "package.json"),
|
|
JSON.stringify({ name: "ws", version: "8.20.0" }),
|
|
);
|
|
const calls: BundledRuntimeDepsInstallParams[] = [];
|
|
|
|
const result = ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
installDeps: (params) => {
|
|
calls.push(params);
|
|
},
|
|
pluginId: "codex",
|
|
pluginRoot,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
installedSpecs: ["ws@^8.20.0", "zod@^4.3.6"],
|
|
retainSpecs: ["ws@^8.20.0", "zod@^4.3.6"],
|
|
});
|
|
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} });
|
|
expect(calls).toEqual([
|
|
{
|
|
installRoot,
|
|
missingSpecs: ["ws@^8.20.0", "zod@^4.3.6"],
|
|
installSpecs: ["ws@^8.20.0", "zod@^4.3.6"],
|
|
},
|
|
]);
|
|
expect(installRoot).not.toBe(pluginRoot);
|
|
});
|
|
|
|
it("does not treat sibling extension runtime deps as satisfying a plugin", () => {
|
|
const packageRoot = makeTempDir();
|
|
const extensionsRoot = path.join(packageRoot, "dist", "extensions");
|
|
const pluginRoot = path.join(extensionsRoot, "codex");
|
|
fs.mkdirSync(path.join(extensionsRoot, "discord", "node_modules", "zod"), {
|
|
recursive: true,
|
|
});
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
zod: "^4.3.6",
|
|
},
|
|
}),
|
|
);
|
|
fs.writeFileSync(
|
|
path.join(extensionsRoot, "discord", "node_modules", "zod", "package.json"),
|
|
JSON.stringify({ name: "zod", version: "4.3.6" }),
|
|
);
|
|
const calls: BundledRuntimeDepsInstallParams[] = [];
|
|
|
|
const result = ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
installDeps: (params) => {
|
|
calls.push(params);
|
|
},
|
|
pluginId: "codex",
|
|
pluginRoot,
|
|
});
|
|
|
|
expect(result).toEqual({
|
|
installedSpecs: ["zod@^4.3.6"],
|
|
retainSpecs: ["zod@^4.3.6"],
|
|
});
|
|
const installRoot = resolveBundledRuntimeDependencyInstallRoot(pluginRoot, { env: {} });
|
|
expect(calls).toEqual([
|
|
{
|
|
installRoot,
|
|
missingSpecs: ["zod@^4.3.6"],
|
|
installSpecs: ["zod@^4.3.6"],
|
|
},
|
|
]);
|
|
expect(installRoot).not.toBe(pluginRoot);
|
|
});
|
|
|
|
it("rejects unsupported remote runtime dependency specs", () => {
|
|
const packageRoot = makeTempDir();
|
|
const pluginRoot = path.join(packageRoot, "dist", "extensions", "tokenjuice");
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
tokenjuice: "https://evil.example/tokenjuice.tgz",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(() =>
|
|
ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
installDeps: () => {
|
|
throw new Error("should not attempt install");
|
|
},
|
|
pluginId: "tokenjuice",
|
|
pluginRoot,
|
|
}),
|
|
).toThrow("Unsupported bundled runtime dependency spec for tokenjuice");
|
|
});
|
|
|
|
it("rejects invalid runtime dependency names before resolving sentinels", () => {
|
|
const packageRoot = makeTempDir();
|
|
const pluginRoot = path.join(packageRoot, "dist", "extensions", "tokenjuice");
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
"../escape": "0.6.1",
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(() =>
|
|
ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
installDeps: () => {
|
|
throw new Error("should not attempt install");
|
|
},
|
|
pluginId: "tokenjuice",
|
|
pluginRoot,
|
|
}),
|
|
).toThrow("Invalid bundled runtime dependency name");
|
|
});
|
|
|
|
it("rehydrates source-checkout dist deps from cache after rebuilds", () => {
|
|
const packageRoot = makeTempDir();
|
|
fs.mkdirSync(path.join(packageRoot, ".git"), { recursive: true });
|
|
fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true });
|
|
fs.mkdirSync(path.join(packageRoot, "extensions"), { recursive: true });
|
|
const pluginRoot = path.join(packageRoot, "dist", "extensions", "codex");
|
|
fs.mkdirSync(pluginRoot, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(pluginRoot, "package.json"),
|
|
JSON.stringify({
|
|
dependencies: {
|
|
zod: "^4.3.6",
|
|
},
|
|
}),
|
|
);
|
|
const installCalls: BundledRuntimeDepsInstallParams[] = [];
|
|
|
|
const first = ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
installDeps: (params) => {
|
|
installCalls.push(params);
|
|
fs.mkdirSync(path.join(params.installRoot, "node_modules", "zod"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(params.installRoot, "node_modules", "zod", "package.json"),
|
|
JSON.stringify({ name: "zod", version: "4.3.6" }),
|
|
);
|
|
},
|
|
pluginId: "codex",
|
|
pluginRoot,
|
|
});
|
|
|
|
fs.rmSync(path.join(pluginRoot, "node_modules"), { recursive: true, force: true });
|
|
|
|
const second = ensureBundledPluginRuntimeDeps({
|
|
env: {},
|
|
installDeps: () => {
|
|
throw new Error("cached runtime deps should not reinstall");
|
|
},
|
|
pluginId: "codex",
|
|
pluginRoot,
|
|
});
|
|
|
|
expect(first).toEqual({
|
|
installedSpecs: ["zod@^4.3.6"],
|
|
retainSpecs: ["zod@^4.3.6"],
|
|
});
|
|
expect(second).toEqual({ installedSpecs: [], retainSpecs: [] });
|
|
expect(installCalls).toHaveLength(1);
|
|
expect(fs.existsSync(path.join(pluginRoot, "node_modules", "zod", "package.json"))).toBe(true);
|
|
});
|
|
});
|