From c2a2edb329b742680c82d52bf4e0bbcf02c1ed2c Mon Sep 17 00:00:00 2001 From: Devin Robison Date: Wed, 25 Mar 2026 08:59:33 -0700 Subject: [PATCH] Fix local copied package installs honoring staged project .npmrc (#54543) --- src/infra/install-package-dir.test.ts | 60 ++++++++++++++++++++++ src/infra/install-package-dir.ts | 74 ++++++++++++++++++++++----- 2 files changed, 121 insertions(+), 13 deletions(-) diff --git a/src/infra/install-package-dir.test.ts b/src/infra/install-package-dir.test.ts index cacbcadf5cc..48b45d689a9 100644 --- a/src/infra/install-package-dir.test.ts +++ b/src/infra/install-package-dir.test.ts @@ -21,6 +21,11 @@ async function listMatchingDirs(root: string, prefix: string): Promise .map((entry) => entry.name); } +async function listMatchingEntries(root: string, prefix: string): Promise { + const entries = await fs.readdir(root, { withFileTypes: true }); + return entries.filter((entry) => entry.name.startsWith(prefix)).map((entry) => entry.name); +} + function normalizeDarwinTmpPath(filePath: string): string { return process.platform === "darwin" && filePath.startsWith("/private/var/") ? filePath.slice("/private".length) @@ -317,4 +322,59 @@ describe("installPackageDir", () => { }), ); }); + + it("hides the staged project .npmrc while npm install runs and restores it afterward", async () => { + fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-install-package-dir-")); + const sourceDir = path.join(fixtureRoot, "source"); + const targetDir = path.join(fixtureRoot, "plugins", "demo"); + const npmrcContent = "git=calc.exe\n"; + await fs.mkdir(sourceDir, { recursive: true }); + await fs.writeFile( + path.join(sourceDir, "package.json"), + JSON.stringify({ + name: "demo-plugin", + version: "1.0.0", + dependencies: { + zod: "^4.0.0", + }, + }), + "utf-8", + ); + await fs.writeFile(path.join(sourceDir, ".npmrc"), npmrcContent, "utf-8"); + + vi.mocked(runCommandWithTimeout).mockImplementation(async (_argv, optionsOrTimeout) => { + const cwd = typeof optionsOrTimeout === "number" ? undefined : optionsOrTimeout.cwd; + expect(cwd).toBeTruthy(); + await expect(fs.stat(path.join(cwd ?? "", ".npmrc"))).rejects.toMatchObject({ + code: "ENOENT", + }); + await expect( + listMatchingEntries(cwd ?? "", ".openclaw-install-hidden-npmrc-"), + ).resolves.toHaveLength(1); + return { + stdout: "", + stderr: "", + code: 0, + signal: null, + killed: false, + termination: "exit", + }; + }); + + const result = await installPackageDir({ + sourceDir, + targetDir, + mode: "install", + timeoutMs: 1_000, + copyErrorPrefix: "failed to copy plugin", + hasDeps: true, + depsLogMessage: "Installing deps…", + }); + + expect(result).toEqual({ ok: true }); + await expect(fs.readFile(path.join(targetDir, ".npmrc"), "utf8")).resolves.toBe(npmrcContent); + await expect( + listMatchingEntries(targetDir, ".openclaw-install-hidden-npmrc-"), + ).resolves.toHaveLength(0); + }); }); diff --git a/src/infra/install-package-dir.ts b/src/infra/install-package-dir.ts index 45611b17ffe..f6c467096c3 100644 --- a/src/infra/install-package-dir.ts +++ b/src/infra/install-package-dir.ts @@ -9,6 +9,14 @@ const INSTALL_BASE_CHANGED_ABORT_WARNING = "Install base directory changed during install; aborting staged publish."; const INSTALL_BASE_CHANGED_BACKUP_WARNING = "Install base directory changed before backup cleanup; leaving backup in place."; +const STAGED_NPM_PROJECT_CONFIG_NAME = ".npmrc"; +const STAGED_NPM_PROJECT_CONFIG_PREFIX = ".openclaw-install-hidden-npmrc-"; + +type HiddenProjectConfigFile = { + hiddenDir: string; + originalPath: string; + hiddenPath: string; +} | null; function isObjectRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); @@ -55,6 +63,35 @@ async function sanitizeManifestForNpmInstall(targetDir: string): Promise { await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf-8"); } +async function hideProjectNpmConfigForInstall(targetDir: string): Promise { + const originalPath = path.join(targetDir, STAGED_NPM_PROJECT_CONFIG_NAME); + let hiddenDir = ""; + try { + hiddenDir = await fs.mkdtemp(path.join(targetDir, STAGED_NPM_PROJECT_CONFIG_PREFIX)); + const hiddenPath = path.join(hiddenDir, STAGED_NPM_PROJECT_CONFIG_NAME); + await fs.rename(originalPath, hiddenPath); + return { hiddenDir, originalPath, hiddenPath }; + } catch (error) { + if (hiddenDir) { + await fs.rm(hiddenDir, { recursive: true, force: true }).catch(() => undefined); + } + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw error; + } +} + +async function restoreProjectNpmConfigAfterInstall( + hiddenConfig: HiddenProjectConfigFile, +): Promise { + if (!hiddenConfig) { + return; + } + await fs.rename(hiddenConfig.hiddenPath, hiddenConfig.originalPath); + await fs.rm(hiddenConfig.hiddenDir, { recursive: true, force: true }); +} + async function assertInstallBoundaryPaths(params: { installBaseDir: string; candidatePaths: string[]; @@ -186,19 +223,30 @@ export async function installPackageDir(params: { } if (params.hasDeps) { - await sanitizeManifestForNpmInstall(stageDir); - params.logger?.info?.(params.depsLogMessage); - const npmRes = await runCommandWithTimeout( - // Plugins install into isolated directories, so omitting peer deps can strip - // runtime requirements that npm would otherwise materialize for the package. - ["npm", "install", "--omit=dev", "--silent", "--ignore-scripts"], - { - timeoutMs: Math.max(params.timeoutMs, 300_000), - cwd: stageDir, - }, - ); - if (npmRes.code !== 0) { - return await fail(`npm install failed: ${npmRes.stderr.trim() || npmRes.stdout.trim()}`); + try { + await sanitizeManifestForNpmInstall(stageDir); + const hiddenProjectNpmConfig = await hideProjectNpmConfigForInstall(stageDir); + params.logger?.info?.(params.depsLogMessage); + const npmRes = await (async () => { + try { + return await runCommandWithTimeout( + // Plugins install into isolated directories, so omitting peer deps can strip + // runtime requirements that npm would otherwise materialize for the package. + ["npm", "install", "--omit=dev", "--silent", "--ignore-scripts"], + { + timeoutMs: Math.max(params.timeoutMs, 300_000), + cwd: stageDir, + }, + ); + } finally { + await restoreProjectNpmConfigAfterInstall(hiddenProjectNpmConfig); + } + })(); + if (npmRes.code !== 0) { + return await fail(`npm install failed: ${npmRes.stderr.trim() || npmRes.stdout.trim()}`); + } + } catch (error) { + return await fail(`npm install failed: ${String(error)}`, error); } }