mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-06 06:20:43 +00:00
fix(daemon): prefer system node for gateway install
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user