diff --git a/CHANGELOG.md b/CHANGELOG.md index 006b744dae7..35e9e64c83e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -82,6 +82,9 @@ Docs: https://docs.openclaw.ai - CLI/agents: keep `openclaw agents list --json` on the config-only path by default, avoiding bundled plugin loading unless callers request `--bindings`. Fixes #71739. Thanks @kaloster. +- Plugins/install: force plugin dependency installs to stay project-local even + when inherited npm config requests global installs, so successful installs + still materialize the plugin's staged `node_modules`. - Providers/Google: transcode Gemini TTS PCM to Opus for voice-note targets so WhatsApp and other native voice-note replies can play as voice messages. - Plugins/runtime deps: reuse existing external bundled-plugin stage roots when diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index c9637730c3e..3f4e763f4d9 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -109,7 +109,8 @@ visibility and per-hook enablement, not package installation. Npm specs are **registry-only** (package name + optional **exact version** or **dist-tag**). Git/URL/file specs and semver ranges are rejected. Dependency -installs run with `--ignore-scripts` for safety. +installs run project-local with `--ignore-scripts` for safety, even when your +shell has global npm install settings. Bare specs and `@latest` stay on the stable track. If npm resolves either of those to a prerelease, OpenClaw stops and asks you to opt in explicitly with a diff --git a/docs/plugins/architecture-internals.md b/docs/plugins/architecture-internals.md index 98215dc3206..1d5bdf25515 100644 --- a/docs/plugins/architecture-internals.md +++ b/docs/plugins/architecture-internals.md @@ -771,9 +771,11 @@ Security guardrail: every `openclaw.extensions` entry must stay inside the plugi directory after symlink resolution. Entries that escape the package directory are rejected. -Security note: `openclaw plugins install` installs plugin dependencies with -`npm install --omit=dev --ignore-scripts` (no lifecycle scripts, no dev dependencies at runtime). Keep plugin dependency -trees "pure JS/TS" and avoid packages that require `postinstall` builds. +Security note: `openclaw plugins install` installs plugin dependencies with a +project-local `npm install --omit=dev --ignore-scripts` (no lifecycle scripts, +no dev dependencies at runtime), ignoring inherited global npm install settings. +Keep plugin dependency trees "pure JS/TS" and avoid packages that require +`postinstall` builds. Optional: `openclaw.setupEntry` can point at a lightweight setup-only module. When OpenClaw needs setup surfaces for a disabled channel plugin, or diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md index 6cedd59560f..a60a5f8f05c 100644 --- a/docs/plugins/sdk-setup.md +++ b/docs/plugins/sdk-setup.md @@ -554,8 +554,9 @@ openclaw plugins install For npm-sourced installs, `openclaw plugins install` runs - `npm install --ignore-scripts` (no lifecycle scripts). Keep plugin dependency - trees pure JS/TS and avoid packages that require `postinstall` builds. + project-local `npm install --ignore-scripts` (no lifecycle scripts), ignoring + inherited global npm install settings. Keep plugin dependency trees pure JS/TS + and avoid packages that require `postinstall` builds. Bundled OpenClaw-owned plugins are the only startup repair exception: when a diff --git a/src/infra/install-package-dir.test.ts b/src/infra/install-package-dir.test.ts index 4d49d80600a..ffeb80eaae4 100644 --- a/src/infra/install-package-dir.test.ts +++ b/src/infra/install-package-dir.test.ts @@ -387,4 +387,72 @@ describe("installPackageDir", () => { listMatchingEntries(targetDir, ".openclaw-install-hidden-npmrc-"), ).resolves.toHaveLength(0); }); + + it("forces dependency installs to stay project-local when npm global config leaks in", async () => { + await fixtureRootTracker.setup(); + const fixtureRoot = await fixtureRootTracker.make("case"); + const sourceDir = path.join(fixtureRoot, "source"); + const targetDir = path.join(fixtureRoot, "plugins", "demo"); + 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", + ); + + vi.stubEnv("NPM_CONFIG_GLOBAL", "true"); + vi.stubEnv("npm_config_global", "true"); + vi.stubEnv("NPM_CONFIG_LOCATION", "global"); + vi.stubEnv("npm_config_location", "global"); + vi.stubEnv("NPM_CONFIG_PREFIX", path.join(fixtureRoot, "global-prefix-uppercase")); + vi.stubEnv("npm_config_prefix", path.join(fixtureRoot, "global-prefix")); + vi.mocked(runCommandWithTimeout).mockResolvedValue({ + 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 }); + expect(vi.mocked(runCommandWithTimeout)).toHaveBeenCalledWith( + ["npm", "install", "--omit=dev", "--silent", "--ignore-scripts"], + expect.objectContaining({ + env: expect.objectContaining({ + npm_config_global: "false", + npm_config_location: "project", + npm_config_package_lock: "false", + npm_config_save: "false", + }), + }), + ); + expect(vi.mocked(runCommandWithTimeout)).toHaveBeenCalledWith( + expect.any(Array), + expect.objectContaining({ + env: expect.not.objectContaining({ + NPM_CONFIG_GLOBAL: expect.any(String), + NPM_CONFIG_LOCATION: expect.any(String), + NPM_CONFIG_PREFIX: expect.any(String), + npm_config_prefix: expect.any(String), + }), + }), + ); + }); }); diff --git a/src/infra/install-package-dir.ts b/src/infra/install-package-dir.ts index 7703cd57399..3ad43a58c0a 100644 --- a/src/infra/install-package-dir.ts +++ b/src/infra/install-package-dir.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { runCommandWithTimeout } from "../process/exec.js"; import { fileExists } from "./archive.js"; import { assertCanonicalPathWithinBase } from "./install-safe-path.js"; +import { createNpmProjectInstallEnv } from "./npm-install-env.js"; const INSTALL_BASE_CHANGED_ERROR_MESSAGE = "install base directory changed during install"; const INSTALL_BASE_CHANGED_ABORT_WARNING = @@ -249,6 +250,11 @@ export async function installPackageDir(params: { { timeoutMs: Math.max(params.timeoutMs, 300_000), cwd: stageDir, + env: { + ...createNpmProjectInstallEnv(process.env), + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", + NPM_CONFIG_IGNORE_SCRIPTS: "true", + }, }, ); } finally { diff --git a/src/infra/npm-install-env.ts b/src/infra/npm-install-env.ts new file mode 100644 index 00000000000..039035742a6 --- /dev/null +++ b/src/infra/npm-install-env.ts @@ -0,0 +1,30 @@ +export type NpmProjectInstallEnvOptions = { + cacheDir?: string; +}; + +const NPM_CONFIG_KEYS_TO_RESET = new Set([ + "npm_config_cache", + "npm_config_global", + "npm_config_location", + "npm_config_prefix", +]); + +export function createNpmProjectInstallEnv( + env: NodeJS.ProcessEnv, + options: NpmProjectInstallEnvOptions = {}, +): NodeJS.ProcessEnv { + const nextEnv = { ...env }; + for (const key of Object.keys(nextEnv)) { + if (NPM_CONFIG_KEYS_TO_RESET.has(key.toLowerCase())) { + delete nextEnv[key]; + } + } + return { + ...nextEnv, + npm_config_global: "false", + npm_config_location: "project", + npm_config_package_lock: "false", + npm_config_save: "false", + ...(options.cacheDir ? { npm_config_cache: options.cacheDir } : {}), + }; +} diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index 7216f686859..f75d4778878 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -79,7 +79,9 @@ describe("resolveBundledRuntimeDepsNpmRunner", () => { ).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", }); diff --git a/src/plugins/bundled-runtime-deps.ts b/src/plugins/bundled-runtime-deps.ts index 91291647e87..6f044e12162 100644 --- a/src/plugins/bundled-runtime-deps.ts +++ b/src/plugins/bundled-runtime-deps.ts @@ -7,6 +7,7 @@ import path from "node:path"; import { resolveStateDir } from "../config/paths.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { resolveHomeRelativePath } from "../infra/home-dir.js"; +import { createNpmProjectInstallEnv } from "../infra/npm-install-env.js"; import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js"; import { normalizePluginsConfig } from "./config-state.js"; import { satisfies, validRange, validSemver } from "./semver.runtime.js"; @@ -745,29 +746,13 @@ function storeSourceCheckoutRuntimeDepsCache(params: { } } -function createNestedNpmInstallEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv { - const nextEnv = { ...env }; - delete nextEnv.NPM_CONFIG_CACHE; - delete nextEnv.NPM_CONFIG_GLOBAL; - delete nextEnv.NPM_CONFIG_LOCATION; - delete nextEnv.NPM_CONFIG_PREFIX; - delete nextEnv.npm_config_cache; - delete nextEnv.npm_config_global; - delete nextEnv.npm_config_location; - delete nextEnv.npm_config_prefix; - return nextEnv; -} - export function createBundledRuntimeDepsInstallEnv( env: NodeJS.ProcessEnv, options: { cacheDir?: string } = {}, ): NodeJS.ProcessEnv { return { - ...createNestedNpmInstallEnv(env), + ...createNpmProjectInstallEnv(env, options), npm_config_legacy_peer_deps: "true", - npm_config_package_lock: "false", - npm_config_save: "false", - ...(options.cacheDir ? { npm_config_cache: options.cacheDir } : {}), }; }