fix(plugins): force dependency installs local

This commit is contained in:
Peter Steinberger
2026-04-25 22:46:41 +01:00
parent 1dfa52d071
commit cbe5515b70
9 changed files with 121 additions and 23 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -554,8 +554,9 @@ openclaw plugins install <package-name>
<Info>
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.
</Info>
Bundled OpenClaw-owned plugins are the only startup repair exception: when a

View File

@@ -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),
}),
}),
);
});
});

View File

@@ -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 {

View File

@@ -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 } : {}),
};
}

View File

@@ -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",
});

View File

@@ -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 } : {}),
};
}