diff --git a/src/cli/daemon-cli/status.gather.test.ts b/src/cli/daemon-cli/status.gather.test.ts index fd94acca3a9..272872d5cf4 100644 --- a/src/cli/daemon-cli/status.gather.test.ts +++ b/src/cli/daemon-cli/status.gather.test.ts @@ -215,6 +215,36 @@ describe("gatherDaemonStatus", () => { 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 () => { serviceReadCommand.mockResolvedValueOnce({ programArguments: ["/bin/node", "cli", "gateway", "--port", "19001"], diff --git a/src/cli/daemon-cli/status.gather.ts b/src/cli/daemon-cli/status.gather.ts index 4647b789ff9..5e66b69466f 100644 --- a/src/cli/daemon-cli/status.gather.ts +++ b/src/cli/daemon-cli/status.gather.ts @@ -74,6 +74,37 @@ type ResolvedGatewayStatus = { 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 = { service: { label: string; @@ -201,18 +232,34 @@ async function resolveGatewayStatusSummary(params: { : "env/config"; const bindMode: GatewayBindMode = params.daemonCfg.gateway?.bind ?? "loopback"; const customBindHost = params.daemonCfg.gateway?.customBindHost; - const bindHost = await resolveGatewayBindHost(bindMode, customBindHost); - const tailnetIPv4 = pickPrimaryTailnetIPv4(); + let bindHost: string; + 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 probeUrlOverride = trimToUndefined(params.rpcUrlOverride) ?? null; const scheme = params.daemonCfg.gateway?.tls?.enabled === true ? "wss" : "ws"; const probeUrl = probeUrlOverride ?? `${scheme}://${probeHost}:${daemonPort}`; - const probeNote = + let probeNote = !probeUrlOverride && bindMode === "lan" ? `bind=lan listens on 0.0.0.0 (all interfaces); probing via ${probeHost}.` : !probeUrlOverride && bindMode === "loopback" ? "Loopback-only gateway; only local clients can connect." : undefined; + probeNote = appendProbeNote(probeNote, networkWarning); return { gateway: { diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index 3762afc6d8a..c5712fc6c65 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -220,6 +220,24 @@ describe("gateway-status command", () => { 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 () => { const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); readBestEffortConfig.mockResolvedValueOnce({ diff --git a/src/commands/gateway-status/helpers.ts b/src/commands/gateway-status/helpers.ts index aec1a6a794d..75869c8167e 100644 --- a/src/commands/gateway-status/helpers.ts +++ b/src/commands/gateway-status/helpers.ts @@ -81,6 +81,14 @@ function normalizeWsUrl(value: string): string | null { return trimmed; } +function pickPrimaryTailnetIPv4ForStatus(): string | undefined { + try { + return pickPrimaryTailnetIPv4(); + } catch { + return undefined; + } +} + export function resolveTargets(cfg: OpenClawConfig, explicitUrl?: string): GatewayStatusTarget[] { const targets: GatewayStatusTarget[] = []; const add = (t: GatewayStatusTarget) => { @@ -310,7 +318,7 @@ export function extractConfigSummary(snapshotUnknown: unknown): GatewayConfigSum } export function buildNetworkHints(cfg: OpenClawConfig) { - const tailnetIPv4 = pickPrimaryTailnetIPv4(); + const tailnetIPv4 = pickPrimaryTailnetIPv4ForStatus(); const port = resolveGatewayPort(cfg); return { localLoopbackUrl: `ws://127.0.0.1:${port}`, diff --git a/src/commands/onboard-helpers.test.ts b/src/commands/onboard-helpers.test.ts index 3f70ccccfcb..1d8da5c84fb 100644 --- a/src/commands/onboard-helpers.test.ts +++ b/src/commands/onboard-helpers.test.ts @@ -1,3 +1,4 @@ +import os from "node:os"; import { afterEach, describe, expect, it, vi } from "vitest"; import { normalizeGatewayTokenInput, @@ -112,6 +113,30 @@ describe("resolveControlUiLinks", () => { 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 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", () => { diff --git a/src/commands/onboard-helpers.ts b/src/commands/onboard-helpers.ts index 6e029531f50..967cfd6b5fb 100644 --- a/src/commands/onboard-helpers.ts +++ b/src/commands/onboard-helpers.ts @@ -456,6 +456,22 @@ function summarizeError(err: unknown): string { 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: { port: number; bind?: "auto" | "lan" | "loopback" | "custom" | "tailnet"; @@ -465,7 +481,7 @@ export function resolveControlUiLinks(params: { const port = params.port; const bind = params.bind ?? "loopback"; const customBindHost = params.customBindHost?.trim(); - const tailnetIPv4 = pickPrimaryTailnetIPv4(); + const tailnetIPv4 = pickPrimaryTailnetIPv4ForDisplay(); const host = (() => { if (bind === "custom" && customBindHost && isValidIPv4(customBindHost)) { return customBindHost; @@ -474,7 +490,7 @@ export function resolveControlUiLinks(params: { return tailnetIPv4 ?? "127.0.0.1"; } if (bind === "lan") { - return pickPrimaryLanIPv4() ?? "127.0.0.1"; + return pickPrimaryLanIPv4ForDisplay() ?? "127.0.0.1"; } return "127.0.0.1"; })(); diff --git a/src/infra/system-presence.ts b/src/infra/system-presence.ts index a644cd001de..b11cd288cf8 100644 --- a/src/infra/system-presence.ts +++ b/src/infra/system-presence.ts @@ -45,7 +45,12 @@ function normalizePresenceKey(key: string | undefined): string | undefined { } function resolvePrimaryIPv4(): string | undefined { - return pickPrimaryLanIPv4() ?? os.hostname(); + const host = os.hostname(); + try { + return pickPrimaryLanIPv4() ?? host; + } catch { + return host; + } } function initSelfPresence() { diff --git a/src/infra/system-presence.version.test.ts b/src/infra/system-presence.version.test.ts index 867ce379392..982d2ac5010 100644 --- a/src/infra/system-presence.version.test.ts +++ b/src/infra/system-presence.version.test.ts @@ -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"; async function withPresenceModule( @@ -13,6 +14,10 @@ async function withPresenceModule( } describe("system-presence version fallback", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + async function expectSelfVersion( env: Record, expectedVersion: string | (() => Promise), @@ -78,4 +83,18 @@ describe("system-presence version fallback", () => { 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"); + }); + }); });