Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
fix(update): keep self-updates on the running install's global root
Package self-updates resolved their install target from `npm root -g`
even when the running install's package root was already known. In an
environment where PATH's npm does not belong to the running install's
Node tree (a launchd/systemd service PATH, or mixed Homebrew + nvm
setups), the probe points at a different global root — or derives a
brand-new one — so the update installs there, reports success, and
leaves the running install stale.

Observed in the wild: a gateway-managed `openclaw update --yes` on
2026.5.28 ran nvm's npm under Homebrew's node, npm derived its default
prefix from the realpathed execPath, and the update installed a full
copy into /opt/homebrew/Cellar/node/26.3.1/lib/node_modules/openclaw
("before": null — a tree that had never existed) while the running nvm
install stayed on the old version until a manual shell update.

resolveGlobalInstallTarget now prefers the package root's own global
root for npm targets; the `npm root -g` probe only decides the target
when no package root is known, and an ephemeral per-Node probe result
(Homebrew Cellar, nvm/n/asdf/volta version dirs — the same set
#93650 filters npm commands against) is never adopted.
  • Loading branch information
buddyh authored and steipete committed Jul 7, 2026
commit 08e95b66bf0bae55b022ee46ed9d29d33a90a64f
96 changes: 96 additions & 0 deletions src/infra/update-global.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -482,6 +482,102 @@ describe("update global helpers", () => {
});
});

it("does not adopt an ephemeral per-Node npm root probed from PATH", async () => {
await withMockedPlatform("darwin", async () => {
await withTempDir({ prefix: "openclaw-update-ephemeral-probe-" }, async (base) => {
// The running install lives in an nvm tree while `npm root -g` on
// PATH answers with a Homebrew Cellar root — the skew produced when a
// per-Node npm shim is executed by a foreign node (e.g. a launchd
// service PATH pairing nvm's npm with Homebrew's node). Installing
// into the Cellar root would create a brand-new tree the running
// install never loads from.
const nvmPrefix = path.join(base, "home", ".nvm", "versions", "node", "v24.5.0");
const nvmRoot = path.join(nvmPrefix, "lib", "node_modules");
const pkgRoot = path.join(nvmRoot, "openclaw");
const cellarRoot = path.join(
base,
"opt",
"homebrew",
"Cellar",
"node",
"26.3.1",
"lib",
"node_modules",
);
await fs.mkdir(pkgRoot, { recursive: true });

const runCommand = createNpmRootRunner({ defaultNpmRoot: cellarRoot });

await expect(
resolveGlobalInstallTarget({
manager: "npm",
runCommand,
timeoutMs: 1000,
pkgRoot,
}),
).resolves.toEqual({
manager: "npm",
command: "npm",
globalRoot: nvmRoot,
packageRoot: pkgRoot,
});
});
});
});

it("still adopts the probed npm root when it matches the running per-Node tree", async () => {
await withMockedPlatform("darwin", async () => {
await withTempDir({ prefix: "openclaw-update-matching-probe-" }, async (base) => {
const nvmPrefix = path.join(base, "home", ".nvm", "versions", "node", "v24.5.0");
const nvmRoot = path.join(nvmPrefix, "lib", "node_modules");
const pkgRoot = path.join(nvmRoot, "openclaw");
await fs.mkdir(pkgRoot, { recursive: true });

const runCommand = createNpmRootRunner({ defaultNpmRoot: nvmRoot });

await expect(
resolveGlobalInstallTarget({
manager: "npm",
runCommand,
timeoutMs: 1000,
pkgRoot,
}),
).resolves.toEqual({
manager: "npm",
command: "npm",
globalRoot: nvmRoot,
packageRoot: pkgRoot,
});
});
});
});

it("falls back to the running package root when the npm root probe fails", async () => {
await withMockedPlatform("darwin", async () => {
await withTempDir({ prefix: "openclaw-update-probe-failure-" }, async (base) => {
const globalRoot = path.join(base, "usr", "local", "lib", "node_modules");
const pkgRoot = path.join(globalRoot, "openclaw");
await fs.mkdir(pkgRoot, { recursive: true });

const runCommand: CommandRunner = async () => ({ stdout: "", stderr: "", code: 1 });

await expect(
resolveGlobalInstallTarget({
manager: "npm",
runCommand,
timeoutMs: 1000,
pkgRoot,
}),
).resolves.toEqual({
manager: "npm",
command: "npm",
globalRoot,
packageRoot: pkgRoot,
});
});
});
});

it("does not infer npm ownership from path shape alone when the owning npm binary is absent", async () => {
await withTempDir({ prefix: "openclaw-update-npm-missing-bin-" }, async (base) => {
const brewRoot = path.join(base, "opt", "homebrew", "lib", "node_modules");
Expand Down
31 changes: 30 additions & 1 deletion src/infra/update-global.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,6 +571,25 @@ function isEphemeralNodeManagedNpmPrefix(prefix: string): boolean {
);
}

/**
* Returns true when a probed `npm root -g` result is safe to adopt as the
* install target. A probe that lands in an ephemeral per-Node prefix (Homebrew
* Cellar, nvm/fnm/asdf/volta version dirs) means npm's PATH/node pairing is
* skewed — for example an nvm npm shim executed by a Homebrew node derives its
* default prefix from the foreign node's realpathed Cellar path. Installing
* there would create a tree that no stable entrypoint loads from and that a
* `brew upgrade node` silently deletes.
*/
function isAdoptableProbedGlobalRoot(probedGlobalRoot: string | null): boolean {
if (!probedGlobalRoot) {
return false;
}
const probedPrefix = resolveNpmGlobalPrefixLayoutFromGlobalRoot(probedGlobalRoot, {
allowDirectNodeModulesRoot: true,
})?.prefix;
return !probedPrefix || !isEphemeralNodeManagedNpmPrefix(probedPrefix);
}

function resolveNpmCommandBesidePackageRoot(pkgRoot?: string | null): string | null {
const prefix = inferNpmPrefixFromPackageRoot(pkgRoot);
if (!prefix) {
Expand Down Expand Up @@ -796,11 +815,21 @@ export async function resolveGlobalInstallTarget(params: {
params.pkgRoot,
);
const pkgRootGlobalRoot = command.manager === "pnpm" ? pnpmPackageRootGlobalRoot : null;
// Self-updates must mutate the tree the resolved package root lives in.
// `npm root -g` only decides the target when no package root is known: in a
// skewed environment (e.g. a service PATH whose npm does not belong to the
// running install's Node tree) the probe can point at a different — or
// brand-new — global root, leaving the running install stale after a
// reported-successful update.
const pkgRootDerivedGlobalRoot =
command.manager === "npm" ? inferGlobalRootFromPackageRoot(params.pkgRoot) : null;
const probedGlobalRoot = isAdoptableProbedGlobalRoot(globalRoot) ? globalRoot : null;
const targetGlobalRoot =
(command.manager === "bun" ? bunPackageRootGlobalRoot : null) ??
pkgRootGlobalRoot ??
(command.manager === "npm" ? honoredPackageRootGlobalRoot : null) ??
globalRoot;
pkgRootDerivedGlobalRoot ??
probedGlobalRoot;
return {
...command,
globalRoot: targetGlobalRoot,
Expand Down