From 4c712d3372151384ba12c56a6d571182c50dbba6 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 29 Apr 2026 23:23:08 +0100 Subject: [PATCH] fix: add bundled plugin deps repair command --- CHANGELOG.md | 1 + docs/cli/plugins.md | 19 +- docs/plugins/bundles.md | 13 +- docs/plugins/sdk-setup.md | 2 +- src/cli/plugins-cli.ts | 22 ++ src/cli/plugins-deps-command.test.ts | 251 ++++++++++++++++++ src/cli/plugins-deps-command.ts | 197 ++++++++++++++ src/infra/install-package-dir.ts | 10 +- src/infra/safe-package-install.test.ts | 61 +++++ src/infra/safe-package-install.ts | 49 ++++ .../bundled-runtime-deps-package-manager.ts | 15 +- src/plugins/bundled-runtime-deps.test.ts | 2 + 12 files changed, 624 insertions(+), 18 deletions(-) create mode 100644 src/cli/plugins-deps-command.test.ts create mode 100644 src/cli/plugins-deps-command.ts create mode 100644 src/infra/safe-package-install.test.ts create mode 100644 src/infra/safe-package-install.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d9625e944d..061181f2be6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -28,6 +28,7 @@ Docs: https://docs.openclaw.ai ### Fixes +- Plugins/runtime-deps: add `openclaw plugins deps` inspection and repair with script-free package-manager defaults shared across plugin installers, so operators can repair missing bundled runtime deps without corrupting JSON output or blocking unrelated conflict-free deps. Thanks @vincentkoc. - Agents/sessions: emit a terminal lifecycle backstop when embedded timeout/error turns return without `agent_end`, so Gateway sessions no longer stay stuck in `running` after failover surfaces a timeout. Fixes #74607. Thanks @millerc79. - Gateway/diagnostics: include stuck-session reason hints and recovery skip causes in warnings, so operators can tell whether a lane is waiting on active work, queued work, or stale bookkeeping. Thanks @vincentkoc. - Agents/Codex: bound embedded-run cleanup, trajectory flushing, and command-lane task timeouts after runtime failures, so Discord and other chat sessions return to idle instead of staying stuck in processing. Thanks @vincentkoc. diff --git a/docs/cli/plugins.md b/docs/cli/plugins.md index a02c5844b0b..85fa2e6c2dd 100644 --- a/docs/cli/plugins.md +++ b/docs/cli/plugins.md @@ -1,5 +1,5 @@ --- -summary: "CLI reference for `openclaw plugins` (list, install, marketplace, uninstall, enable/disable, doctor)" +summary: "CLI reference for `openclaw plugins` (list, install, marketplace, uninstall, enable/disable, deps, doctor)" read_when: - You want to install or manage Gateway plugins or compatible bundles - You want to debug plugin load failures @@ -41,6 +41,10 @@ openclaw plugins disable openclaw plugins registry openclaw plugins registry --refresh openclaw plugins uninstall +openclaw plugins deps +openclaw plugins deps --repair +openclaw plugins deps --prune +openclaw plugins deps --json openclaw plugins doctor openclaw plugins update openclaw plugins update --all @@ -252,6 +256,19 @@ Plugin install metadata is machine-managed state, not user config. Installs and When OpenClaw sees shipped legacy `plugins.installs` records in config, it moves them into the plugin index and removes the config key; if either write fails, the config records are kept so the install metadata is not lost. +### Runtime deps + +```bash +openclaw plugins deps +openclaw plugins deps --repair +openclaw plugins deps --prune +openclaw plugins deps --json +``` + +`plugins deps` inspects the packaged runtime dependency stage for OpenClaw-owned bundled plugins. It is not the install/update path for third-party npm or ClawHub plugins. + +Use `--repair` when a packaged install reports missing bundled runtime dependencies during Gateway startup or `plugins doctor`. Repair installs only missing enabled bundled-plugin deps with lifecycle scripts disabled. Use `--prune` to remove stale unknown external runtime-dependency roots left behind by older packaged layouts. + ### Uninstall ```bash diff --git a/docs/plugins/bundles.md b/docs/plugins/bundles.md index 9e9214453e2..c4665436f28 100644 --- a/docs/plugins/bundles.md +++ b/docs/plugins/bundles.md @@ -255,10 +255,15 @@ dual-format packages from being partially installed as bundles. ## Runtime dependencies and cleanup -- Bundled plugin runtime dependencies ship inside the OpenClaw package under - `dist/*`. OpenClaw does **not** run `npm install` at startup for bundled - plugins; the release pipeline is responsible for shipping a complete bundled - dependency payload (see the postpublish verification rule in +- Third-party compatible bundles do not get startup `npm install` repair. They + should be installed through `openclaw plugins install` and ship everything + they need in the installed plugin directory. +- OpenClaw-owned packaged bundled plugins have a narrow exception: when one is + enabled, Gateway startup can repair missing declared runtime dependencies + before import. Operators can inspect or repair that stage with + `openclaw plugins deps`. +- The release pipeline is still responsible for shipping a complete bundled + dependency payload when possible (see the postpublish verification rule in [Releasing](/reference/RELEASING)). ## Security diff --git a/docs/plugins/sdk-setup.md b/docs/plugins/sdk-setup.md index 6e86bd48868..11de764c9b5 100644 --- a/docs/plugins/sdk-setup.md +++ b/docs/plugins/sdk-setup.md @@ -517,7 +517,7 @@ For npm-sourced installs, `openclaw plugins install` runs project-local `npm ins -Bundled OpenClaw-owned plugins are the only startup repair exception: when a packaged install sees one enabled by plugin config, legacy channel config, or its bundled default-enabled manifest, startup installs that plugin's missing runtime dependencies before import. Third-party plugins should not rely on startup installs; keep using the explicit plugin installer. +Bundled OpenClaw-owned plugins are the only startup repair exception: when a packaged install sees one enabled by plugin config, legacy channel config, or its bundled default-enabled manifest, startup installs that plugin's missing runtime dependencies before import. Operators can inspect or repair that stage with `openclaw plugins deps`. Third-party plugins should not rely on startup installs; keep using the explicit plugin installer. Bundled package-level runtime deps are explicit metadata, not inferred from built JavaScript at gateway startup. If a shared OpenClaw root dependency must be available inside the external bundled-plugin runtime mirror, declare it in `openclaw.bundle.mirroredRootRuntimeDependencies` in the root package manifest. diff --git a/src/cli/plugins-cli.ts b/src/cli/plugins-cli.ts index 1529fc308c1..420862d79c1 100644 --- a/src/cli/plugins-cli.ts +++ b/src/cli/plugins-cli.ts @@ -53,6 +53,13 @@ export type PluginRegistryOptions = { refresh?: boolean; }; +export type PluginsDepsCliOptions = { + json?: boolean; + packageRoot?: string; + prune?: boolean; + repair?: boolean; +}; + const quietPluginJsonLogger: PluginLogger = { debug: () => undefined, info: () => undefined, @@ -252,6 +259,21 @@ export function registerPluginsCli(program: Command) { defaultRuntime.log(lines.join("\n").trim()); }); + plugins + .command("deps") + .description("Inspect or repair bundled plugin runtime dependencies") + .option("--json", "Print JSON") + .option("--package-root ", "OpenClaw package root to inspect") + .option("--prune", "Prune stale unknown external runtime dependency roots", false) + .option("--repair", "Install missing bundled runtime dependencies", false) + .action(async (opts: PluginsDepsCliOptions) => { + const { runPluginsDepsCommand } = await import("./plugins-deps-command.js"); + await runPluginsDepsCommand({ + config: getRuntimeConfig(), + options: opts, + }); + }); + plugins .command("inspect") .alias("info") diff --git a/src/cli/plugins-deps-command.test.ts b/src/cli/plugins-deps-command.test.ts new file mode 100644 index 00000000000..8c25185bfb7 --- /dev/null +++ b/src/cli/plugins-deps-command.test.ts @@ -0,0 +1,251 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +type RuntimeDepFixture = { + name: string; + version: string; + pluginIds: string[]; +}; + +const mocks = vi.hoisted(() => { + const runtimeLogs: string[] = []; + const stringifyArgs = (args: unknown[]) => args.map((value) => String(value)).join(" "); + return { + runtimeLogs, + defaultRuntime: { + log: vi.fn((...args: unknown[]) => { + runtimeLogs.push(stringifyArgs(args)); + }), + error: vi.fn((...args: unknown[]) => { + runtimeLogs.push(stringifyArgs(args)); + }), + writeStdout: vi.fn((value: string) => { + runtimeLogs.push(value.endsWith("\n") ? value.slice(0, -1) : value); + }), + writeJson: vi.fn((value: unknown, space = 2) => { + runtimeLogs.push(JSON.stringify(value, null, space > 0 ? space : undefined)); + }), + exit: vi.fn((code: number) => { + throw new Error(`__exit__:${code}`); + }), + }, + createBundledRuntimeDepsInstallSpecs: vi.fn((params: { deps: readonly RuntimeDepFixture[] }) => + params.deps.map((dep) => `${dep.name}@${dep.version}`), + ), + pruneUnknownBundledRuntimeDepsRoots: vi.fn(), + repairBundledRuntimeDepsInstallRootAsync: vi.fn(), + resolveBundledRuntimeDependencyPackageInstallRootPlan: vi.fn(), + resolveOpenClawPackageRootSync: vi.fn(), + scanBundledPluginRuntimeDeps: vi.fn(), + }; +}); + +vi.mock("../runtime.js", () => ({ + defaultRuntime: mocks.defaultRuntime, +})); + +vi.mock("../infra/openclaw-root.js", () => ({ + resolveOpenClawPackageRootSync: mocks.resolveOpenClawPackageRootSync, +})); + +vi.mock("../plugins/bundled-runtime-deps.js", () => ({ + createBundledRuntimeDepsInstallSpecs: mocks.createBundledRuntimeDepsInstallSpecs, + pruneUnknownBundledRuntimeDepsRoots: mocks.pruneUnknownBundledRuntimeDepsRoots, + repairBundledRuntimeDepsInstallRootAsync: mocks.repairBundledRuntimeDepsInstallRootAsync, + resolveBundledRuntimeDependencyPackageInstallRootPlan: + mocks.resolveBundledRuntimeDependencyPackageInstallRootPlan, + scanBundledPluginRuntimeDeps: mocks.scanBundledPluginRuntimeDeps, +})); + +const { runPluginsDepsCommand } = await import("./plugins-deps-command.js"); + +describe("plugins deps command", () => { + beforeEach(() => { + mocks.runtimeLogs.length = 0; + mocks.defaultRuntime.log.mockClear(); + mocks.defaultRuntime.error.mockClear(); + mocks.defaultRuntime.writeStdout.mockClear(); + mocks.defaultRuntime.writeJson.mockClear(); + mocks.defaultRuntime.exit.mockClear(); + mocks.createBundledRuntimeDepsInstallSpecs.mockClear(); + mocks.pruneUnknownBundledRuntimeDepsRoots.mockReset(); + mocks.repairBundledRuntimeDepsInstallRootAsync.mockReset(); + mocks.resolveBundledRuntimeDependencyPackageInstallRootPlan.mockReset(); + mocks.resolveOpenClawPackageRootSync.mockReset(); + mocks.scanBundledPluginRuntimeDeps.mockReset(); + mocks.resolveBundledRuntimeDependencyPackageInstallRootPlan.mockReturnValue({ + installRoot: "/runtime-deps", + searchRoots: ["/runtime-deps"], + external: true, + }); + }); + + it("does not reinstall already materialized bundled runtime deps", async () => { + mocks.scanBundledPluginRuntimeDeps.mockReturnValue({ + deps: [{ name: "zod", version: "4.0.0", pluginIds: ["openclaw-demo"] }], + missing: [], + conflicts: [], + }); + + await runPluginsDepsCommand({ + config: {}, + options: { + json: true, + packageRoot: "/openclaw-package", + repair: true, + }, + }); + + expect(mocks.repairBundledRuntimeDepsInstallRootAsync).not.toHaveBeenCalled(); + expect(JSON.parse(mocks.runtimeLogs[0] ?? "null")).toEqual( + expect.objectContaining({ + packageRoot: "/openclaw-package", + installSpecs: ["zod@4.0.0"], + missingSpecs: [], + repairedSpecs: [], + }), + ); + }); + + it("repairs only when bundled runtime deps are missing", async () => { + const dep = { name: "zod", version: "4.0.0", pluginIds: ["openclaw-demo"] }; + mocks.scanBundledPluginRuntimeDeps + .mockReturnValueOnce({ + deps: [dep], + missing: [dep], + conflicts: [], + }) + .mockReturnValueOnce({ + deps: [dep], + missing: [], + conflicts: [], + }); + mocks.repairBundledRuntimeDepsInstallRootAsync.mockResolvedValue({ + installSpecs: ["zod@4.0.0"], + skipped: false, + }); + + await runPluginsDepsCommand({ + config: {}, + options: { + json: true, + packageRoot: "/openclaw-package", + repair: true, + }, + }); + + expect(mocks.repairBundledRuntimeDepsInstallRootAsync).toHaveBeenCalledWith( + expect.objectContaining({ + installRoot: "/runtime-deps", + installSpecs: ["zod@4.0.0"], + missingSpecs: ["zod@4.0.0"], + }), + ); + expect(JSON.parse(mocks.runtimeLogs[0] ?? "null")).toEqual( + expect.objectContaining({ + missing: [], + missingSpecs: [], + repairedSpecs: ["zod@4.0.0"], + warnings: [], + }), + ); + }); + + it("keeps repair warnings inside JSON output", async () => { + const dep = { name: "zod", version: "4.0.0", pluginIds: ["openclaw-demo"] }; + mocks.scanBundledPluginRuntimeDeps + .mockReturnValueOnce({ + deps: [dep], + missing: [dep], + conflicts: [], + }) + .mockReturnValueOnce({ + deps: [dep], + missing: [], + conflicts: [], + }); + mocks.repairBundledRuntimeDepsInstallRootAsync.mockImplementation(async (params: unknown) => { + (params as { warn: (message: string) => void }).warn("low disk space"); + return { + installSpecs: ["zod@4.0.0"], + skipped: false, + }; + }); + + await runPluginsDepsCommand({ + config: {}, + options: { + json: true, + packageRoot: "/openclaw-package", + repair: true, + }, + }); + + expect(mocks.runtimeLogs).toHaveLength(1); + expect(JSON.parse(mocks.runtimeLogs[0] ?? "null")).toEqual( + expect.objectContaining({ + missing: [], + repairedSpecs: ["zod@4.0.0"], + warnings: ["low disk space"], + }), + ); + }); + + it("repairs missing deps even when separate deps have version conflicts", async () => { + const dep = { name: "zod", version: "4.0.0", pluginIds: ["openclaw-demo"] }; + const conflict = { + name: "shared-conflict", + versions: ["1.0.0", "2.0.0"], + pluginIdsByVersion: new Map([ + ["1.0.0", ["openclaw-one"]], + ["2.0.0", ["openclaw-two"]], + ]), + }; + mocks.scanBundledPluginRuntimeDeps + .mockReturnValueOnce({ + deps: [dep], + missing: [dep], + conflicts: [conflict], + }) + .mockReturnValueOnce({ + deps: [dep], + missing: [], + conflicts: [conflict], + }); + mocks.repairBundledRuntimeDepsInstallRootAsync.mockResolvedValue({ + installSpecs: ["zod@4.0.0"], + skipped: false, + }); + + await runPluginsDepsCommand({ + config: {}, + options: { + json: true, + packageRoot: "/openclaw-package", + repair: true, + }, + }); + + expect(mocks.repairBundledRuntimeDepsInstallRootAsync).toHaveBeenCalledWith( + expect.objectContaining({ + installSpecs: ["zod@4.0.0"], + missingSpecs: ["zod@4.0.0"], + }), + ); + expect(JSON.parse(mocks.runtimeLogs[0] ?? "null")).toEqual( + expect.objectContaining({ + missing: [], + conflicts: [ + { + name: "shared-conflict", + versions: ["1.0.0", "2.0.0"], + pluginIdsByVersion: { + "1.0.0": ["openclaw-one"], + "2.0.0": ["openclaw-two"], + }, + }, + ], + repairedSpecs: ["zod@4.0.0"], + }), + ); + }); +}); diff --git a/src/cli/plugins-deps-command.ts b/src/cli/plugins-deps-command.ts new file mode 100644 index 00000000000..ce85faf303a --- /dev/null +++ b/src/cli/plugins-deps-command.ts @@ -0,0 +1,197 @@ +import path from "node:path"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { resolveOpenClawPackageRootSync } from "../infra/openclaw-root.js"; +import { + createBundledRuntimeDepsInstallSpecs, + pruneUnknownBundledRuntimeDepsRoots, + repairBundledRuntimeDepsInstallRootAsync, + resolveBundledRuntimeDependencyPackageInstallRootPlan, + scanBundledPluginRuntimeDeps, +} from "../plugins/bundled-runtime-deps.js"; +import { defaultRuntime } from "../runtime.js"; +import { getTerminalTableWidth, renderTable } from "../terminal/table.js"; +import { theme } from "../terminal/theme.js"; +import { shortenHomePath } from "../utils.js"; + +export type PluginsDepsOptions = { + json?: boolean; + packageRoot?: string; + prune?: boolean; + repair?: boolean; +}; + +function resolvePackageRoot(rawPackageRoot: string | undefined): string | null { + if (rawPackageRoot?.trim()) { + return path.resolve(rawPackageRoot.trim()); + } + return resolveOpenClawPackageRootSync({ + argv1: process.argv[1], + cwd: process.cwd(), + moduleUrl: import.meta.url, + }); +} + +function formatRuntimeDepOwners(pluginIds: readonly string[]): string { + return pluginIds.length > 0 ? pluginIds.join(", ") : "-"; +} + +function formatRuntimeDepConflicts( + conflicts: ReturnType["conflicts"], +) { + return conflicts.map((conflict) => ({ + name: conflict.name, + versions: conflict.versions, + pluginIdsByVersion: Object.fromEntries(conflict.pluginIdsByVersion), + })); +} + +function createWarningSink(params: { json?: boolean; warnings: string[] }) { + return (message: string) => { + params.warnings.push(message); + if (!params.json) { + defaultRuntime.log(theme.warn(message)); + } + }; +} + +export async function runPluginsDepsCommand(params: { + config: OpenClawConfig; + options: PluginsDepsOptions; +}): Promise { + const packageRoot = resolvePackageRoot(params.options.packageRoot); + if (!packageRoot) { + const message = "Could not resolve the OpenClaw package root for bundled plugin deps."; + if (params.options.json) { + defaultRuntime.writeJson({ ok: false, error: message }); + return; + } + defaultRuntime.error(message); + return defaultRuntime.exit(1); + } + + const warnings: string[] = []; + const warn = createWarningSink({ json: params.options.json, warnings }); + const pruned = params.options.prune + ? pruneUnknownBundledRuntimeDepsRoots({ + env: process.env, + warn, + }) + : undefined; + const scanRuntimeDeps = () => + scanBundledPluginRuntimeDeps({ + packageRoot, + config: params.config, + includeConfiguredChannels: true, + env: process.env, + }); + let scan = scanRuntimeDeps(); + const installRootPlan = resolveBundledRuntimeDependencyPackageInstallRootPlan(packageRoot, { + env: process.env, + }); + let installSpecs = createBundledRuntimeDepsInstallSpecs({ deps: scan.deps }); + let missingSpecs = createBundledRuntimeDepsInstallSpecs({ deps: scan.missing }); + let repairedSpecs: string[] = []; + + if (params.options.repair && missingSpecs.length > 0) { + const result = await repairBundledRuntimeDepsInstallRootAsync({ + installRoot: installRootPlan.installRoot, + missingSpecs, + installSpecs, + env: process.env, + warn, + onProgress: (message) => { + if (!params.options.json) { + defaultRuntime.log(theme.muted(message)); + } + }, + }); + repairedSpecs = result.installSpecs; + scan = scanRuntimeDeps(); + installSpecs = createBundledRuntimeDepsInstallSpecs({ deps: scan.deps }); + missingSpecs = createBundledRuntimeDepsInstallSpecs({ deps: scan.missing }); + } + + if (params.options.json) { + defaultRuntime.writeJson({ + packageRoot, + installRoot: installRootPlan.installRoot, + installRootExternal: installRootPlan.external, + searchRoots: installRootPlan.searchRoots, + deps: scan.deps, + missing: scan.missing, + conflicts: formatRuntimeDepConflicts(scan.conflicts), + installSpecs, + missingSpecs, + repairedSpecs, + warnings, + ...(pruned ? { pruned } : {}), + }); + return; + } + + const lines = [ + theme.heading("Bundled Plugin Runtime Deps"), + `${theme.muted("Package root:")} ${shortenHomePath(packageRoot)}`, + `${theme.muted("Install root:")} ${shortenHomePath(installRootPlan.installRoot)}${ + installRootPlan.external ? theme.muted(" (external)") : "" + }`, + ]; + if (pruned) { + lines.push( + `${theme.muted("Pruned unknown roots:")} ${pruned.removed}/${pruned.scanned}${ + pruned.skippedLocked > 0 ? theme.muted(` (${pruned.skippedLocked} locked)`) : "" + }`, + ); + } + if (scan.conflicts.length > 0) { + lines.push(""); + lines.push(theme.error("Version conflicts:")); + for (const conflict of scan.conflicts) { + const owners = conflict.versions + .map((version) => `${version}: ${conflict.pluginIdsByVersion.get(version)?.join(", ")}`) + .join("; "); + lines.push(`- ${conflict.name}: ${owners}`); + } + } + if (scan.deps.length === 0) { + lines.push(""); + lines.push(theme.muted("No packaged bundled runtime deps are required for this checkout.")); + defaultRuntime.log(lines.join("\n")); + return; + } + + lines.push(""); + lines.push( + `${theme.muted("Status:")} ${ + scan.missing.length === 0 ? theme.success("materialized") : theme.warn("missing") + }`, + ); + if (repairedSpecs.length > 0) { + lines.push(`${theme.muted("Repaired:")} ${repairedSpecs.join(", ")}`); + } else if (params.options.repair && scan.conflicts.length > 0) { + lines.push(theme.warn("Repair skipped because runtime dependency versions conflict.")); + } + lines.push(""); + lines.push( + renderTable({ + width: getTerminalTableWidth(), + columns: [ + { key: "Name", header: "Name", minWidth: 18, flex: true }, + { key: "Version", header: "Version", minWidth: 12 }, + { key: "Status", header: "Status", minWidth: 12 }, + { key: "Plugins", header: "Plugins", minWidth: 24, flex: true }, + ], + rows: scan.deps.map((dep) => ({ + Name: dep.name, + Version: dep.version, + Status: scan.missing.some( + (missing) => missing.name === dep.name && missing.version === dep.version, + ) + ? theme.warn("missing") + : theme.success("ok"), + Plugins: formatRuntimeDepOwners(dep.pluginIds), + })), + }).trimEnd(), + ); + defaultRuntime.log(lines.join("\n")); +} diff --git a/src/infra/install-package-dir.ts b/src/infra/install-package-dir.ts index e2af4c11541..5548855c970 100644 --- a/src/infra/install-package-dir.ts +++ b/src/infra/install-package-dir.ts @@ -3,7 +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"; +import { createSafeNpmInstallArgs, createSafeNpmInstallEnv } from "./safe-package-install.js"; const INSTALL_BASE_CHANGED_ERROR_MESSAGE = "install base directory changed during install"; const INSTALL_BASE_CHANGED_ABORT_WARNING = @@ -261,15 +261,11 @@ export async function installPackageDir(params: { // Verified on Blacksmith Ubuntu/Node 24/npm 11: `--silent` can make npm fail // with empty stdout/stderr for bad specs like `workspace:^`; `--loglevel=error` // stays quiet on success while preserving the actionable npm failure text. - ["npm", "install", "--omit=dev", "--loglevel=error", "--ignore-scripts"], + ["npm", ...createSafeNpmInstallArgs({ omitDev: true, loglevel: "error" })], { timeoutMs: Math.max(params.timeoutMs, 300_000), cwd: stageDir, - env: { - ...createNpmProjectInstallEnv(process.env), - COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", - NPM_CONFIG_IGNORE_SCRIPTS: "true", - }, + env: createSafeNpmInstallEnv(process.env), }, ); } finally { diff --git a/src/infra/safe-package-install.test.ts b/src/infra/safe-package-install.test.ts new file mode 100644 index 00000000000..a994dbbe202 --- /dev/null +++ b/src/infra/safe-package-install.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { createSafeNpmInstallArgs, createSafeNpmInstallEnv } from "./safe-package-install.js"; + +describe("safe npm install helpers", () => { + it("builds script-free npm install args", () => { + expect( + createSafeNpmInstallArgs({ + omitDev: true, + loglevel: "error", + noAudit: true, + noFund: true, + }), + ).toEqual([ + "install", + "--omit=dev", + "--loglevel=error", + "--ignore-scripts", + "--no-audit", + "--no-fund", + ]); + }); + + it("forces project-local script-free npm install env", () => { + expect( + createSafeNpmInstallEnv( + { + PATH: "/usr/bin:/bin", + npm_config_global: "true", + npm_config_location: "global", + npm_config_package_lock: "true", + }, + { + cacheDir: "/tmp/openclaw-npm-cache", + legacyPeerDeps: true, + packageLock: false, + quiet: true, + }, + ), + ).toEqual({ + PATH: "/usr/bin:/bin", + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", + NPM_CONFIG_IGNORE_SCRIPTS: "true", + npm_config_audit: "false", + npm_config_cache: "/tmp/openclaw-npm-cache", + npm_config_dry_run: "false", + npm_config_fetch_retries: "5", + npm_config_fetch_retry_maxtimeout: "120000", + npm_config_fetch_retry_mintimeout: "10000", + npm_config_fetch_timeout: "300000", + npm_config_fund: "false", + npm_config_global: "false", + npm_config_legacy_peer_deps: "true", + npm_config_location: "project", + npm_config_loglevel: "error", + npm_config_package_lock: "false", + npm_config_progress: "false", + npm_config_save: "false", + npm_config_yes: "true", + }); + }); +}); diff --git a/src/infra/safe-package-install.ts b/src/infra/safe-package-install.ts new file mode 100644 index 00000000000..8481be0f64c --- /dev/null +++ b/src/infra/safe-package-install.ts @@ -0,0 +1,49 @@ +import type { NpmProjectInstallEnvOptions } from "./npm-install-env.js"; +import { createNpmProjectInstallEnv } from "./npm-install-env.js"; + +export type SafeNpmInstallEnvOptions = NpmProjectInstallEnvOptions & { + legacyPeerDeps?: boolean; + packageLock?: boolean; + quiet?: boolean; +}; + +export type SafeNpmInstallArgsOptions = { + loglevel?: "error" | "silent"; + noAudit?: boolean; + noFund?: boolean; + omitDev?: boolean; +}; + +export function createSafeNpmInstallEnv( + env: NodeJS.ProcessEnv, + options: SafeNpmInstallEnvOptions = {}, +): NodeJS.ProcessEnv { + const nextEnv: NodeJS.ProcessEnv = { + ...createNpmProjectInstallEnv(env, options), + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", + NPM_CONFIG_IGNORE_SCRIPTS: "true", + npm_config_audit: "false", + npm_config_fund: "false", + npm_config_package_lock: options.packageLock === true ? "true" : "false", + ...(options.legacyPeerDeps ? { npm_config_legacy_peer_deps: "true" } : {}), + }; + if (options.quiet) { + Object.assign(nextEnv, { + npm_config_loglevel: "error", + npm_config_progress: "false", + npm_config_yes: "true", + }); + } + return nextEnv; +} + +export function createSafeNpmInstallArgs(options: SafeNpmInstallArgsOptions = {}): string[] { + return [ + "install", + ...(options.omitDev ? ["--omit=dev"] : []), + ...(options.loglevel ? [`--loglevel=${options.loglevel}`] : []), + "--ignore-scripts", + ...(options.noAudit ? ["--no-audit"] : []), + ...(options.noFund ? ["--no-fund"] : []), + ]; +} diff --git a/src/plugins/bundled-runtime-deps-package-manager.ts b/src/plugins/bundled-runtime-deps-package-manager.ts index 6c21f76893e..a5e64d83b20 100644 --- a/src/plugins/bundled-runtime-deps-package-manager.ts +++ b/src/plugins/bundled-runtime-deps-package-manager.ts @@ -1,6 +1,9 @@ import fs from "node:fs"; import path from "node:path"; -import { createNpmProjectInstallEnv } from "../infra/npm-install-env.js"; +import { + createSafeNpmInstallArgs, + createSafeNpmInstallEnv, +} from "../infra/safe-package-install.js"; export type BundledRuntimeDepsNpmRunner = { command: string; @@ -21,11 +24,13 @@ export function createBundledRuntimeDepsInstallEnv( options: { cacheDir?: string } = {}, ): NodeJS.ProcessEnv { const nextEnv: NodeJS.ProcessEnv = { - ...createNpmProjectInstallEnv(env, options), + ...createSafeNpmInstallEnv(env, { + ...options, + legacyPeerDeps: true, + packageLock: true, + }), npm_config_audit: "false", npm_config_fund: "false", - npm_config_legacy_peer_deps: "true", - npm_config_package_lock: "true", }; for (const key of Object.keys(nextEnv)) { if (key.toLowerCase() === NPM_EXECPATH_ENV_KEY) { @@ -36,7 +41,7 @@ export function createBundledRuntimeDepsInstallEnv( } export function createBundledRuntimeDepsInstallArgs(): string[] { - return ["install", "--ignore-scripts", "--no-audit", "--no-fund", "--omit=dev"]; + return [...createSafeNpmInstallArgs({ noAudit: true, noFund: true }), "--omit=dev"]; } function createBundledRuntimeDepsPnpmInstallArgs(params: { storeDir: string }): string[] { diff --git a/src/plugins/bundled-runtime-deps.test.ts b/src/plugins/bundled-runtime-deps.test.ts index e9186cdf4e5..dd417215b06 100644 --- a/src/plugins/bundled-runtime-deps.test.ts +++ b/src/plugins/bundled-runtime-deps.test.ts @@ -125,6 +125,8 @@ describe("resolveBundledRuntimeDepsNpmRunner", () => { { cacheDir: "/opt/openclaw/runtime-cache" }, ), ).toEqual({ + COREPACK_ENABLE_DOWNLOAD_PROMPT: "0", + NPM_CONFIG_IGNORE_SCRIPTS: "true", PATH: "/usr/bin:/bin", npm_config_audit: "false", npm_config_cache: "/opt/openclaw/runtime-cache",