fix(daemon): prefer system node for gateway install

This commit is contained in:
Peter Steinberger
2026-05-03 19:40:38 +01:00
parent ca620fbd4f
commit d0ad5c3eaa
3 changed files with 250 additions and 19 deletions

View File

@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Gateway/install: prefer supported system Node over nvm/fnm/volta/asdf/mise when regenerating managed gateway services, so `gateway install --force` no longer recreates service definitions that doctor immediately flags as version-manager-backed. Fixes #76339. Thanks @brokemac79.
- Gateway/usage: serve `usage.cost` and `sessions.usage` from a durable transcript aggregate cache with lock-safe background refreshes and localized stale-cache status, so large usage views avoid repeated full scans. (#76650) Thanks @Marvinthebored.
- Plugins/hooks: let `plugins.entries.<id>.hooks.timeoutMs` and `plugins.entries.<id>.hooks.timeouts` bound plugin typed hooks from operator config, so slow hooks can be tuned without patching installed plugin code. Fixes #76778. Thanks @vincentkoc.
- Telegram: add `channels.telegram.mediaGroupFlushMs` at the top level and per account so operators can tune album buffering instead of being stuck with the hard-coded 500ms media-group flush window. Fixes #76149. Thanks @vincentkoc.

View File

@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
const fsMocks = vi.hoisted(() => ({
access: vi.fn(),
realpath: vi.fn(),
}));
vi.mock("node:fs/promises", async () => {
@@ -11,8 +12,10 @@ vi.mock("node:fs/promises", async () => {
default: {
...actual,
access: fsMocks.access,
realpath: fsMocks.realpath,
},
access: fsMocks.access,
realpath: fsMocks.realpath,
};
});
@@ -27,7 +30,12 @@ afterEach(() => {
vi.resetAllMocks();
});
function mockNodeRealpath(realpaths: Record<string, string> = {}) {
fsMocks.realpath.mockImplementation(async (target: string) => realpaths[target] ?? target);
}
function mockNodePathPresent(...nodePaths: string[]) {
mockNodeRealpath();
fsMocks.access.mockImplementation(async (target: string) => {
if (nodePaths.includes(target)) {
return;
@@ -39,11 +47,119 @@ function mockNodePathPresent(...nodePaths: string[]) {
describe("resolvePreferredNodePath", () => {
const darwinNode = "/opt/homebrew/bin/node";
const fnmNode = "/Users/test/.fnm/node-versions/v24.11.1/installation/bin/node";
const linuxSystemNode = "/usr/bin/node";
const nvmNode = "/home/test/.nvm/versions/node/v24.14.1/bin/node";
it("prefers execPath (version manager node) over system node", async () => {
it("prefers supported system node over version-manager execPath", async () => {
mockNodePathPresent(darwinNode);
const execFile = vi.fn().mockResolvedValue({ stdout: "24.11.1\n", stderr: "" });
const execFile = vi
.fn()
.mockResolvedValueOnce({ stdout: "24.11.1\n", stderr: "" })
.mockResolvedValueOnce({ stdout: "24.11.1\n", stderr: "" });
const result = await resolvePreferredNodePath({
env: {},
runtime: "node",
platform: "darwin",
execFile,
execPath: fnmNode,
});
expect(result).toBe(darwinNode);
expect(execFile).toHaveBeenCalledTimes(2);
});
it("uses system node for Linux service installs instead of nvm execPath", async () => {
mockNodePathPresent(linuxSystemNode);
const execFile = vi
.fn()
.mockResolvedValueOnce({ stdout: "24.14.1\n", stderr: "" })
.mockResolvedValueOnce({ stdout: "24.14.1\n", stderr: "" });
const result = await resolvePreferredNodePath({
env: {},
runtime: "node",
platform: "linux",
execFile,
execPath: nvmNode,
});
expect(result).toBe(linuxSystemNode);
expect(execFile).toHaveBeenCalledTimes(2);
});
it("uses system node for Linux service installs instead of default fnm execPath", async () => {
const linuxFnmNode = "/home/test/.local/share/fnm/aliases/default/bin/node";
mockNodePathPresent(linuxSystemNode);
const execFile = vi
.fn()
.mockResolvedValueOnce({ stdout: "24.14.1\n", stderr: "" })
.mockResolvedValueOnce({ stdout: "24.14.1\n", stderr: "" });
const result = await resolvePreferredNodePath({
env: {},
runtime: "node",
platform: "linux",
execFile,
execPath: linuxFnmNode,
});
expect(result).toBe(linuxSystemNode);
expect(execFile).toHaveBeenCalledTimes(2);
});
it("uses system node for macOS service installs instead of default fnm execPath", async () => {
const darwinFnmNode = "/Users/test/Library/Application Support/fnm/aliases/default/bin/node";
mockNodePathPresent(darwinNode);
const execFile = vi
.fn()
.mockResolvedValueOnce({ stdout: "24.14.1\n", stderr: "" })
.mockResolvedValueOnce({ stdout: "24.14.1\n", stderr: "" });
const result = await resolvePreferredNodePath({
env: {},
runtime: "node",
platform: "darwin",
execFile,
execPath: darwinFnmNode,
});
expect(result).toBe(darwinNode);
expect(execFile).toHaveBeenCalledTimes(2);
});
it("uses Homebrew opt Node when a version-manager execPath is active", async () => {
const homebrewOptNode = "/opt/homebrew/opt/node@22/bin/node";
mockNodePathPresent(homebrewOptNode);
const execFile = vi
.fn()
.mockResolvedValueOnce({ stdout: "24.11.1\n", stderr: "" })
.mockResolvedValueOnce({ stdout: "22.17.0\n", stderr: "" });
const result = await resolvePreferredNodePath({
env: {},
runtime: "node",
platform: "darwin",
execFile,
execPath: fnmNode,
});
expect(result).toBe(homebrewOptNode);
expect(execFile).toHaveBeenCalledTimes(2);
});
it("falls back to version-manager execPath when no supported system node exists", async () => {
mockNodePathPresent(darwinNode);
const execFile = vi
.fn()
.mockResolvedValueOnce({ stdout: "24.11.1\n", stderr: "" })
.mockResolvedValueOnce({ stdout: "18.0.0\n", stderr: "" });
const result = await resolvePreferredNodePath({
env: {},
@@ -54,7 +170,7 @@ describe("resolvePreferredNodePath", () => {
});
expect(result).toBe(fnmNode);
expect(execFile).toHaveBeenCalledTimes(1);
expect(execFile).toHaveBeenCalledTimes(2);
});
it("falls back to system node when execPath version is unsupported", async () => {
@@ -248,6 +364,73 @@ describe("resolveSystemNodeInfo", () => {
expect(result).toBeNull();
});
it("continues past an old system node to find a supported candidate", async () => {
const homebrewOptNode = "/opt/homebrew/opt/node@22/bin/node";
mockNodePathPresent(darwinNode, homebrewOptNode);
const execFile = vi
.fn()
.mockResolvedValueOnce({ stdout: "18.0.0\n", stderr: "" })
.mockResolvedValueOnce({ stdout: "22.17.0\n", stderr: "" });
const result = await resolveSystemNodeInfo({
env: {},
platform: "darwin",
execFile,
});
expect(result).toEqual({
path: homebrewOptNode,
version: "22.17.0",
supported: true,
});
});
it("skips system-node candidates that resolve into version-manager paths", async () => {
const homebrewOptNode = "/opt/homebrew/opt/node@22/bin/node";
mockNodePathPresent(darwinNode, homebrewOptNode);
mockNodeRealpath({
[darwinNode]: "/Users/test/.nvm/versions/node/v24.14.1/bin/node",
[homebrewOptNode]: homebrewOptNode,
});
const execFile = vi.fn().mockResolvedValue({ stdout: "24.14.1\n", stderr: "" });
const result = await resolveSystemNodeInfo({
env: {},
platform: "darwin",
execFile,
});
expect(result).toEqual({
path: homebrewOptNode,
version: "24.14.1",
supported: true,
});
expect(execFile).toHaveBeenCalledTimes(1);
expect(execFile).toHaveBeenCalledWith(homebrewOptNode, ["-p", "process.versions.node"], {
encoding: "utf8",
});
});
it("returns null when every system-node candidate resolves into a version manager", async () => {
mockNodePathPresent(darwinNode);
mockNodeRealpath({
[darwinNode]: "/Users/test/Library/Application Support/fnm/aliases/default/bin/node",
});
const execFile = vi.fn();
const result = await resolveSystemNodeInfo({
env: {},
platform: "darwin",
execFile,
});
expect(result).toBeNull();
expect(execFile).not.toHaveBeenCalled();
});
it("renders a warning when system node is too old", () => {
const warning = renderSystemNodeWarning(
{

View File

@@ -10,8 +10,11 @@ import { normalizeLowercaseStringOrEmpty } from "../shared/string-coerce.js";
const VERSION_MANAGER_MARKERS = [
"/.nvm/",
"/.fnm/",
"/.local/share/fnm/",
"/library/application support/fnm/",
"/.volta/",
"/.asdf/",
"/.local/share/mise/",
"/.n/",
"/.nodenv/",
"/.nodebrew/",
@@ -42,7 +45,17 @@ function buildSystemNodeCandidates(
platform: NodeJS.Platform,
): string[] {
if (platform === "darwin") {
return ["/opt/homebrew/bin/node", "/usr/local/bin/node", "/usr/bin/node"];
return [
"/opt/homebrew/bin/node",
"/opt/homebrew/opt/node/bin/node",
"/opt/homebrew/opt/node@24/bin/node",
"/opt/homebrew/opt/node@22/bin/node",
"/usr/local/bin/node",
"/usr/local/opt/node/bin/node",
"/usr/local/opt/node@24/bin/node",
"/usr/local/opt/node@22/bin/node",
"/usr/bin/node",
];
}
if (platform === "linux") {
return ["/usr/local/bin/node", "/usr/bin/node"];
@@ -85,11 +98,23 @@ type SystemNodeInfo = {
supported: boolean;
};
async function isVersionManagedRealNodePath(
nodePath: string,
platform: NodeJS.Platform,
): Promise<boolean> {
try {
const realPath = await fs.realpath(nodePath);
return isVersionManagedNodePath(realPath, platform);
} catch {
return false;
}
}
export function isVersionManagedNodePath(
nodePath: string,
platform: NodeJS.Platform = process.platform,
): boolean {
const normalized = normalizeForCompare(nodePath, platform);
const normalized = normalizeLowercaseStringOrEmpty(normalizeForCompare(nodePath, platform));
return VERSION_MANAGER_MARKERS.some((marker) => normalized.includes(marker));
}
@@ -128,17 +153,29 @@ export async function resolveSystemNodeInfo(params: {
}): Promise<SystemNodeInfo | null> {
const env = params.env ?? process.env;
const platform = params.platform ?? process.platform;
const systemNode = await resolveSystemNodePath(env, platform);
if (!systemNode) {
return null;
const execFileImpl = params.execFile ?? execFileAsync;
let firstAvailable: SystemNodeInfo | null = null;
for (const systemNode of buildSystemNodeCandidates(env, platform)) {
try {
await fs.access(systemNode);
} catch {
continue;
}
if (await isVersionManagedRealNodePath(systemNode, platform)) {
continue;
}
const version = await resolveNodeVersion(systemNode, execFileImpl);
const info = {
path: systemNode,
version,
supported: isSupportedNodeVersion(version),
};
if (info.supported) {
return info;
}
firstAvailable ??= info;
}
const version = await resolveNodeVersion(systemNode, params.execFile ?? execFileAsync);
return {
path: systemNode,
version,
supported: isSupportedNodeVersion(version),
};
return firstAvailable;
}
export function renderSystemNodeWarning(
@@ -165,15 +202,25 @@ export async function resolvePreferredNodePath(params: {
return undefined;
}
// Prefer the node that is currently running `openclaw gateway install`.
// This respects the user's active version manager (fnm/nvm/volta/etc.).
const platform = params.platform ?? process.platform;
const currentExecPath = params.execPath ?? process.execPath;
const execFileImpl = params.execFile ?? execFileAsync;
if (currentExecPath && isNodeExecPath(currentExecPath, platform)) {
const execFileImpl = params.execFile ?? execFileAsync;
const version = await resolveNodeVersion(currentExecPath, execFileImpl);
if (isSupportedNodeVersion(version)) {
return resolveStableNodePath(currentExecPath);
const stableCurrentPath = await resolveStableNodePath(currentExecPath);
if (!isVersionManagedNodePath(currentExecPath, platform)) {
return stableCurrentPath;
}
const systemNode = await resolveSystemNodeInfo({
env: params.env,
platform,
execFile: execFileImpl,
});
if (systemNode?.supported) {
return systemNode.path;
}
return stableCurrentPath;
}
}