From 9d66a900e50083e22bbe520b257a3548eee7f677 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 22 Apr 2026 18:48:35 +0100 Subject: [PATCH] fix(plugins): harden bundled runtime dep staging --- scripts/postinstall-bundled-plugins.mjs | 14 +++++++++++++ src/plugins/bundled-runtime-deps.test.ts | 16 ++++++++++++++ src/plugins/bundled-runtime-deps.ts | 15 +++++++++++-- .../postinstall-bundled-plugins.test.ts | 21 +++++++++++++++++++ 4 files changed, 64 insertions(+), 2 deletions(-) diff --git a/scripts/postinstall-bundled-plugins.mjs b/scripts/postinstall-bundled-plugins.mjs index 75fa38aed4f..523a66989d1 100644 --- a/scripts/postinstall-bundled-plugins.mjs +++ b/scripts/postinstall-bundled-plugins.mjs @@ -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( diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 226671713e1..ea3569e75bb 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -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( diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 6a0a490cd38..653b954fc95 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -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. + } + } } } diff --git a/test/scripts/postinstall-bundled-plugins.test.ts b/test/scripts/postinstall-bundled-plugins.test.ts index 607168463c3..252b5261f0f 100644 --- a/test/scripts/postinstall-bundled-plugins.test.ts +++ b/test/scripts/postinstall-bundled-plugins.test.ts @@ -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();