fix(plugins): harden bundled runtime dep staging

This commit is contained in:
Peter Steinberger
2026-04-22 18:48:35 +01:00
parent 0e9c632444
commit 9d66a900e5
4 changed files with 64 additions and 2 deletions

View File

@@ -183,6 +183,12 @@ function assertSafeInstalledDistPath(relativePath, params) {
return candidatePath;
}
function isStagedRuntimeNodeModulesPath(relativePath) {
return /^dist\/extensions\/[^/]+\/node_modules(?:\/|$)/u.test(
normalizeRelativePath(relativePath),
);
}
function listInstalledDistFiles(params = {}) {
const readDir = params.readdirSync ?? readdirSync;
const distRoot = resolveInstalledDistRoot(params);
@@ -197,6 +203,10 @@ function listInstalledDistFiles(params = {}) {
if (!currentDir) {
continue;
}
const relativeCurrentDir = normalizeRelativePath(relative(packageRoot, currentDir));
if (isStagedRuntimeNodeModulesPath(relativeCurrentDir)) {
continue;
}
for (const entry of readDir(currentDir, { withFileTypes: true })) {
const entryPath = join(currentDir, entry.name);
if (entry.isSymbolicLink()) {
@@ -232,6 +242,10 @@ function pruneEmptyDistDirectories(params = {}) {
const pathLstat = params.lstatSync ?? lstatSync;
function prune(currentDir) {
const relativeCurrentDir = normalizeRelativePath(relative(packageRoot, currentDir));
if (isStagedRuntimeNodeModulesPath(relativeCurrentDir)) {
return;
}
for (const entry of readDir(currentDir, { withFileTypes: true })) {
if (entry.isSymbolicLink()) {
throw new Error(

View File

@@ -9,6 +9,7 @@ import {
createBundledRuntimeDepsInstallEnv,
ensureBundledPluginRuntimeDeps,
installBundledRuntimeDeps,
isWritableDirectory,
resolveBundledRuntimeDependencyInstallRoot,
resolveBundledRuntimeDepsNpmRunner,
type BundledRuntimeDepsInstallParams,
@@ -145,6 +146,21 @@ describe("resolveBundledRuntimeDepsNpmRunner", () => {
});
describe("installBundledRuntimeDeps", () => {
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", () => {
vi.spyOn(process, "platform", "get").mockReturnValue("win32");
vi.spyOn(fs, "existsSync").mockImplementation(

View File

@@ -263,12 +263,23 @@ function writeRetainedRuntimeDepsManifest(installRoot: string, specs: readonly s
);
}
function isWritableDirectory(dir: string): boolean {
export function isWritableDirectory(dir: string): boolean {
let probeDir: string | null = null;
try {
fs.accessSync(dir, fs.constants.W_OK);
probeDir = fs.mkdtempSync(path.join(dir, ".openclaw-write-probe-"));
fs.writeFileSync(path.join(probeDir, "probe"), "", "utf8");
return true;
} catch {
return false;
} finally {
if (probeDir) {
try {
fs.rmSync(probeDir, { recursive: true, force: true });
} catch {
// Best-effort cleanup. A failed cleanup should not turn a writable
// probe into a hard runtime-dependency failure.
}
}
}
}

View File

@@ -436,6 +436,27 @@ describe("bundled plugin postinstall", () => {
).toThrow("unsafe dist entry: dist/escape");
});
it("ignores staged bundled plugin node_modules when pruning packaged dist", async () => {
const packageRoot = await createTempDirAsync("openclaw-packaged-install-runtime-deps-");
const staleFile = path.join(packageRoot, "dist", "stale-runtime.js");
const packageJson = path.join(packageRoot, "dist", "extensions", "slack", "package.json");
const binDir = path.join(packageRoot, "dist", "extensions", "slack", "node_modules", ".bin");
await fs.mkdir(path.dirname(staleFile), { recursive: true });
await fs.mkdir(path.dirname(packageJson), { recursive: true });
await fs.mkdir(binDir, { recursive: true });
await fs.writeFile(staleFile, "export {};\n");
await fs.writeFile(packageJson, "{}\n");
await fs.symlink("../fxparser/bin.js", path.join(binDir, "fxparser"));
expect(
pruneInstalledPackageDist({
packageRoot,
expectedFiles: new Set(["dist/extensions/slack/package.json"]),
log: { log: vi.fn(), warn: vi.fn() },
}),
).toEqual(["dist/stale-runtime.js"]);
});
it("unlinks stale files instead of recursive pruning them", () => {
const unlinkSync = vi.fn();