From 05bbd48c514ad7511a99ea0ebdeca7d022cc8646 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 17 Aug 2026 01:00:17 -0700 Subject: [PATCH] docs(install): clarify lifecycle script trust (#124727) * docs(install): approve OpenClaw lifecycle scripts Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae * fix(update): bind npm lifecycle policy to install owner Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae * fix(update): gate npm lifecycle policy before mutation * test(update): cover npm lifecycle ownership * fix(update): defer backup cleanup until package preflight passes Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae * fix(update): preflight npm policy before service stop Amp-Thread-ID: https://ampcode.com/threads/T-01a00ae0-190d-718b-8a76-b75f3e8d1fae * docs(install): avoid unsupported lifecycle claim --------- Co-authored-by: Amp --- README.md | 7 +- docs/gateway/sandboxing.md | 3 +- docs/index.md | 8 +- docs/install/daytona.md | 5 +- docs/install/index.md | 39 +++--- docs/install/macos-vm.md | 5 +- docs/install/updating.md | 53 ++++++--- docs/install/upstash.md | 5 +- docs/plan/cloud-workers.md | 2 +- docs/platforms/chromeos.md | 4 +- docs/platforms/linux.md | 2 +- docs/platforms/mac/bundled-gateway.md | 5 +- docs/platforms/mac/dev-setup.md | 11 +- docs/plugins/dependency-resolution.md | 8 +- src/cli/update-cli.test.ts | 111 ++++++++++++++++++ src/cli/update-cli/update-command-git.ts | 58 +++++++-- src/cli/update-cli/update-command-package.ts | 7 -- .../update-cli/update-command-post-core.ts | 9 +- src/cli/update-cli/update-command.ts | 13 ++ src/infra/package-update-steps.test.ts | 37 ++++++ src/infra/package-update-steps.ts | 49 ++++++++ .../update-global.pnpm11-discovery.test.ts | 7 ++ src/infra/update-global.test.ts | 73 ++++++++++++ src/infra/update-global.ts | 91 +++++++++++++- src/infra/update-runner-global.ts | 3 - src/infra/update-runner.test.ts | 28 +++++ 26 files changed, 568 insertions(+), 75 deletions(-) diff --git a/README.md b/README.md index 59b24ff324a2..e08c7300ed45 100644 --- a/README.md +++ b/README.md @@ -36,10 +36,13 @@ iwr -useb https://openclaw.ai/install.ps1 | iex Already manage Node.js? Install the published package instead (Node 22.22.3+, 24.15+, or 25.9+): ```bash -npm install -g openclaw@latest +npm install -g openclaw@latest --allow-scripts=openclaw ``` -See the [installation guide](https://docs.openclaw.ai/install) for npm 12 lifecycle-script requirements, Docker, Nix, and other deployment paths. +That command is for npm 12 or npm 11.16+. On npm 11.12 and earlier, omit +`--allow-scripts=openclaw`. If you have npm 11.13–11.15, upgrade npm first. See +the [installation guide](https://docs.openclaw.ai/install) for the lifecycle +script contract, Docker, Nix, and other deployment paths. ## Quick start diff --git a/docs/gateway/sandboxing.md b/docs/gateway/sandboxing.md index cf0331d6de0a..8c3fbc747cde 100644 --- a/docs/gateway/sandboxing.md +++ b/docs/gateway/sandboxing.md @@ -401,7 +401,8 @@ Default Docker image: `openclaw-sandbox:bookworm-slim` The `scripts/sandbox-setup.sh`, `scripts/sandbox-common-setup.sh`, and `scripts/sandbox-browser-setup.sh` helper scripts are only available when running from a [source checkout](https://github.com/openclaw/openclaw). They are not included in the npm package. -If you installed OpenClaw via `npm install -g openclaw`, use the inline `docker build` commands shown below instead. +If you installed the global OpenClaw npm package, use the inline `docker build` +commands shown below instead. diff --git a/docs/index.md b/docs/index.md index 2d15560c36a5..5a8f8f959c1f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -142,9 +142,15 @@ The Gateway is the single source of truth for sessions, routing, and channel con + On npm 12 or npm 11.16+: + ```bash - npm install -g openclaw@latest + npm install -g openclaw@latest --allow-scripts=openclaw ``` + + On npm 11.12 and earlier, omit `--allow-scripts=openclaw`. Upgrade npm + 11.13–11.15 before installing. + ```bash diff --git a/docs/install/daytona.md b/docs/install/daytona.md index 0aa9c615b445..94d6e938fa9b 100644 --- a/docs/install/daytona.md +++ b/docs/install/daytona.md @@ -242,8 +242,11 @@ personal-number mode, and self-chat details: [WhatsApp](/channels/whatsapp). The snapshot's global npm tree is owned by root, so plain `openclaw update` cannot write to it. Update from the sandbox SSH session with: +The command below is for npm 12 or npm 11.16+. On npm 11.12 and earlier, +omit `--allow-scripts=openclaw`; upgrade npm 11.13–11.15 first. + ```bash -sudo env "PATH=$PATH" npm install --global openclaw@latest +sudo env "PATH=$PATH" npm install --global openclaw@latest --allow-scripts=openclaw openclaw doctor ``` diff --git a/docs/install/index.md b/docs/install/index.md index 07a617618642..95fb2251c1f9 100644 --- a/docs/install/index.md +++ b/docs/install/index.md @@ -75,26 +75,31 @@ If you already manage Node yourself: + On npm 12 or npm 11.16+: + ```bash - npm install -g openclaw@latest + npm install -g openclaw@latest --allow-scripts=openclaw openclaw onboard --install-daemon ``` + On npm 11.12 and earlier, use the same command without + `--allow-scripts=openclaw`. Do not use npm 11.13–11.15 for this install; + upgrade to npm 11.16+ first. + - npm 12 blocks package lifecycle scripts by default, so the command above - skips OpenClaw's `preinstall` and `postinstall` steps — npm reports them - as `blocked because they are not covered by allowScripts`. Allow them - explicitly: + npm 12 blocks unapproved package lifecycle scripts by default. The + `--allow-scripts=openclaw` option explicitly allows OpenClaw's `preinstall` + and `postinstall` steps; without it, npm reports them as `blocked because + they are not covered by allowScripts`. - ```bash - npm install -g openclaw@latest --allow-scripts openclaw - ``` - - npm 11.16.x only warns that the scripts are `not yet covered by - allowScripts` and still runs them. If you want to clear that warning, be - aware that the `npm approve-scripts openclaw` command it suggests does not - work for a global install — it fails with `ENOMATCH No installed packages - match: openclaw`. npm 11.12 and earlier have no such policy. + npm 11.16 accepts the option but otherwise only warns that the scripts are + `not yet covered by allowScripts` and still runs them. npm 11.12 and earlier + have neither the policy nor the option, so their command must be unflagged. + npm 11.13–11.15 also lack the option, but they are transitional upstream + releases outside this documented install contract; upgrade rather than + relying on their unflagged behavior. The `npm approve-scripts openclaw` + command suggested by npm 11.16 does not work for a global install — it fails + with `ENOMATCH No installed packages match: openclaw`. @@ -117,12 +122,14 @@ If you already manage Node yourself: ```bash - bun add -g openclaw@latest + bun add -g --trust openclaw@latest openclaw onboard --install-daemon ``` - Bun can install the global package, but the resulting `openclaw` executable requires a supported Node runtime because OpenClaw state uses `node:sqlite`. + `--trust` allows OpenClaw's package lifecycle scripts for this install. Bun + can install the global package, but the resulting `openclaw` executable + requires a supported Node runtime because OpenClaw state uses `node:sqlite`. diff --git a/docs/install/macos-vm.md b/docs/install/macos-vm.md index 436358f44a28..8c6bcbf3f76b 100644 --- a/docs/install/macos-vm.md +++ b/docs/install/macos-vm.md @@ -116,10 +116,11 @@ Replace `youruser` with the account you created, and the IP with your VM's IP. ## 6) Install OpenClaw -Inside the VM: +Inside the VM, use the following command on npm 12 or npm 11.16+. On npm 11.12 +and earlier, omit `--allow-scripts=openclaw`; upgrade npm 11.13–11.15 first. ```bash -npm install -g openclaw@latest +npm install -g openclaw@latest --allow-scripts=openclaw openclaw onboard --install-daemon ``` diff --git a/docs/install/updating.md b/docs/install/updating.md index f85102ce8cd1..3f6b2399726f 100644 --- a/docs/install/updating.md +++ b/docs/install/updating.md @@ -45,9 +45,12 @@ verifies the selected exact package, and installs that exact version. Missing or inconsistent registry data fails closed; it never falls back to `latest`. If the selected version is older than the installed version, the normal downgrade confirmation still applies. The CLI persists the channel after a -successful core update; a direct `npm install -g openclaw@extended-stable` -does not update `update.channel`, but a final extended-stable package version -still checks only the verified `extended-stable` selector for update availability. +successful core update; a direct +`npm install -g openclaw@extended-stable --allow-scripts=openclaw` does not +update `update.channel`, but a final extended-stable package version still +checks only the verified `extended-stable` selector for update availability. +That direct command is for npm 12 or npm 11.16+. On npm 11.12 and earlier, +omit `--allow-scripts=openclaw`; upgrade npm 11.13–11.15 first. After the core swap, eligible official npm plugins with bare/default or `latest` intent converge to that exact core version. Exact pins and explicit non-`latest` tags, third-party plugins, and non-npm sources remain unchanged. @@ -154,8 +157,11 @@ curl -fsSL https://openclaw.ai/install.sh | bash -s -- --install-method npm --ve ## Alternative: manual npm, pnpm, or bun +The npm command below is for npm 12 or npm 11.16+. On npm 11.12 and earlier, +omit `--allow-scripts=openclaw`; upgrade npm 11.13–11.15 first. + ```bash -npm i -g openclaw@latest +npm i -g openclaw@latest --allow-scripts=openclaw ``` Prefer `openclaw update` for supervised installs: it can coordinate the package @@ -171,9 +177,12 @@ manual replacement. Use the same profile flags/environment you normally use for that Gateway. Replace `/usr/bin/npm` with the system npm that owns the root-owned global prefix on your host: +The npm command below follows the same version contract: use the flag on npm 12 +or npm 11.16+, omit it on npm 11.12 and earlier, and upgrade npm 11.13–11.15. + ```bash openclaw gateway stop -sudo /usr/bin/npm i -g openclaw@latest +sudo /usr/bin/npm i -g openclaw@latest --allow-scripts=openclaw openclaw gateway install --force openclaw gateway restart ``` @@ -194,11 +203,13 @@ Node version during `preinstall`; only then does OpenClaw verify the packaged `dist` inventory and swap the clean package tree into the real global prefix. A packed completion guard is omitted from the expected inventory and removed only after `preinstall` succeeds, so skipped lifecycle scripts also fail before the -swap. On npm 12 and newer, the updater approves only the candidate OpenClaw -lifecycle; transitive dependency scripts remain blocked. This avoids npm -overlaying a new package onto stale files from the old one. If the install -command fails, OpenClaw retries once with `--omit=optional`, which helps hosts -where native optional dependencies cannot compile. +swap. The updater probes the owning npm before mutation. On npm 11.12 and +earlier it omits the unsupported lifecycle-policy flag; on npm 11.13–11.15 it +stops with upgrade guidance. On npm 12 and npm 11.16+, it approves only the +candidate OpenClaw lifecycle; transitive dependency scripts remain unapproved. +This avoids npm overlaying a new package onto stale files from the old one. If +the install command fails, OpenClaw retries once with `--omit=optional`, which +helps hosts where native optional dependencies cannot compile. OpenClaw-managed npm update and plugin-update commands also clear npm's `min-release-age` supply-chain quarantine (or the older `before` config key) @@ -206,7 +217,7 @@ for the child npm process. That policy exists for general protection, but an explicit OpenClaw update means "install the selected release now." ```bash -pnpm add -g openclaw@latest +pnpm add -g --allow-build=openclaw openclaw@latest ``` If pnpm 11 installed OpenClaw 2026.7.1, run that manual command once. That @@ -224,9 +235,12 @@ comma-separated group manually so its sibling packages and build policy stay intact. ```bash -bun add -g openclaw@latest +bun add -g --trust openclaw@latest ``` +`--trust` allows OpenClaw's lifecycle scripts. The canonical `openclaw update` +path applies the same OpenClaw-only Bun trust when it owns the install. + ### Advanced npm install topics @@ -431,16 +445,23 @@ automatically replacing the package again. If the CLI update path is unavailable, use the same package manager and install scope that own the current Gateway: +The npm command below is for npm 12 or npm 11.16+. On npm 11.12 and earlier, +omit `--allow-scripts=openclaw`; upgrade npm 11.13–11.15 first. + ```bash openclaw gateway stop -npm i -g openclaw@ +npm i -g openclaw@ --allow-scripts=openclaw openclaw gateway install --force openclaw gateway restart ``` -Replace `npm` with `pnpm` or `bun` when that manager owns the install. During -incident recovery, prevent an enabled auto-updater from immediately applying a -newer release by setting `OPENCLAW_NO_AUTO_UPDATE=1` in the Gateway environment. +For a pnpm-owned install, use +`pnpm add -g --allow-build=openclaw openclaw@` instead. For +a Bun-owned install, use +`bun add -g --trust openclaw@`; `--trust` allows OpenClaw's +lifecycle scripts. During incident recovery, prevent an enabled auto-updater +from immediately applying a newer release by setting +`OPENCLAW_NO_AUTO_UPDATE=1` in the Gateway environment. ### Roll back a source checkout diff --git a/docs/install/upstash.md b/docs/install/upstash.md index a46b6a3cdc83..fe32a6b61c67 100644 --- a/docs/install/upstash.md +++ b/docs/install/upstash.md @@ -39,10 +39,11 @@ The keepalive options reduce idle tunnel drops during onboarding. ## Install OpenClaw -Inside the Box: +Inside the Box, use the following command on npm 12 or npm 11.16+. On npm 11.12 +and earlier, omit `--allow-scripts=openclaw`; upgrade npm 11.13–11.15 first. ```bash -sudo npm install -g openclaw +sudo npm install -g openclaw --allow-scripts=openclaw ``` ## Run onboarding diff --git a/docs/plan/cloud-workers.md b/docs/plan/cloud-workers.md index b46a7fe97f6c..d0c77ebdad30 100644 --- a/docs/plan/cloud-workers.md +++ b/docs/plan/cloud-workers.md @@ -88,7 +88,7 @@ RPCs: `environments.create`, `environments.destroy`, extended `environments.list No bespoke worker artifact, and no dependence on npm availability: - Canonical install for all modes: a gateway-produced, content-hashed worker bundle (the gateway's own build output packed as a tarball), pushed over SSH and installed on the box. This covers dev builds and unreleased commits by construction. -- `npm i -g openclaw@` is an optimization when the gateway runs a released version; never `latest`. +- On npm 12 or npm 11.16+, `npm i -g openclaw@ --allow-scripts=openclaw` is an optimization when the gateway runs a released version; never `latest`. On npm 11.12 and earlier, omit `--allow-scripts=openclaw`; upgrade npm 11.13–11.15 first. - Bootstrap is idempotent; a warm lease with a matching bundle hash skips install. Raw machines may need a networked toolchain phase (Node runtime) — part of the setup phase, closed afterwards. - Handshake verifies worker build hash, protocol feature set, and runtime compatibility. The existing gateway version/protocol checks are insufficient for this (SSH-tunneled nodes are exempted from exact-version rejection), so worker admission does its own exact-build check. diff --git a/docs/platforms/chromeos.md b/docs/platforms/chromeos.md index 74a12d648c66..dbf3fb6a745d 100644 --- a/docs/platforms/chromeos.md +++ b/docs/platforms/chromeos.md @@ -53,7 +53,9 @@ Full server guidance lives in the [Linux guide](/platforms/linux) and the ## Prefer the native install over Docker On a single user Chromebook, use the native npm install (the installer script, -or a global `npm i -g openclaw@latest`) rather than [Docker](/install/docker). +or `npm i -g openclaw@latest --allow-scripts=openclaw` on npm 12 or npm +11.16+) rather than [Docker](/install/docker). On npm 11.12 and earlier, omit +`--allow-scripts=openclaw`; upgrade npm 11.13–11.15 first. Docker works inside Crostini, but Docker in Crostini adds friction: if you use the Claude Code CLI as your model runtime, it has to be installed and logged in diff --git a/docs/platforms/linux.md b/docs/platforms/linux.md index 2baceabe603b..2b503b36f424 100644 --- a/docs/platforms/linux.md +++ b/docs/platforms/linux.md @@ -130,7 +130,7 @@ Linux v1 uses one Canvas window. HTTP and HTTPS pages are renderable, but A2UI a The CLI remains the simplest option for a headless server, a VPS, or a remote Gateway: 1. Install Node 26 (recommended), or another supported release: Node 22.22.3+, Node 24.15+, or Node 25.9+. -2. `npm i -g openclaw@latest` +2. On npm 12 or npm 11.16+, run `npm i -g openclaw@latest --allow-scripts=openclaw`. On npm 11.12 and earlier, omit `--allow-scripts=openclaw`; upgrade npm 11.13–11.15 first. 3. `openclaw onboard --install-daemon` 4. From your laptop: `ssh -N -L 18789:127.0.0.1:18789 @` 5. Open `http://127.0.0.1:18789/` and authenticate with the configured shared diff --git a/docs/platforms/mac/bundled-gateway.md b/docs/platforms/mac/bundled-gateway.md index 829890220878..1a32655d4df5 100644 --- a/docs/platforms/mac/bundled-gateway.md +++ b/docs/platforms/mac/bundled-gateway.md @@ -29,8 +29,11 @@ OpenClaw package. For a manual install, use Node 26 (recommended) or another supported release: Node 22.22.3+, Node 24.15+, or Node 25.9+. Install `openclaw` globally: +The command below is for npm 12 or npm 11.16+. On npm 11.12 and earlier, +omit `--allow-scripts=openclaw`; upgrade npm 11.13–11.15 first. + ```bash -npm install -g openclaw@ +npm install -g openclaw@ --allow-scripts=openclaw ``` Use **Retry setup** after a failed automatic setup. If that still fails, diff --git a/docs/platforms/mac/dev-setup.md b/docs/platforms/mac/dev-setup.md index 60c5402e61bb..ac585d878cac 100644 --- a/docs/platforms/mac/dev-setup.md +++ b/docs/platforms/mac/dev-setup.md @@ -55,12 +55,17 @@ matching user-space CLI and runtime before starting the Gateway wizard. For manual development recovery, install the matching CLI yourself: +The npm command below is for npm 12 or npm 11.16+. On npm 11.12 and earlier, +omit `--allow-scripts=openclaw`; upgrade npm 11.13–11.15 first. + ```bash -npm install -g openclaw@ +npm install -g openclaw@ --allow-scripts=openclaw ``` -`pnpm add -g openclaw@` and `bun add -g openclaw@` also -work. Node remains the recommended runtime for the Gateway itself. +`pnpm add -g --allow-build=openclaw openclaw@` and +`bun add -g --trust openclaw@` also work. Bun's `--trust` allows the +OpenClaw lifecycle scripts for that install. Node remains the recommended +runtime for the Gateway itself. ## Troubleshooting diff --git a/docs/plugins/dependency-resolution.md b/docs/plugins/dependency-resolution.md index ddf5515e33f2..def508860c87 100644 --- a/docs/plugins/dependency-resolution.md +++ b/docs/plugins/dependency-resolution.md @@ -201,10 +201,16 @@ the repository root does not prepare bundled plugin dependencies. | Install shape | Bundled plugin location | Dependency owner | | -------------------------------- | ------------------------------------- | -------------------------------------------------------------------- | -| `npm install -g openclaw` | Built runtime tree inside the package | OpenClaw package and explicit plugin install/update/doctor flows | +| Global npm install | Built runtime tree inside the package | OpenClaw package and explicit plugin install/update/doctor flows | | Git checkout plus `pnpm install` | `extensions/` workspace packages | The pnpm workspace, including each plugin package's own dependencies | | `openclaw plugins install ...` | Managed npm project/git/ClawHub root | The plugin install/update flow | +For the global npm row, use +`npm install -g openclaw --allow-scripts=openclaw` on npm 12 or npm 11.16+. +On npm 11.12 and earlier, omit `--allow-scripts=openclaw`; upgrade npm +11.13–11.15 first. Plugin dependency convergence remains intentionally +script-disabled and continues to use the `--ignore-scripts` commands above. + ## Legacy cleanup Older OpenClaw versions generated bundled-plugin dependency roots at startup diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index ce2f63aa5bc9..bf265dcc4353 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -1267,6 +1267,9 @@ describe("update-cli", () => { const mockNpmGlobalRoot = (nodeModules: string) => { vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (Array.isArray(argv) && argv[1] === "--version") { + return commandResult({ stdout: "12.0.0\n" }); + } if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { return commandResult({ stdout: `${nodeModules}\n` }); } @@ -1410,6 +1413,9 @@ describe("update-cli", () => { }, }); vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (argv[1] === "--version") { + return commandResult({ stdout: "12.0.0\n" }); + } if (argv[0] === "npm" && argv[1] === "pack") { const destination = argv[argv.indexOf("--pack-destination") + 1]; if (destination) { @@ -4392,6 +4398,9 @@ describe("update-cli", () => { }); readPackageVersion.mockResolvedValue("2026.3.23"); vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (Array.isArray(argv) && argv[1] === "--version") { + return commandResult({ stdout: "12.0.0\n" }); + } if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { return commandResult({ stdout: nodeModules }); } @@ -4415,6 +4424,9 @@ describe("update-cli", () => { readPackageVersion.mockResolvedValue("2026.4.20"); primeNpmChannelTag("latest", "2026.4.25"); vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (Array.isArray(argv) && argv[1] === "--version") { + return commandResult({ stdout: "12.0.0\n" }); + } if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { return commandResult({ stdout: `${nodeModules}\n` }); } @@ -4505,6 +4517,9 @@ describe("update-cli", () => { primeNpmChannelTag("latest", "2026.4.21"); mockFileBackedPathExists(); vi.mocked(runCommandWithTimeout).mockImplementation(async (argv, options) => { + if (Array.isArray(argv) && argv[1] === "--version") { + return commandResult({ stdout: "12.0.0\n" }); + } if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { return commandResult({ stdout: `${nodeModules}\n` }); } @@ -4571,6 +4586,9 @@ describe("update-cli", () => { primeNpmChannelTag("latest", "2026.4.21"); mockFileBackedPathExists(); vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (Array.isArray(argv) && argv[1] === "--version") { + return commandResult({ stdout: "12.0.0\n" }); + } if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { return commandResult({ stdout: `${nodeModules}\n` }); } @@ -4614,6 +4632,9 @@ describe("update-cli", () => { if (!Array.isArray(argv)) { return commandResult(); } + if (argv[1] === "--version") { + return commandResult({ stdout: "12.0.0\n" }); + } if (argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { return commandResult({ stdout: `${nodeModules}\n` }); } @@ -4658,6 +4679,34 @@ describe("update-cli", () => { expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); }); + it.each(["11.13.0", "11.15.9"])( + "refuses npm %s before stopping the managed gateway or cleaning update backups", + async (npmVersion) => { + const tempDir = await createTrackedTempDir("openclaw-update-npm-policy-"); + const { nodeModules, entryPath } = await setupInstalledPackageRoot(tempDir); + const backupDir = path.join(nodeModules, ".openclaw-interrupted"); + await fs.mkdir(backupDir, { recursive: true }); + mockRunningManagedGateway(["node", entryPath, "gateway", "run"]); + mockFileBackedPathExists(); + vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (argv[0] === "npm" && argv[1] === "--version") { + return commandResult({ stdout: `${npmVersion}\n` }); + } + if (argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { + return commandResult({ stdout: `${nodeModules}\n` }); + } + return commandResult(); + }); + + await updateCommand({ yes: true }); + + expect(serviceStop).not.toHaveBeenCalled(); + expect(packageInstallCommandCall()).toBeUndefined(); + await expect(fs.access(backupDir)).resolves.toBeUndefined(); + expect(getErrorOutput()).toContain(`npm ${npmVersion} cannot safely approve`); + }, + ); + it("stops a running managed gateway before package replacement", async () => { const platformSpy = vi.spyOn(process, "platform", "get").mockReturnValue("win32"); const processOnSpy = vi.spyOn(process, "on"); @@ -4834,6 +4883,9 @@ describe("update-cli", () => { mockFileBackedPathExists(); mockNpmGlobalRoot(nodeModules); vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (argv[1] === "--version") { + return commandResult({ stdout: "12.0.0\n" }); + } if (argv[0] === "npm" && argv[1] === "i" && argv[2] === "-g") { throw new Error("package replacement failed"); } @@ -4872,6 +4924,9 @@ describe("update-cli", () => { mockFileBackedPathExists(); mockNpmGlobalRoot(nodeModules); vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (argv[1] === "--version") { + return commandResult({ stdout: "12.0.0\n" }); + } if (argv[0] === "npm" && argv[1] === "i" && argv[2] === "-g") { throw new Error("package replacement failed"); } @@ -5019,6 +5074,9 @@ describe("update-cli", () => { serviceReadRuntime.mockResolvedValue({ status: "stopped", state: "stopped" }); suspendScheduledTaskAutoStartForUpdate.mockResolvedValue(true); vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (argv[1] === "--version") { + return commandResult({ stdout: "12.0.0\n" }); + } if (argv[0] === "npm" && argv[1] === "i" && argv[2] === "-g") { throw new Error("update invariant broke"); } @@ -5440,6 +5498,9 @@ describe("update-cli", () => { }); vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (Array.isArray(argv) && argv[1] === "--version") { + return commandResult({ stdout: "12.0.0\n" }); + } if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { return commandResult({ stdout: `${nodeModules}\n` }); } @@ -5505,6 +5566,9 @@ describe("update-cli", () => { if (!Array.isArray(argv)) { return commandResult(); } + if (isOwningNpmCommand(argv[0]) && argv[1] === "--version") { + return commandResult({ stdout: "12.0.0\n" }); + } if (argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { return commandResult({ stdout: `${pathNpmRoot}\n` }); } @@ -5719,6 +5783,9 @@ describe("update-cli", () => { if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") { return commandResult({ stdout: "v22.18.0\n" }); } + if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "--version") { + return commandResult({ stdout: "12.0.0\n" }); + } return commandResult(); }); nodeVersionSatisfiesEngine.mockReturnValue(false); @@ -5762,6 +5829,13 @@ describe("update-cli", () => { if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") { return commandResult({ stdout: "v22.22.0\n" }); } + if ( + Array.isArray(argv) && + (argv[0] === serviceNpm || argv[0] === serviceNpmReal) && + argv[1] === "--version" + ) { + return commandResult({ stdout: "12.0.0\n" }); + } if ( Array.isArray(argv) && (argv[0] === serviceNpm || argv[0] === serviceNpmReal) && @@ -5860,6 +5934,13 @@ describe("update-cli", () => { if (Array.isArray(argv) && argv[0] === process.execPath && argv[1] === "--version") { return commandResult({ stdout: "v24.15.0\n" }); } + if ( + Array.isArray(argv) && + (argv[0] === serviceNpm || argv[0] === serviceNpmReal) && + argv[1] === "--version" + ) { + return commandResult({ stdout: "12.0.0\n" }); + } if ( Array.isArray(argv) && (argv[0] === serviceNpm || argv[0] === serviceNpmReal) && @@ -5929,6 +6010,9 @@ describe("update-cli", () => { if (Array.isArray(argv) && argv[0] === serviceNode && argv[1] === "--version") { return commandResult({ stdout: "v24.14.0\n" }); } + if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "--version") { + return commandResult({ stdout: "12.0.0\n" }); + } if (Array.isArray(argv) && argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { // PATH npm returns Node-B's root, NOT the service root. return commandResult({ stdout: `${nodeBGlobalRoot}\n` }); @@ -6755,6 +6839,33 @@ describe("update-cli", () => { expectNoSideEffects(runRestartScript, runDaemonRestart); expect(defaultRuntime.exit).not.toHaveBeenCalledWith(1); }); + + it.each(["11.13.0", "11.15.9"])( + "refuses npm %s package-to-dev updates before checkout or install", + async (npmVersion) => { + const packageRoot = createCaseDir("openclaw-npm-transition"); + mockPackageInstallStatus(packageRoot); + vi.mocked(runCommandWithTimeout).mockImplementation(async (argv) => { + if (argv[0] === "npm" && argv[1] === "--version") { + return commandResult({ stdout: `${npmVersion}\n` }); + } + if (argv[0] === "npm" && argv[1] === "root" && argv[2] === "-g") { + return commandResult({ stdout: `${path.dirname(packageRoot)}\n` }); + } + return commandResult(); + }); + + await updateCommand({ channel: "dev", yes: true, restart: false }); + + expect(runGatewayUpdate).not.toHaveBeenCalled(); + expect(commandCalls().some(([argv]) => argv[0] === "git")).toBe(false); + expect( + commandCalls().some(([argv]) => argv[0] === "npm" && argv[1] === "i" && argv[2] === "-g"), + ).toBe(false); + expect(getErrorOutput()).toContain(`npm ${npmVersion} cannot safely approve`); + }, + ); + it("explains why git updates cannot run with edited files", async () => { vi.mocked(defaultRuntime.log).mockClear(); vi.mocked(defaultRuntime.error).mockClear(); diff --git a/src/cli/update-cli/update-command-git.ts b/src/cli/update-cli/update-command-git.ts index 00f7ec26bfea..b5b54f0cd229 100644 --- a/src/cli/update-cli/update-command-git.ts +++ b/src/cli/update-cli/update-command-git.ts @@ -4,6 +4,7 @@ import { createGlobalInstallEnv, globalInstallArgs, resolveGlobalInstallTarget, + resolveNpmLifecyclePolicyGate, resolvePnpmGlobalDirFromGlobalRoot, } from "../../infra/update-global.js"; import { runGatewayUpdate, type UpdateRunResult } from "../../infra/update-runner.js"; @@ -136,6 +137,39 @@ export async function updateGitInstall(params: { const updateRoot = params.switchToGit ? resolveGitInstallDir() : params.root; const effectiveTimeout = params.timeoutMs ?? DEFAULT_UPDATE_STEP_TIMEOUT_MS; const installEnv = await createGlobalInstallEnv(); + const runCommand = createGlobalCommandRunner(); + const installTarget = params.switchToGit + ? await resolveGlobalInstallTarget({ + manager: await resolveGlobalManager({ + root: params.root, + installKind: params.installKind, + timeoutMs: effectiveTimeout, + }), + runCommand, + timeoutMs: effectiveTimeout, + pkgRoot: params.root, + }) + : null; + const npmLifecycleGate = installTarget + ? resolveNpmLifecyclePolicyGate(installTarget) + : { policy: null, error: null }; + + // Package-to-Git updates must settle package-manager policy before cloning or + // updating the checkout; carry this exact decision into the later install. + if (npmLifecycleGate.error) { + const result: UpdateRunResult = { + status: "error", + mode: "git", + root: updateRoot, + reason: "npm lifecycle policy preflight", + steps: [], + durationMs: Date.now() - params.startedAt, + }; + params.stop(); + defaultRuntime.error(npmLifecycleGate.error); + defaultRuntime.exit(1); + return result; + } const cloneStep = params.switchToGit ? await ensureGitCheckout({ @@ -177,25 +211,23 @@ export async function updateGitInstall(params: { const steps = [...(cloneStep ? [cloneStep] : []), ...updateResult.steps]; if (params.switchToGit && updateResult.status === "ok") { - const manager = await resolveGlobalManager({ - root: params.root, - installKind: params.installKind, - timeoutMs: effectiveTimeout, - }); - const runCommand = createGlobalCommandRunner(); - const installTarget = await resolveGlobalInstallTarget({ - manager, - runCommand, - timeoutMs: effectiveTimeout, - pkgRoot: params.root, - }); + if (!installTarget) { + throw new Error("global install target missing after package-to-Git preflight"); + } const installLocation = installTarget.manager === "pnpm" ? resolvePnpmGlobalDirFromGlobalRoot(installTarget.globalRoot) : null; const installStep = await runUpdateStep({ name: "global install", - argv: globalInstallArgs(installTarget, updateRoot, undefined, installLocation, updateRoot), + argv: globalInstallArgs( + installTarget, + updateRoot, + undefined, + installLocation, + updateRoot, + npmLifecycleGate.policy ?? undefined, + ), cwd: updateRoot, env: installEnv, timeoutMs: effectiveTimeout, diff --git a/src/cli/update-cli/update-command-package.ts b/src/cli/update-cli/update-command-package.ts index 8f7f962e2bf9..35f54fe8a342 100644 --- a/src/cli/update-cli/update-command-package.ts +++ b/src/cli/update-cli/update-command-package.ts @@ -20,7 +20,6 @@ import { } from "../../infra/update-doctor-result.js"; import { createGlobalInstallEnv, - cleanupGlobalRenameDirs, resolveGlobalInstallSpec, resolveGlobalInstallTarget, type ResolvedGlobalInstallTarget, @@ -94,12 +93,6 @@ export async function runPackageInstallUpdate(params: { }); const beforeVersion = pkgRoot ? await readPackageVersion(pkgRoot) : null; - if (pkgRoot) { - await cleanupGlobalRenameDirs({ - globalRoot: path.dirname(pkgRoot), - packageName, - }); - } const diskWarning = createLowDiskSpaceWarning({ targetPath: pkgRoot ? path.dirname(pkgRoot) : params.root, diff --git a/src/cli/update-cli/update-command-post-core.ts b/src/cli/update-cli/update-command-post-core.ts index 7895b057325d..f6317f7819ff 100644 --- a/src/cli/update-cli/update-command-post-core.ts +++ b/src/cli/update-cli/update-command-post-core.ts @@ -101,7 +101,11 @@ const POST_CORE_UPDATE_RESULT_POLL_MS = 100; export async function reportPreMutationUpdateFailure(params: { root: string; installKind: "git" | "package" | "unknown"; - reason: ExtendedStableFailureReason | typeof EXTENDED_STABLE_TAG_UNSUPPORTED_REASON; + reason: + | ExtendedStableFailureReason + | typeof EXTENDED_STABLE_TAG_UNSUPPORTED_REASON + | "npm lifecycle policy preflight"; + message?: string; opts: UpdateCommandOptions; controlPlaneUpdateSentinelMeta: ControlPlaneUpdateSentinelMetaFile["meta"] | null; }): Promise { @@ -120,6 +124,9 @@ export async function reportPreMutationUpdateFailure(params: { jsonMode: Boolean(params.opts.json), }); } + if (params.message) { + defaultRuntime.error(params.message); + } printResult(result, params.opts); defaultRuntime.exit(1); } diff --git a/src/cli/update-cli/update-command.ts b/src/cli/update-cli/update-command.ts index 578e3fe536ed..0dbf2a032760 100644 --- a/src/cli/update-cli/update-command.ts +++ b/src/cli/update-cli/update-command.ts @@ -37,6 +37,7 @@ import { createGlobalInstallEnv, resolveGlobalInstallSpec, resolveGlobalInstallTarget, + resolveNpmLifecyclePolicyGate, type ResolvedGlobalInstallTarget, } from "../../infra/update-global.js"; import { updateInstallRootsMatch } from "../../infra/update-install-root.js"; @@ -375,6 +376,18 @@ async function updateCommandInternal( managedServiceRootRedirect !== null || managedServiceNodeRunner !== undefined, packageName: installedPackageName, }); + const npmLifecycleGate = resolveNpmLifecyclePolicyGate(packageInstallTarget); + if (npmLifecycleGate.error) { + await reportPreMutationUpdateFailure({ + root, + installKind: updateInstallKind, + reason: "npm lifecycle policy preflight", + message: npmLifecycleGate.error, + opts, + controlPlaneUpdateSentinelMeta, + }); + return; + } } const npmMetadataCommand = packageInstallTarget?.manager === "npm" ? packageInstallTarget.command : undefined; diff --git a/src/infra/package-update-steps.test.ts b/src/infra/package-update-steps.test.ts index 308d769de050..d8062b35a74d 100644 --- a/src/infra/package-update-steps.test.ts +++ b/src/infra/package-update-steps.test.ts @@ -47,6 +47,10 @@ function createNpmTarget(globalRoot: string): ResolvedGlobalInstallTarget { command: "npm", globalRoot, packageRoot: path.join(globalRoot, "openclaw"), + npmOwner: { + version: "12.0.0", + lifecyclePolicy: "allow-scripts", + }, }; } @@ -75,6 +79,9 @@ async function expectPathMissing(filePath: string): Promise { function createRootRunner(globalRoot: string): CommandRunner { return async (argv) => { + if (argv.join(" ") === "npm --version") { + return { stdout: "12.0.0\n", stderr: "", code: 0 }; + } if (argv.join(" ") === "npm root -g") { return { stdout: `${globalRoot}\n`, stderr: "", code: 0 }; } @@ -155,6 +162,36 @@ describe("markPackagePostInstallDoctorAdvisory", () => { }); }); +describe("npm lifecycle policy preflight", () => { + it.each([ + { version: "11.13.0", message: "Upgrade the owning npm to 11.16" }, + { version: "11.15.9", message: "Upgrade the owning npm to 11.16" }, + { version: null, message: "Unable to determine the owning npm version" }, + ])("stops before mutation for npm $version", async ({ version, message }) => { + const runStep = vi.fn(); + const runCommand = vi.fn(); + const installTarget = createNpmTarget("/tmp/npm-policy-test/lib/node_modules"); + installTarget.npmOwner = { + version, + lifecyclePolicy: version ? "unsupported-transition" : null, + ...(version ? {} : { probeError: "version probe failed" }), + }; + + const result = await runGlobalPackageUpdateSteps({ + installTarget, + installSpec: "openclaw@2.0.0", + packageName: "openclaw", + runCommand, + runStep, + timeoutMs: 1000, + }); + + expect(runCommand).not.toHaveBeenCalled(); + expect(result.failedStep?.stderrTail).toContain(message); + expect(runStep).not.toHaveBeenCalled(); + }); +}); + describe("runGlobalPackageUpdateSteps", () => { it("installs npm updates into a clean staged prefix before swapping the global package", async () => { await withTestDir({ prefix: "openclaw-package-update-staged-" }, async (base) => { diff --git a/src/infra/package-update-steps.ts b/src/infra/package-update-steps.ts index cc4c1f7b6e40..b0811237ee1a 100644 --- a/src/infra/package-update-steps.ts +++ b/src/infra/package-update-steps.ts @@ -17,6 +17,7 @@ import { } from "./update-doctor-result.js"; import { collectInstalledGlobalPackageErrors, + cleanupGlobalRenameDirs, globalInstallArgs, globalInstallFallbackArgs, listActivePnpmIsolatedGlobalPackages, @@ -25,6 +26,7 @@ import { resolveNpmGlobalPrefixLayoutFromPrefix, resolvePnpmIsolatedInstallOwner, resolvePnpmGlobalDirFromGlobalRoot, + resolveNpmLifecyclePolicyGate, resolveExpectedInstalledVersionFromSpec, resolveGlobalInstallTarget, type CommandRunner, @@ -85,6 +87,32 @@ const PACKAGE_PREINSTALL_SCRIPT_PATH = path.join( "preinstall-package-manager-warning.mjs", ); +async function resolveNpmUpdateLifecyclePolicy(params: { + installTarget: ResolvedGlobalInstallTarget; +}): Promise<{ + policy: "unflagged" | "allow-scripts" | null; + failedStep: PackageUpdateStepResult | null; +}> { + const gate = resolveNpmLifecyclePolicyGate(params.installTarget); + if (!gate.error) { + return { policy: gate.policy, failedStep: null }; + } + const argv = [params.installTarget.command, "--version"]; + const version = params.installTarget.npmOwner?.version ?? ""; + return { + policy: null, + failedStep: { + name: "npm lifecycle policy preflight", + command: argv.join(" "), + cwd: process.cwd(), + durationMs: 0, + exitCode: 1, + stdoutTail: version || null, + stderrTail: gate.error, + }, + }; +} + async function resolveCanonicalPath(filePath: string): Promise { return path.resolve(await fs.realpath(filePath).catch(() => filePath)); } @@ -842,6 +870,17 @@ export async function runGlobalPackageUpdateSteps(params: { let packedInstallDir: string | null = null; try { + const npmPreflight = await resolveNpmUpdateLifecyclePolicy({ + installTarget: params.installTarget, + }); + if (npmPreflight.failedStep) { + return { + steps: [npmPreflight.failedStep], + verifiedPackageRoot: params.packageRoot ?? params.installTarget.packageRoot, + afterVersion: null, + failedStep: npmPreflight.failedStep, + }; + } const pnpmPreflight = await validatePnpmIsolatedUpdate({ installTarget: params.installTarget, packageName: params.packageName, @@ -857,6 +896,14 @@ export async function runGlobalPackageUpdateSteps(params: { failedStep: pnpmPreflight.failedStep, }; } + const packageRoot = params.packageRoot ?? params.installTarget.packageRoot; + if (packageRoot) { + // Lifecycle policy must refuse before cleanup can remove an interrupted update backup. + await cleanupGlobalRenameDirs({ + globalRoot: path.dirname(packageRoot), + packageName: params.packageName, + }); + } // Keep the preflight and mutation on the same pnpm executable. `pnpm bin -g` // already verifies its reported bin is on PATH, so no PATH rewrite is needed. const effectiveInstallEnv = params.env; @@ -933,6 +980,7 @@ export async function runGlobalPackageUpdateSteps(params: { undefined, installLocation, preparedSpec.installCwd, + npmPreflight.policy ?? undefined, ), ...(updateCwd ? { cwd: updateCwd } : {}), ...installEnv, @@ -965,6 +1013,7 @@ export async function runGlobalPackageUpdateSteps(params: { undefined, stagedInstall?.prefix, preparedSpec.installCwd, + npmPreflight.policy ?? undefined, ); if (fallbackArgv) { const fallbackStep = await params.runStep({ diff --git a/src/infra/update-global.pnpm11-discovery.test.ts b/src/infra/update-global.pnpm11-discovery.test.ts index b5f0760044a5..429fa329b8c4 100644 --- a/src/infra/update-global.pnpm11-discovery.test.ts +++ b/src/infra/update-global.pnpm11-discovery.test.ts @@ -280,6 +280,9 @@ describe("pnpm 11 global install discovery", () => { if (command === "npm root -g") { return { stdout: `${npmGlobalRoot}\n`, stderr: "", code: 0 }; } + if (command === "npm --version") { + return { stdout: "12.0.0\n", stderr: "", code: 0 }; + } if (command === "pnpm root -g") { return { stdout: `${path.join(base, "pnpm-home", "global", "v11")}\n`, @@ -307,6 +310,10 @@ describe("pnpm 11 global install discovery", () => { command: "npm", globalRoot: npmGlobalRoot, packageRoot, + npmOwner: { + version: "12.0.0", + lifecyclePolicy: "allow-scripts", + }, }); }); }); diff --git a/src/infra/update-global.test.ts b/src/infra/update-global.test.ts index f4916cb5dc65..2a7dee822594 100644 --- a/src/infra/update-global.test.ts +++ b/src/infra/update-global.test.ts @@ -71,6 +71,9 @@ function createNpmRootRunner(params: { overrideNpmRoot?: string; }): CommandRunner { return async (argv) => { + if (argv[1] === "--version") { + return { stdout: "12.0.0\n", stderr: "", code: 0 }; + } if (argv[0] === "npm") { return { stdout: `${params.defaultNpmRoot}\n`, stderr: "", code: 0 }; } @@ -113,6 +116,18 @@ describe("update global helpers", () => { ).toBe("openclaw@next"); }); + it("applies an unflagged npm policy to primary and retry argv", () => { + expect( + globalInstallArgs("npm", "openclaw@latest", null, null, null, "unflagged"), + ).not.toContain("--allow-scripts=openclaw"); + expect( + globalInstallFallbackArgs("npm", "openclaw@latest", null, null, null, "unflagged"), + ).toEqual(expect.arrayContaining(["--omit=optional"])); + expect( + globalInstallFallbackArgs("npm", "openclaw@latest", null, null, null, "unflagged"), + ).not.toContain("--allow-scripts=openclaw"); + }); + it("maps main and explicit package targets to install specs", () => { expect(resolveGlobalInstallSpec({ packageName: "openclaw", tag: "main" })).toBe( "github:openclaw/openclaw#main", @@ -196,6 +211,55 @@ describe("update global helpers", () => { }); }); + it.each([ + ["11.12.0", "unflagged"], + ["11.13.0", "unsupported-transition"], + ["11.14.0", "unsupported-transition"], + ["11.15.9", "unsupported-transition"], + ["11.16.0", "allow-scripts"], + ["12.0.0", "allow-scripts"], + ] as const)("binds npm %s lifecycle policy to the owning executable", async (version, policy) => { + await withTestDir({ prefix: "openclaw-npm-owner-" }, async (prefix) => { + const globalRoot = path.join(prefix, "lib", "node_modules"); + const packageRoot = path.join(globalRoot, "openclaw"); + const owningNpm = path.join(prefix, "bin", "npm"); + await Promise.all([ + fs.mkdir(packageRoot, { recursive: true }), + fs.mkdir(path.dirname(owningNpm), { recursive: true }), + ]); + await fs.writeFile(owningNpm, "", "utf8"); + const calls: string[][] = []; + const runCommand: CommandRunner = async (argv) => { + calls.push(argv); + if (argv[0] === owningNpm && argv[1] === "root") { + return { stdout: `${globalRoot}\n`, stderr: "", code: 0 }; + } + if (argv[0] === owningNpm && argv[1] === "--version") { + return { stdout: `${version}\n`, stderr: "", code: 0 }; + } + throw new Error(`unexpected command: ${argv.join(" ")}`); + }; + + await expect( + resolveGlobalInstallTarget({ + manager: "npm", + runCommand, + timeoutMs: 1000, + pkgRoot: packageRoot, + packageName: "openclaw", + }), + ).resolves.toMatchObject({ + command: owningNpm, + npmOwner: { + version, + lifecyclePolicy: policy, + }, + }); + expect(calls).toContainEqual([owningNpm, "--version"]); + expect(calls).not.toContainEqual(["npm", "--version"]); + }); + }); + it("defaults corepack download prompts off for global install env", async () => { const defaultEnv = await createGlobalInstallEnv({}); expect(defaultEnv?.COREPACK_ENABLE_DOWNLOAD_PROMPT).toBe("0"); @@ -355,6 +419,7 @@ describe("update global helpers", () => { command: "npm", globalRoot: nvmRoot, packageRoot: pkgRoot, + npmOwner: { version: "12.0.0", lifecyclePolicy: "allow-scripts" }, }); }); }); @@ -393,6 +458,7 @@ describe("update global helpers", () => { command: "npm", globalRoot: nvmRoot, packageRoot: pkgRoot, + npmOwner: { version: "12.0.0", lifecyclePolicy: "allow-scripts" }, }); }); }); @@ -420,6 +486,7 @@ describe("update global helpers", () => { command: "npm", globalRoot: nvmRoot, packageRoot: path.join(nvmRoot, "openclaw"), + npmOwner: { version: "12.0.0", lifecyclePolicy: "allow-scripts" }, }); }); }); @@ -446,6 +513,7 @@ describe("update global helpers", () => { command: "npm", globalRoot, packageRoot: pkgRoot, + npmOwner: { version: null, lifecyclePolicy: null }, }); }); }); @@ -488,6 +556,9 @@ describe("update global helpers", () => { await fs.mkdir(path.join(otherPnpmRoot, "openclaw"), { recursive: true }); const runCommand: CommandRunner = async (argv) => { + if (argv[1] === "--version") { + return { stdout: "12.0.0\n", stderr: "", code: 0 }; + } if (argv[0] === "npm" || argv[0] === customNpm) { return { stdout: `${pathNpmRoot}\n`, stderr: "", code: 0 }; } @@ -511,6 +582,7 @@ describe("update global helpers", () => { globalRoot: managedNpmRoot, packageRoot: pkgRoot, directNodeModulesRoot: true, + npmOwner: { version: "12.0.0", lifecyclePolicy: "allow-scripts" }, }); await expect( resolveGlobalInstallTarget({ @@ -526,6 +598,7 @@ describe("update global helpers", () => { globalRoot: managedNpmRoot, packageRoot: pkgRoot, directNodeModulesRoot: true, + npmOwner: { version: "12.0.0", lifecyclePolicy: "allow-scripts" }, }); expect( diff --git a/src/infra/update-global.ts b/src/infra/update-global.ts index 7ac3ff21fdfc..964445781994 100644 --- a/src/infra/update-global.ts +++ b/src/infra/update-global.ts @@ -54,6 +54,11 @@ export type ResolvedGlobalInstallTarget = ResolvedGlobalInstallCommand & { globalRoot: string | null; packageRoot: string | null; directNodeModulesRoot?: boolean; + npmOwner?: { + version: string | null; + lifecyclePolicy: NpmLifecyclePolicy | null; + probeError?: string; + }; }; const PRIMARY_PACKAGE_NAME = "openclaw"; @@ -78,6 +83,73 @@ export type NpmGlobalPrefixLayout = { binDir: string; }; +type NpmLifecyclePolicy = "unsupported-transition" | "unflagged" | "allow-scripts"; + +type SupportedNpmLifecyclePolicy = Exclude; + +type NpmLifecyclePolicyGate = + | { policy: SupportedNpmLifecyclePolicy | null; error: null } + | { policy: null; error: string }; + +/** Selects npm's lifecycle policy from the version of the owning executable. */ +function resolveNpmLifecyclePolicy(version: string): NpmLifecyclePolicy | null { + const parsed = parseSemver(version); + if (!parsed) { + return null; + } + if (parsed.major !== 11) { + return parsed.major >= 12 ? "allow-scripts" : "unflagged"; + } + if (parsed.minor <= 12) { + return "unflagged"; + } + return parsed.minor >= 16 ? "allow-scripts" : "unsupported-transition"; +} + +/** Resolves the owning npm policy once, before any update mutation. */ +export function resolveNpmLifecyclePolicyGate( + installTarget: ResolvedGlobalInstallTarget, +): NpmLifecyclePolicyGate { + if (installTarget.manager !== "npm") { + return { policy: null, error: null }; + } + const version = installTarget.npmOwner?.version ?? ""; + const policy = installTarget.npmOwner?.lifecyclePolicy ?? null; + if (policy === "unflagged" || policy === "allow-scripts") { + return { policy, error: null }; + } + if (policy === "unsupported-transition") { + return { + policy: null, + error: `npm ${version} cannot safely approve OpenClaw lifecycle scripts. Upgrade the owning npm to 11.16 or newer before updating; no package changes were made.`, + }; + } + return { + policy: null, + error: `Unable to determine the owning npm version before updating; no package changes were made.${installTarget.npmOwner?.probeError ? ` ${installTarget.npmOwner.probeError}` : ""}`, + }; +} + +async function resolveNpmOwner(params: { + command: string; + runCommand: CommandRunner; + timeoutMs: number; +}): Promise> { + const result = await params + .runCommand([params.command, "--version"], { timeoutMs: params.timeoutMs }) + .catch((error: unknown) => ({ + stdout: "", + stderr: error instanceof Error ? error.message : String(error), + code: 1, + })); + const version = result.code === 0 ? readPackageManagerProbeValue(result.stdout) : ""; + return { + version: version || null, + lifecyclePolicy: version ? resolveNpmLifecyclePolicy(version) : null, + ...(result.code === 0 || !result.stderr ? {} : { probeError: result.stderr }), + }; +} + function normalizePackageTarget(value: string): string { return value.trim(); } @@ -1089,6 +1161,14 @@ export async function resolveGlobalInstallTarget(params: { ? (pnpmIsolatedPackage?.packageRoot ?? (verifiedPnpmIsolatedGlobalRoot && params.pkgRoot ? params.pkgRoot : fallbackPackageRoot)) : fallbackPackageRoot; + const npmOwner = + command.manager === "npm" + ? await resolveNpmOwner({ + command: command.command, + runCommand: params.runCommand, + timeoutMs: params.timeoutMs, + }) + : null; // Preserve metadata-backed pnpm ownership when the invoking project link is gone. // The update preflight must reject that orphan instead of falling through to npm. return { @@ -1102,6 +1182,7 @@ export async function resolveGlobalInstallTarget(params: { : {}), globalRoot: targetGlobalRoot, packageRoot, + ...(npmOwner ? { npmOwner } : {}), ...(honoredPackageRootGlobalRoot && targetGlobalRoot === honoredPackageRootGlobalRoot && honoredDirectNpmRoot @@ -1215,6 +1296,7 @@ export function globalInstallArgs( pkgRoot?: string | null, installPrefix?: string | null, installCwd?: string | null, + npmLifecyclePolicy: SupportedNpmLifecyclePolicy = "allow-scripts", ): string[] { const resolved = normalizeGlobalInstallCommand(managerOrCommand, pkgRoot); if (resolved.manager === "pnpm") { @@ -1243,7 +1325,9 @@ export function globalInstallArgs( resolved.command, "i", "-g", - resolveNpmInstallScriptsAllowFlag(spec, installCwd), + ...(npmLifecyclePolicy === "allow-scripts" + ? [resolveNpmInstallScriptsAllowFlag(spec, installCwd)] + : []), ...(installPrefix ? ["--prefix", installPrefix] : []), spec, ...NPM_GLOBAL_INSTALL_QUIET_FLAGS, @@ -1263,6 +1347,7 @@ export function globalInstallFallbackArgs( pkgRoot?: string | null, installPrefix?: string | null, installCwd?: string | null, + npmLifecyclePolicy: SupportedNpmLifecyclePolicy = "allow-scripts", ): string[] | null { const resolved = normalizeGlobalInstallCommand(managerOrCommand, pkgRoot); if (resolved.manager !== "npm") { @@ -1272,7 +1357,9 @@ export function globalInstallFallbackArgs( resolved.command, "i", "-g", - resolveNpmInstallScriptsAllowFlag(spec, installCwd), + ...(npmLifecyclePolicy === "allow-scripts" + ? [resolveNpmInstallScriptsAllowFlag(spec, installCwd)] + : []), ...(installPrefix ? ["--prefix", installPrefix] : []), spec, "--omit=optional", diff --git a/src/infra/update-runner-global.ts b/src/infra/update-runner-global.ts index ec32d580f35b..49a50a6ed850 100644 --- a/src/infra/update-runner-global.ts +++ b/src/infra/update-runner-global.ts @@ -1,4 +1,3 @@ -import path from "node:path"; import { resolveGatewayInstallEntrypoint } from "../daemon/gateway-entrypoint.js"; import { readPackageName, readPackageVersion } from "./package-json.js"; import { normalizePackageTagInput } from "./package-tag.js"; @@ -11,7 +10,6 @@ import { } from "./update-channels.js"; import { resolveExtendedStablePackage } from "./update-check.js"; import { - cleanupGlobalRenameDirs, createGlobalInstallEnv, resolveGlobalInstallSpec, resolveGlobalInstallTarget, @@ -73,7 +71,6 @@ export async function runGlobalUpdate(params: { pkgRoot, packageName, }); - await cleanupGlobalRenameDirs({ globalRoot: path.dirname(pkgRoot), packageName }); const extendedStable = channel === "extended-stable" ? await resolveExtendedStablePackage({ installKind: "package", timeoutMs, packageName }) diff --git a/src/infra/update-runner.test.ts b/src/infra/update-runner.test.ts index b76d55334eaf..949dc3e49bfa 100644 --- a/src/infra/update-runner.test.ts +++ b/src/infra/update-runner.test.ts @@ -772,6 +772,9 @@ describe("runGatewayUpdate", () => { if (key === "pnpm root -g") { return { stdout: "", stderr: "", code: 1 }; } + if (key === "npm --version") { + return { stdout: "12.0.0", stderr: "", code: 0 }; + } if (key === baseInstallKey) { return (await params.onBaseInstall?.()) ?? { stdout: "ok", stderr: "", code: 0 }; } @@ -2732,6 +2735,7 @@ describe("runGatewayUpdate", () => { const createGlobalInstallHarness = (params: { pkgRoot: string; npmRootOutput?: string; + npmVersion?: string; pnpmRootOutput?: string; installCommand: InstallCommandExpectation; gitRootMode?: "not-git" | "missing"; @@ -2757,6 +2761,9 @@ describe("runGatewayUpdate", () => { } return { stdout: "", stderr: "", code: 1 }; } + if (key === "npm --version") { + return { stdout: params.npmVersion ?? "12.0.0", stderr: "", code: 0 }; + } if (key === "pnpm root -g") { if (params.pnpmRootOutput) { return { stdout: params.pnpmRootOutput, stderr: "", code: 0 }; @@ -3006,6 +3013,27 @@ describe("runGatewayUpdate", () => { expect(await pathExists(staleDir)).toBe(false); }); + it("refuses unsupported npm before global update cleanup mutates backups", async () => { + const { nodeModules, pkgRoot } = await createGlobalPackageFixture(tempDir); + const backupDir = path.join(nodeModules, ".openclaw-interrupted"); + await fs.mkdir(backupDir, { recursive: true }); + const { runCommand } = createGlobalInstallHarness({ + pkgRoot, + npmRootOutput: nodeModules, + npmVersion: "11.15.9", + installCommand: npmGlobalInstallCommand("openclaw@latest"), + }); + + const result = await runWithCommand(runCommand, { cwd: pkgRoot }); + + expect(result).toMatchObject({ + status: "error", + reason: "unexpected-error", + }); + expect(result.steps.at(-1)?.name).toBe("npm lifecycle policy preflight"); + await expect(fs.access(backupDir)).resolves.toBeUndefined(); + }); + it("retries global npm update with --omit=optional when initial install fails", async () => { const nodeModules = path.join(tempDir, "node_modules"); const pkgRoot = path.join(nodeModules, "openclaw");