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:
fs.mkdir(globalRoot, { recursive: true }) — creates a literal *** directory tree on disk (createStagedNpmInstall),
- runs
npm i -g --prefix <***-path>/.openclaw-update-stage-XXXXXX <spec> — installs the new version into the phantom tree,
- swaps the staged package into
<***-path>/node_modules/openclaw,
- 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): resolveGlobalRoot → const 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: createStagedNpmInstall → fs.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 -g → b'/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)
- Validate the value returned by
resolveGlobalRoot: reject it (fall back) if the path does not exist, or if it contains npm's redaction marker ***.
- 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.
- 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.)
Summary
resolveGlobalRoot()shells out tonpm root -gand usesres.stdout.trim()as a real filesystem path. Since npm added@npmcli/redact, all npm output — including plain stdout ofnpm root -g/npm prefix -g— is passed throughredactLog, 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 -greturns a poisoned path such as:The updater then:
fs.mkdir(globalRoot, { recursive: true })— creates a literal***directory tree on disk (createStagedNpmInstall),npm i -g --prefix <***-path>/.openclaw-update-stage-XXXXXX <spec>— installs the new version into the phantom tree,<***-path>/node_modules/openclaw,Net effect:
openclaw updateis 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, commit1b9aca26b)src/infra/update-global.tsv2026.6.11 L737–743 (main L751–757):resolveGlobalRoot→const root = res.stdout.trim(); return root || null;— trusts redacted stdout.src/infra/update-runner.tsv2026.6.11 L1661:resolveGlobalInstallTarget({ manager, runCommand, timeoutMs, pkgRoot })— called withouthonorPackageRoot, so the (real, process-derived)pkgRootnever overrides the poisoned stdout value;targetGlobalRootfalls through toglobalRoot(update-global.ts v2026.6.11 L782–786).src/infra/package-update-steps.tsv2026.6.11 L248–249:createStagedNpmInstall→fs.mkdir(targetLayout.globalRoot, { recursive: true })+mkdtemp— materializes the literal***tree; L499 (swap step) and L621 (stagedInstall?.prefix→--prefix) propagate it.node_modules/npm/lib/utils/format.js— comment "All logging goes through here, both to console and log files" →redactLogfrom@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 viaos.listdir/file reads — no shell display involved):Observed on Linux arm64, node v24.14.0, npm 11.9.0:
subprocess, not a terminal):npm root -g→b'/tmp/ci-agent/***/workspace/sbx-***/nvm/versions/node/v24.14.0/lib/node_modules\n'***path exists anywhere.openclaw update: a literal***tree exists (verified with byte-levelos.listdir, entryb'***'), containing a freshly installedopenclaw@2026.7.1;update --jsonstep log showsnpm i -g --prefix /tmp/.../***/.../.openclaw-update-stage-AkrV8k openclaw@latest(exit 0) andswap .../***/.../.openclaw-update-stage-AkrV8k/lib/node_modules/openclaw -> .../***/.../node_modules/openclaw(exit 0).node_modules/openclaw/package.jsonstill reads2026.6.11after the "successful" install+swap steps.(Interesting detail visible in a single
update --jsondocument: fields derived fromprocess.execPathshow 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-12hex): ephemeral CI workdirs, per-session sandboxes, tmp-scoped toolchains. npm redacts stdout of at leastnpm root -gandnpm prefix -g.Suggested fixes (any of these breaks the chain)
resolveGlobalRoot: reject it (fall back) if the path does not exist, or if it contains npm's redaction marker***.pkgRoot, process-derived, never redacted) in theupdateflow — e.g. passhonorPackageRoot: trueatupdate-runner.tscall site, or useinferGlobalRootFromPackageRootbefore the command probe rather than after it fails.node -p "process.execPath"-relative resolution) instead of parsing npm's decorated stdout.Note the same
stdout.trim()pattern is also used bydetectGlobalInstallManagerForRoot(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.)