mirror of
https://fastgit.cc/github.com/openclaw/openclaw
synced 2026-04-30 22:12:32 +08:00
fix(plugins): force dependency installs local
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
30
src/infra/npm-install-env.ts
Normal file
30
src/infra/npm-install-env.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user