diff --git a/CHANGELOG.md b/CHANGELOG.md index 7899233e92f..ca3dd86dac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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..hooks.timeoutMs` and `plugins.entries..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. diff --git a/src/daemon/runtime-paths.test.ts b/src/daemon/runtime-paths.test.ts index 7fc985b46ee..f41398afa58 100644 --- a/src/daemon/runtime-paths.test.ts +++ b/src/daemon/runtime-paths.test.ts @@ -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 = {}) { + 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( { diff --git a/src/daemon/runtime-paths.ts b/src/daemon/runtime-paths.ts index ceec59f2cc6..cf56b387e5d 100644 --- a/src/daemon/runtime-paths.ts +++ b/src/daemon/runtime-paths.ts @@ -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 { + 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 { 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; } }