Skip to content

openclaw update trusts npm root -g stdout verbatim, but npm >= 11 redacts UUID-like path segments to literal *** — update then installs into a literal ***/ directory tree and silently leaves the live install stale #107239

Description

@SL4N

Summary

resolveGlobalRoot() shells out to npm root -g and uses res.stdout.trim() as a real filesystem path. Since npm added @npmcli/redact, all npm output — including plain stdout of npm root -g / npm prefix -g — is passed through redactLog, which replaces every UUID-shaped substring ([0-9a-f]{8}-[0-9a-f]{4}-...) with the literal string ***.

If the npm global prefix path contains a UUID-like segment (common for per-session sandboxes, ephemeral CI workspaces, tmpdir-scoped installs), npm root -g returns a poisoned path such as:

/tmp/ci-agent/***/workspace/sbx-***/nvm/versions/node/v24.14.0/lib/node_modules

The updater then:

  1. fs.mkdir(globalRoot, { recursive: true })creates a literal *** directory tree on disk (createStagedNpmInstall),
  2. runs npm i -g --prefix <***-path>/.openclaw-update-stage-XXXXXX <spec> — installs the new version into the phantom tree,
  3. swaps the staged package into <***-path>/node_modules/openclaw,
  4. reports the update as installed, while the real global install is left untouched at the old version.

Net effect: openclaw update is a silent no-op for the live install, plus a junk parallel directory tree literally named ***.

Affected code (verified at v2026.6.11; still present on current main, commit 1b9aca26b)

  • src/infra/update-global.ts v2026.6.11 L737–743 (main L751–757): resolveGlobalRootconst root = res.stdout.trim(); return root || null; — trusts redacted stdout.
  • src/infra/update-runner.ts v2026.6.11 L1661: resolveGlobalInstallTarget({ manager, runCommand, timeoutMs, pkgRoot }) — called without honorPackageRoot, so the (real, process-derived) pkgRoot never overrides the poisoned stdout value; targetGlobalRoot falls through to globalRoot (update-global.ts v2026.6.11 L782–786).
  • src/infra/package-update-steps.ts v2026.6.11 L248–249: createStagedNpmInstallfs.mkdir(targetLayout.globalRoot, { recursive: true }) + mkdtemp — materializes the literal *** tree; L499 (swap step) and L621 (stagedInstall?.prefix--prefix) propagate it.
  • npm side (npm 11.9.0, node 24.14.0): node_modules/npm/lib/utils/format.js — comment "All logging goes through here, both to console and log files" → redactLog from @npmcli/redact (lib/index.js: const REPLACE = '***' + matchers.UUID.pattern).

Reproduction (fully scripted, no manual path handling)

Self-contained script (creates a UUID-named sandbox prefix, installs openclaw@2026.6.11, runs openclaw update --yes --no-restart --json, then does byte-level forensics via os.listdir/file reads — no shell display involved):

Observed on Linux arm64, node v24.14.0, npm 11.9.0:

  • Mechanism probe (bytes captured via subprocess, not a terminal):
    npm root -gb'/tmp/ci-agent/***/workspace/sbx-***/nvm/versions/node/v24.14.0/lib/node_modules\n'
  • Baseline: no *** path exists anywhere.
  • After openclaw update: a literal *** tree exists (verified with byte-level os.listdir, entry b'***'), containing a freshly installed openclaw@2026.7.1; update --json step log shows
    npm i -g --prefix /tmp/.../***/.../.openclaw-update-stage-AkrV8k openclaw@latest (exit 0) and
    swap .../***/.../.openclaw-update-stage-AkrV8k/lib/node_modules/openclaw -> .../***/.../node_modules/openclaw (exit 0).
  • The real prefix's node_modules/openclaw/package.json still reads 2026.6.11 after the "successful" install+swap steps.

(Interesting detail visible in a single update --json document: fields derived from process.execPath show the real UUID path, fields derived from npm stdout show the *** path — a clean fingerprint of where the poisoning enters.)

Trigger surface

Any environment where the npm global prefix path contains a UUID-shaped segment (case-insensitive 8-4-4-4-12 hex): ephemeral CI workdirs, per-session sandboxes, tmp-scoped toolchains. npm redacts stdout of at least npm root -g and npm prefix -g.

Suggested fixes (any of these breaks the chain)

  1. Validate the value returned by resolveGlobalRoot: reject it (fall back) if the path does not exist, or if it contains npm's redaction marker ***.
  2. Prefer deriving the global root from the running package root (pkgRoot, process-derived, never redacted) in the update flow — e.g. pass honorPackageRoot: true at update-runner.ts call site, or use inferGlobalRootFromPackageRoot before the command probe rather than after it fails.
  3. Query the prefix in a redaction-free way (e.g. node -p "process.execPath"-relative resolution) instead of parsing npm's decorated stdout.

Note the same stdout.trim() pattern is also used by detectGlobalInstallManagerForRoot (v2026.6.11 L814–835) — there the poisoned path fails realpath comparison against the real pkgRoot, which can additionally cause manager misdetection (npm probe silently fails to match its own root).

(Found while investigating #106920 in a sandbox; investigation and repro scripting were AI-assisted.)

Metadata

Metadata

Assignees

Labels

P0Emergency: data loss, security bypass, crash loop, or unusable core runtime.clawsweeper:not-repro-on-mainClawSweeper found high-confidence evidence that this issue no longer reproduces on main.impact:ux-release-blockerA non-technical user is blocked without terminal, logs, config, or support.issue-rating: 🦪 silver shellfishThin issue quality; more reproduction proof or environment detail is needed.

Type

No type

Fields

Priority

None yet

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions