diff --git a/CHANGELOG.md b/CHANGELOG.md index b2f0a816216..d7633e83ee8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,9 @@ Docs: https://docs.openclaw.ai - Plugins/startup: remove ownerless bundled runtime-dependency install locks after a short grace window and include lock owner details when startup times out waiting for a plugin runtime-deps lock. +- Plugins/install: anchor bundled runtime-dependency npm installs with an + OpenClaw-owned package manifest so Linux updates cannot accidentally write to + a parent `$HOME/node_modules` tree. Fixes #71730. - Live tests/voice: accept common STT variants for OpenClaw and ElevenLabs brand names so provider smoke tests fail on real regressions rather than equivalent transcripts. diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 910cc598101..7216f686859 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -222,6 +222,44 @@ describe("installBundledRuntimeDeps", () => { ); }); + 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, + }); + 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("uses an isolated execution root and copies node_modules back when requested", () => { const installRoot = makeTempDir(); const installExecutionRoot = makeTempDir(); diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index d170672d10e..91291647e87 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -1154,14 +1154,17 @@ function shouldCleanBundledRuntimeDepsInstallExecutionRoot(params: { return installExecutionRoot.startsWith(`${installRoot}${path.sep}`); } -function writeBundledRuntimeDepsInstallManifest(installExecutionRoot: string): void { +function ensureNpmInstallExecutionManifest(installExecutionRoot: string): void { + const manifestPath = path.join(installExecutionRoot, "package.json"); + if (fs.existsSync(manifestPath)) { + return; + } fs.writeFileSync( - path.join(installExecutionRoot, "package.json"), + manifestPath, `${JSON.stringify({ name: "openclaw-runtime-deps-install", private: true }, null, 2)}\n`, "utf8", ); } - export function installBundledRuntimeDeps(params: { installRoot: string; installExecutionRoot?: string; @@ -1184,7 +1187,7 @@ export function installBundledRuntimeDeps(params: { // doctor repair path installs directly in the external stage dir; without a // manifest, npm can honor a user's global prefix config and write under // $HOME/node_modules instead of our managed stage. - writeBundledRuntimeDepsInstallManifest(installExecutionRoot); + ensureNpmInstallExecutionManifest(installExecutionRoot); const installEnv = createBundledRuntimeDepsInstallEnv(params.env, { cacheDir: path.join(installExecutionRoot, ".openclaw-npm-cache"), }); diff --git a/src/plugins/provider-discovery.test.ts b/src/plugins/provider-discovery.test.ts index 1637ce61d26..8c9680c089b 100644 --- a/src/plugins/provider-discovery.test.ts +++ b/src/plugins/provider-discovery.test.ts @@ -176,6 +176,7 @@ describe("resolveInstalledPluginProviderContributionIds", () => { resolveInstalledPluginProviderContributionIds({ candidates: [candidate], env: hermeticEnv(), + preferPersisted: false, }), ).toEqual(["demo", "demo-alias"]); }); @@ -197,6 +198,7 @@ describe("resolveInstalledPluginProviderContributionIds", () => { }, }, env: hermeticEnv(), + preferPersisted: false, }; expect(resolveInstalledPluginProviderContributionIds(params)).toEqual([]);