fix(daemon): resolve Homebrew Cellar path to stable symlink for gateway install

When `openclaw gateway install` runs under Homebrew Node, `process.execPath`
resolves to the versioned Cellar path (e.g. /opt/homebrew/Cellar/node/25.7.0/bin/node).
This path breaks when Homebrew upgrades Node, silently killing the gateway daemon.

Resolve Cellar paths to the stable Homebrew symlink (/opt/homebrew/bin/node)
which Homebrew updates automatically during upgrades.

Closes #32182

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
scoootscooob
2026-03-02 13:25:18 -08:00
committed by Peter Steinberger
parent 29dde80c3e
commit 8950c59581
2 changed files with 79 additions and 3 deletions

View File

@@ -12,6 +12,7 @@ vi.mock("node:fs/promises", () => ({
import { import {
renderSystemNodeWarning, renderSystemNodeWarning,
resolvePreferredNodePath, resolvePreferredNodePath,
resolveStableNodePath,
resolveSystemNodeInfo, resolveSystemNodeInfo,
} from "./runtime-paths.js"; } from "./runtime-paths.js";
@@ -19,9 +20,9 @@ afterEach(() => {
vi.resetAllMocks(); vi.resetAllMocks();
}); });
function mockNodePathPresent(nodePath: string) { function mockNodePathPresent(...nodePaths: string[]) {
fsMocks.access.mockImplementation(async (target: string) => { fsMocks.access.mockImplementation(async (target: string) => {
if (target === nodePath) { if (nodePaths.includes(target)) {
return; return;
} }
throw new Error("missing"); throw new Error("missing");
@@ -142,6 +143,61 @@ describe("resolvePreferredNodePath", () => {
}); });
}); });
describe("resolveStableNodePath", () => {
it("resolves Homebrew Cellar path to stable symlink", async () => {
mockNodePathPresent("/opt/homebrew/bin/node");
const result = await resolveStableNodePath("/opt/homebrew/Cellar/node/25.7.0/bin/node");
expect(result).toBe("/opt/homebrew/bin/node");
});
it("resolves Intel Mac Cellar path to stable symlink", async () => {
mockNodePathPresent("/usr/local/bin/node");
const result = await resolveStableNodePath("/usr/local/Cellar/node/25.7.0/bin/node");
expect(result).toBe("/usr/local/bin/node");
});
it("returns original path when symlink does not exist", async () => {
fsMocks.access.mockRejectedValue(new Error("missing"));
const cellarPath = "/opt/homebrew/Cellar/node/25.7.0/bin/node";
const result = await resolveStableNodePath(cellarPath);
expect(result).toBe(cellarPath);
});
it("returns non-Cellar paths unchanged", async () => {
const fnmPath = "/Users/test/.fnm/node-versions/v24.11.1/installation/bin/node";
const result = await resolveStableNodePath(fnmPath);
expect(result).toBe(fnmPath);
});
it("returns system paths unchanged", async () => {
const result = await resolveStableNodePath("/opt/homebrew/bin/node");
expect(result).toBe("/opt/homebrew/bin/node");
});
});
describe("resolvePreferredNodePath — Homebrew Cellar", () => {
it("resolves Cellar execPath to stable Homebrew symlink", async () => {
const cellarNode = "/opt/homebrew/Cellar/node/25.7.0/bin/node";
const stableNode = "/opt/homebrew/bin/node";
mockNodePathPresent(stableNode);
const execFile = vi.fn().mockResolvedValue({ stdout: "25.7.0\n", stderr: "" });
const result = await resolvePreferredNodePath({
env: {},
runtime: "node",
platform: "darwin",
execFile,
execPath: cellarNode,
});
expect(result).toBe(stableNode);
});
});
describe("resolveSystemNodeInfo", () => { describe("resolveSystemNodeInfo", () => {
const darwinNode = "/opt/homebrew/bin/node"; const darwinNode = "/opt/homebrew/bin/node";

View File

@@ -153,6 +153,26 @@ export function renderSystemNodeWarning(
return `System Node ${versionLabel} at ${systemNode.path} is below the required Node 22+.${selectedLabel} Install Node 22+ from nodejs.org or Homebrew.`; return `System Node ${versionLabel} at ${systemNode.path} is below the required Node 22+.${selectedLabel} Install Node 22+ from nodejs.org or Homebrew.`;
} }
/**
* Homebrew Cellar paths (e.g. /opt/homebrew/Cellar/node/25.7.0/bin/node)
* break when Homebrew upgrades Node and removes the old version directory.
* Resolve these to the stable Homebrew symlink path (/opt/homebrew/bin/node)
* which Homebrew updates automatically during upgrades.
*/
export async function resolveStableNodePath(nodePath: string): Promise<string> {
const cellarMatch = nodePath.match(/^(.+?)\/Cellar\/[^/]+\/[^/]+\/bin\/node$/);
if (!cellarMatch) {
return nodePath;
}
const stablePath = `${cellarMatch[1]}/bin/node`;
try {
await fs.access(stablePath);
return stablePath;
} catch {
return nodePath;
}
}
export async function resolvePreferredNodePath(params: { export async function resolvePreferredNodePath(params: {
env?: Record<string, string | undefined>; env?: Record<string, string | undefined>;
runtime?: string; runtime?: string;
@@ -172,7 +192,7 @@ export async function resolvePreferredNodePath(params: {
const execFileImpl = params.execFile ?? execFileAsync; const execFileImpl = params.execFile ?? execFileAsync;
const version = await resolveNodeVersion(currentExecPath, execFileImpl); const version = await resolveNodeVersion(currentExecPath, execFileImpl);
if (isSupportedNodeVersion(version)) { if (isSupportedNodeVersion(version)) {
return currentExecPath; return resolveStableNodePath(currentExecPath);
} }
} }