fix(gateway): guard interface discovery failures

Closes #44180.
Refs #47590.
Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
Peter Steinberger
2026-03-22 14:26:35 -07:00
parent 44bbd2d83d
commit 3faaf8984f
7 changed files with 56 additions and 3 deletions

View File

@@ -254,7 +254,9 @@ Docs: https://docs.openclaw.ai
- Discord/ACP: forward worker abort signals into ACP turns so timed-out Discord jobs cancel the running turn instead of silently leaving the bound ACP session working in the background.
- Gateway/openresponses: preserve assistant commentary and session continuity across hosted-tool `/v1/responses` turns, and emit streamed tool-call payloads before finalization so client tool loops stay resumable. (#52171) Thanks @CharZhou.
- Android/Talk: serialize `TalkModeManager` player teardown so rapid interrupt/restart cycles stop double-releasing or overlapping TTS playback. (#52310) Thanks @Kaneki-x.
<<<<<<< HEAD
- WhatsApp/reconnect: preserve the last inbound timestamp across reconnect attempts so the watchdog can still recycle linked-but-dead listeners after a restart instead of leaving them stuck connected forever.
- Gateway/network discovery: guard LAN, tailnet, and pairing interface enumeration so WSL2 and restricted hosts degrade to missing-address fallbacks instead of crashing on `uv_interface_addresses` errors. (#44180, #47590)
### Breaking

View File

@@ -360,6 +360,13 @@ describe("pickPrimaryLanIPv4", () => {
vi.restoreAllMocks();
}
});
it("returns undefined when interface discovery throws", () => {
vi.spyOn(os, "networkInterfaces").mockImplementation(() => {
throw new Error("uv_interface_addresses failed");
});
expect(pickPrimaryLanIPv4()).toBeUndefined();
});
});
describe("isPrivateOrLoopbackAddress", () => {

View File

@@ -15,7 +15,12 @@ import {
* Prefers common interface names (en0, eth0) then falls back to any external IPv4.
*/
export function pickPrimaryLanIPv4(): string | undefined {
const nets = os.networkInterfaces();
let nets: ReturnType<typeof os.networkInterfaces>;
try {
nets = os.networkInterfaces();
} catch {
return undefined;
}
const preferredNames = ["en0", "eth0"];
for (const name of preferredNames) {
const list = nets[name];

View File

@@ -240,5 +240,13 @@ describe("infra runtime", () => {
expect(out.ipv4).toEqual(["100.123.224.76"]);
expect(out.ipv6).toEqual(["fd7a:115c:a1e0::8801:e04c"]);
});
it("returns empty address lists when interface discovery throws", () => {
vi.spyOn(os, "networkInterfaces").mockImplementation(() => {
throw new Error("uv_interface_addresses failed");
});
expect(listTailnetAddresses()).toEqual({ ipv4: [], ipv6: [] });
});
});
});

View File

@@ -25,7 +25,12 @@ export function listTailnetAddresses(): TailnetAddresses {
const ipv4: string[] = [];
const ipv6: string[] = [];
const ifaces = os.networkInterfaces();
let ifaces: ReturnType<typeof os.networkInterfaces>;
try {
ifaces = os.networkInterfaces();
} catch {
return { ipv4, ipv6 };
}
for (const entries of Object.values(ifaces)) {
if (!entries) {
continue;

View File

@@ -368,6 +368,27 @@ describe("pairing setup code", () => {
});
});
it("returns a bind-specific error when interface discovery throws", async () => {
const resolved = await resolvePairingSetupFromConfig(
{
gateway: {
bind: "lan",
auth: { mode: "token", token: "tok" },
},
},
{
networkInterfaces: () => {
throw new Error("uv_interface_addresses failed");
},
},
);
expect(resolved).toEqual({
ok: false,
error: "gateway.bind=lan set, but no private LAN IP was found.",
});
});
it("prefers gateway.remote.url over tailscale when requested", async () => {
const runCommandWithTimeout = createTailnetDnsRunner();

View File

@@ -118,7 +118,12 @@ function pickIPv4Matching(
networkInterfaces: () => ReturnType<typeof os.networkInterfaces>,
matches: (address: string) => boolean,
): string | null {
const nets = networkInterfaces();
let nets: ReturnType<typeof os.networkInterfaces>;
try {
nets = networkInterfaces();
} catch {
return null;
}
for (const entries of Object.values(nets)) {
if (!entries) {
continue;