mirror of
https://github.com/moltbot/moltbot.git
synced 2026-05-05 04:48:17 +00:00
fix(gateway): keep status helpers resilient to netif failures
This commit is contained in:
@@ -215,6 +215,36 @@ describe("gatherDaemonStatus", () => {
|
|||||||
expect(status.rpc?.url).toBe("wss://override.example:18790");
|
expect(status.rpc?.url).toBe("wss://override.example:18790");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses fallback network details when interface discovery throws during status inspection", async () => {
|
||||||
|
daemonLoadedConfig = {
|
||||||
|
gateway: {
|
||||||
|
bind: "tailnet",
|
||||||
|
tls: { enabled: true },
|
||||||
|
auth: { token: "daemon-token" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
resolveGatewayBindHost.mockImplementationOnce(async () => {
|
||||||
|
throw new Error("uv_interface_addresses failed");
|
||||||
|
});
|
||||||
|
pickPrimaryTailnetIPv4.mockImplementationOnce(() => {
|
||||||
|
throw new Error("uv_interface_addresses failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await gatherDaemonStatus({
|
||||||
|
rpc: {},
|
||||||
|
probe: true,
|
||||||
|
deep: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(status.gateway).toMatchObject({
|
||||||
|
bindMode: "tailnet",
|
||||||
|
bindHost: "127.0.0.1",
|
||||||
|
probeUrl: "wss://127.0.0.1:19001",
|
||||||
|
});
|
||||||
|
expect(status.gateway?.probeNote).toContain("interface discovery failed");
|
||||||
|
expect(status.gateway?.probeNote).toContain("tailnet addresses");
|
||||||
|
});
|
||||||
|
|
||||||
it("reuses command environment when reading runtime status", async () => {
|
it("reuses command environment when reading runtime status", async () => {
|
||||||
serviceReadCommand.mockResolvedValueOnce({
|
serviceReadCommand.mockResolvedValueOnce({
|
||||||
programArguments: ["/bin/node", "cli", "gateway", "--port", "19001"],
|
programArguments: ["/bin/node", "cli", "gateway", "--port", "19001"],
|
||||||
|
|||||||
@@ -74,6 +74,37 @@ type ResolvedGatewayStatus = {
|
|||||||
probeUrlOverride: string | null;
|
probeUrlOverride: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function summarizeDisplayNetworkError(error: unknown): string {
|
||||||
|
if (error instanceof Error) {
|
||||||
|
const message = error.message.trim();
|
||||||
|
if (message) {
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "network interface discovery failed";
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackBindHostForStatus(bindMode: GatewayBindMode, customBindHost?: string): string {
|
||||||
|
if (bindMode === "lan") {
|
||||||
|
return "0.0.0.0";
|
||||||
|
}
|
||||||
|
if (bindMode === "custom") {
|
||||||
|
return customBindHost?.trim() || "0.0.0.0";
|
||||||
|
}
|
||||||
|
return "127.0.0.1";
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendProbeNote(
|
||||||
|
existing: string | undefined,
|
||||||
|
extra: string | undefined,
|
||||||
|
): string | undefined {
|
||||||
|
const values = [existing, extra].filter((value): value is string => Boolean(value?.trim()));
|
||||||
|
if (values.length === 0) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return [...new Set(values)].join(" ");
|
||||||
|
}
|
||||||
|
|
||||||
export type DaemonStatus = {
|
export type DaemonStatus = {
|
||||||
service: {
|
service: {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -201,18 +232,34 @@ async function resolveGatewayStatusSummary(params: {
|
|||||||
: "env/config";
|
: "env/config";
|
||||||
const bindMode: GatewayBindMode = params.daemonCfg.gateway?.bind ?? "loopback";
|
const bindMode: GatewayBindMode = params.daemonCfg.gateway?.bind ?? "loopback";
|
||||||
const customBindHost = params.daemonCfg.gateway?.customBindHost;
|
const customBindHost = params.daemonCfg.gateway?.customBindHost;
|
||||||
const bindHost = await resolveGatewayBindHost(bindMode, customBindHost);
|
let bindHost: string;
|
||||||
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
let networkWarning: string | undefined;
|
||||||
|
try {
|
||||||
|
bindHost = await resolveGatewayBindHost(bindMode, customBindHost);
|
||||||
|
} catch (error) {
|
||||||
|
bindHost = fallbackBindHostForStatus(bindMode, customBindHost);
|
||||||
|
networkWarning = `Status is using fallback network details because interface discovery failed: ${summarizeDisplayNetworkError(error)}.`;
|
||||||
|
}
|
||||||
|
let tailnetIPv4: string | undefined;
|
||||||
|
try {
|
||||||
|
tailnetIPv4 = pickPrimaryTailnetIPv4();
|
||||||
|
} catch (error) {
|
||||||
|
networkWarning = appendProbeNote(
|
||||||
|
networkWarning,
|
||||||
|
`Status could not inspect tailnet addresses: ${summarizeDisplayNetworkError(error)}.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
const probeHost = pickProbeHostForBind(bindMode, tailnetIPv4, customBindHost);
|
const probeHost = pickProbeHostForBind(bindMode, tailnetIPv4, customBindHost);
|
||||||
const probeUrlOverride = trimToUndefined(params.rpcUrlOverride) ?? null;
|
const probeUrlOverride = trimToUndefined(params.rpcUrlOverride) ?? null;
|
||||||
const scheme = params.daemonCfg.gateway?.tls?.enabled === true ? "wss" : "ws";
|
const scheme = params.daemonCfg.gateway?.tls?.enabled === true ? "wss" : "ws";
|
||||||
const probeUrl = probeUrlOverride ?? `${scheme}://${probeHost}:${daemonPort}`;
|
const probeUrl = probeUrlOverride ?? `${scheme}://${probeHost}:${daemonPort}`;
|
||||||
const probeNote =
|
let probeNote =
|
||||||
!probeUrlOverride && bindMode === "lan"
|
!probeUrlOverride && bindMode === "lan"
|
||||||
? `bind=lan listens on 0.0.0.0 (all interfaces); probing via ${probeHost}.`
|
? `bind=lan listens on 0.0.0.0 (all interfaces); probing via ${probeHost}.`
|
||||||
: !probeUrlOverride && bindMode === "loopback"
|
: !probeUrlOverride && bindMode === "loopback"
|
||||||
? "Loopback-only gateway; only local clients can connect."
|
? "Loopback-only gateway; only local clients can connect."
|
||||||
: undefined;
|
: undefined;
|
||||||
|
probeNote = appendProbeNote(probeNote, networkWarning);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
gateway: {
|
gateway: {
|
||||||
|
|||||||
@@ -220,6 +220,24 @@ describe("gateway-status command", () => {
|
|||||||
expect(targets[0]?.summary).toBeTruthy();
|
expect(targets[0]?.summary).toBeTruthy();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps status output working when tailnet discovery throws", async () => {
|
||||||
|
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
|
||||||
|
pickPrimaryTailnetIPv4.mockImplementationOnce(() => {
|
||||||
|
throw new Error("uv_interface_addresses failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
await runGatewayStatus(runtime, { timeout: "1000", json: true });
|
||||||
|
|
||||||
|
expect(runtimeErrors).toHaveLength(0);
|
||||||
|
const parsed = JSON.parse(runtimeLogs.join("\n")) as {
|
||||||
|
network?: { tailnetIPv4?: string | null; localTailnetUrl?: string | null };
|
||||||
|
};
|
||||||
|
expect(parsed.network).toMatchObject({
|
||||||
|
tailnetIPv4: null,
|
||||||
|
localTailnetUrl: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("treats missing-scope RPC probe failures as degraded but reachable", async () => {
|
it("treats missing-scope RPC probe failures as degraded but reachable", async () => {
|
||||||
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
|
const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture();
|
||||||
readBestEffortConfig.mockResolvedValueOnce({
|
readBestEffortConfig.mockResolvedValueOnce({
|
||||||
|
|||||||
@@ -81,6 +81,14 @@ function normalizeWsUrl(value: string): string | null {
|
|||||||
return trimmed;
|
return trimmed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pickPrimaryTailnetIPv4ForStatus(): string | undefined {
|
||||||
|
try {
|
||||||
|
return pickPrimaryTailnetIPv4();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveTargets(cfg: OpenClawConfig, explicitUrl?: string): GatewayStatusTarget[] {
|
export function resolveTargets(cfg: OpenClawConfig, explicitUrl?: string): GatewayStatusTarget[] {
|
||||||
const targets: GatewayStatusTarget[] = [];
|
const targets: GatewayStatusTarget[] = [];
|
||||||
const add = (t: GatewayStatusTarget) => {
|
const add = (t: GatewayStatusTarget) => {
|
||||||
@@ -310,7 +318,7 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function buildNetworkHints(cfg: OpenClawConfig) {
|
export function buildNetworkHints(cfg: OpenClawConfig) {
|
||||||
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
const tailnetIPv4 = pickPrimaryTailnetIPv4ForStatus();
|
||||||
const port = resolveGatewayPort(cfg);
|
const port = resolveGatewayPort(cfg);
|
||||||
return {
|
return {
|
||||||
localLoopbackUrl: `ws://127.0.0.1:${port}`,
|
localLoopbackUrl: `ws://127.0.0.1:${port}`,
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import os from "node:os";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import {
|
import {
|
||||||
normalizeGatewayTokenInput,
|
normalizeGatewayTokenInput,
|
||||||
@@ -112,6 +113,30 @@ describe("resolveControlUiLinks", () => {
|
|||||||
expect(links.httpUrl).toBe("http://127.0.0.1:18789/");
|
expect(links.httpUrl).toBe("http://127.0.0.1:18789/");
|
||||||
expect(links.wsUrl).toBe("ws://127.0.0.1:18789");
|
expect(links.wsUrl).toBe("ws://127.0.0.1:18789");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to loopback when tailnet discovery throws", () => {
|
||||||
|
mocks.pickPrimaryTailnetIPv4.mockImplementationOnce(() => {
|
||||||
|
throw new Error("uv_interface_addresses failed");
|
||||||
|
});
|
||||||
|
const links = resolveControlUiLinks({
|
||||||
|
port: 18789,
|
||||||
|
bind: "tailnet",
|
||||||
|
});
|
||||||
|
expect(links.httpUrl).toBe("http://127.0.0.1:18789/");
|
||||||
|
expect(links.wsUrl).toBe("ws://127.0.0.1:18789");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to loopback when LAN discovery throws", () => {
|
||||||
|
vi.spyOn(os, "networkInterfaces").mockImplementation(() => {
|
||||||
|
throw new Error("uv_interface_addresses failed");
|
||||||
|
});
|
||||||
|
const links = resolveControlUiLinks({
|
||||||
|
port: 18789,
|
||||||
|
bind: "lan",
|
||||||
|
});
|
||||||
|
expect(links.httpUrl).toBe("http://127.0.0.1:18789/");
|
||||||
|
expect(links.wsUrl).toBe("ws://127.0.0.1:18789");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("normalizeGatewayTokenInput", () => {
|
describe("normalizeGatewayTokenInput", () => {
|
||||||
|
|||||||
@@ -456,6 +456,22 @@ function summarizeError(err: unknown): string {
|
|||||||
|
|
||||||
export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR;
|
export const DEFAULT_WORKSPACE = DEFAULT_AGENT_WORKSPACE_DIR;
|
||||||
|
|
||||||
|
function pickPrimaryTailnetIPv4ForDisplay(): string | undefined {
|
||||||
|
try {
|
||||||
|
return pickPrimaryTailnetIPv4();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickPrimaryLanIPv4ForDisplay(): string | undefined {
|
||||||
|
try {
|
||||||
|
return pickPrimaryLanIPv4();
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveControlUiLinks(params: {
|
export function resolveControlUiLinks(params: {
|
||||||
port: number;
|
port: number;
|
||||||
bind?: "auto" | "lan" | "loopback" | "custom" | "tailnet";
|
bind?: "auto" | "lan" | "loopback" | "custom" | "tailnet";
|
||||||
@@ -465,7 +481,7 @@ export function resolveControlUiLinks(params: {
|
|||||||
const port = params.port;
|
const port = params.port;
|
||||||
const bind = params.bind ?? "loopback";
|
const bind = params.bind ?? "loopback";
|
||||||
const customBindHost = params.customBindHost?.trim();
|
const customBindHost = params.customBindHost?.trim();
|
||||||
const tailnetIPv4 = pickPrimaryTailnetIPv4();
|
const tailnetIPv4 = pickPrimaryTailnetIPv4ForDisplay();
|
||||||
const host = (() => {
|
const host = (() => {
|
||||||
if (bind === "custom" && customBindHost && isValidIPv4(customBindHost)) {
|
if (bind === "custom" && customBindHost && isValidIPv4(customBindHost)) {
|
||||||
return customBindHost;
|
return customBindHost;
|
||||||
@@ -474,7 +490,7 @@ export function resolveControlUiLinks(params: {
|
|||||||
return tailnetIPv4 ?? "127.0.0.1";
|
return tailnetIPv4 ?? "127.0.0.1";
|
||||||
}
|
}
|
||||||
if (bind === "lan") {
|
if (bind === "lan") {
|
||||||
return pickPrimaryLanIPv4() ?? "127.0.0.1";
|
return pickPrimaryLanIPv4ForDisplay() ?? "127.0.0.1";
|
||||||
}
|
}
|
||||||
return "127.0.0.1";
|
return "127.0.0.1";
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -45,7 +45,12 @@ function normalizePresenceKey(key: string | undefined): string | undefined {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function resolvePrimaryIPv4(): string | undefined {
|
function resolvePrimaryIPv4(): string | undefined {
|
||||||
return pickPrimaryLanIPv4() ?? os.hostname();
|
const host = os.hostname();
|
||||||
|
try {
|
||||||
|
return pickPrimaryLanIPv4() ?? host;
|
||||||
|
} catch {
|
||||||
|
return host;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initSelfPresence() {
|
function initSelfPresence() {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import os from "node:os";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { withEnvAsync } from "../test-utils/env.js";
|
import { withEnvAsync } from "../test-utils/env.js";
|
||||||
|
|
||||||
async function withPresenceModule<T>(
|
async function withPresenceModule<T>(
|
||||||
@@ -13,6 +14,10 @@ async function withPresenceModule<T>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("system-presence version fallback", () => {
|
describe("system-presence version fallback", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
async function expectSelfVersion(
|
async function expectSelfVersion(
|
||||||
env: Record<string, string | undefined>,
|
env: Record<string, string | undefined>,
|
||||||
expectedVersion: string | (() => Promise<string>),
|
expectedVersion: string | (() => Promise<string>),
|
||||||
@@ -78,4 +83,18 @@ describe("system-presence version fallback", () => {
|
|||||||
async () => (await import("../version.js")).VERSION,
|
async () => (await import("../version.js")).VERSION,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("falls back to hostname when self-presence LAN discovery throws", async () => {
|
||||||
|
await withEnvAsync({}, async () => {
|
||||||
|
vi.spyOn(os, "hostname").mockReturnValue("test-host");
|
||||||
|
vi.spyOn(os, "networkInterfaces").mockImplementation(() => {
|
||||||
|
throw new Error("uv_interface_addresses failed");
|
||||||
|
});
|
||||||
|
vi.resetModules();
|
||||||
|
const module = await import("./system-presence.js");
|
||||||
|
const selfEntry = module.listSystemPresence().find((entry) => entry.reason === "self");
|
||||||
|
expect(selfEntry?.host).toBe("test-host");
|
||||||
|
expect(selfEntry?.ip).toBe("test-host");
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user