From 618939ef6d9bbeec097151bfe0992ae76a6ed125 Mon Sep 17 00:00:00 2001 From: ClaudeZackaryFair Date: Mon, 16 Mar 2026 09:40:08 +0800 Subject: [PATCH] fix(gateway): cover explicit loopback probes and call fallback Cherry-picked-by-hand from heavenlost/openclaw@d2b0ea99063c9d38f0119951633fc6e982e091b0 after review and local test verification. --- src/commands/gateway-status.ts | 2 +- src/commands/gateway-status/helpers.test.ts | 42 ++++++++++++++++++--- src/commands/gateway-status/helpers.ts | 24 ++++++++++-- src/gateway/call.test.ts | 31 +++++++++++++-- src/gateway/call.ts | 14 ++++--- 5 files changed, 95 insertions(+), 18 deletions(-) diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index be0b9abf69a..9c1bdf327e2 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -162,7 +162,7 @@ export async function gatewayStatusCommand( token: authResolution.token, password: authResolution.password, }; - const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target.kind); + const timeoutMs = resolveProbeBudgetMs(overallTimeoutMs, target); const probe = await probeGateway({ url: target.url, auth, diff --git a/src/commands/gateway-status/helpers.test.ts b/src/commands/gateway-status/helpers.test.ts index a5c045ffe6d..65d61154c2d 100644 --- a/src/commands/gateway-status/helpers.test.ts +++ b/src/commands/gateway-status/helpers.test.ts @@ -276,10 +276,42 @@ describe("probe reachability classification", () => { }); describe("resolveProbeBudgetMs", () => { - it("gives local loopback probes enough time for detail RPCs", () => { - expect(resolveProbeBudgetMs(10_000, "localLoopback")).toBe(3000); - expect(resolveProbeBudgetMs(1200, "localLoopback")).toBe(1200); - expect(resolveProbeBudgetMs(10_000, "sshTunnel")).toBe(2000); - expect(resolveProbeBudgetMs(10_000, "explicit")).toBe(1500); + it("gives loopback probes enough time for detail RPCs", () => { + expect( + resolveProbeBudgetMs(10_000, { + kind: "localLoopback", + url: "ws://127.0.0.1:18789", + }), + ).toBe(3000); + expect( + resolveProbeBudgetMs(1200, { + kind: "localLoopback", + url: "ws://127.0.0.1:18789", + }), + ).toBe(1200); + expect( + resolveProbeBudgetMs(10_000, { + kind: "explicit", + url: "ws://127.0.0.1:18789", + }), + ).toBe(3000); + expect( + resolveProbeBudgetMs(10_000, { + kind: "explicit", + url: "wss://localhost:18789/ws", + }), + ).toBe(3000); + expect( + resolveProbeBudgetMs(10_000, { + kind: "explicit", + url: "wss://gateway.example/ws", + }), + ).toBe(1500); + expect( + resolveProbeBudgetMs(10_000, { + kind: "sshTunnel", + url: "wss://gateway.example/ws", + }), + ).toBe(2000); }); }); diff --git a/src/commands/gateway-status/helpers.ts b/src/commands/gateway-status/helpers.ts index 743e8f87fd7..727eafc31f4 100644 --- a/src/commands/gateway-status/helpers.ts +++ b/src/commands/gateway-status/helpers.ts @@ -3,6 +3,7 @@ import { resolveGatewayPort } from "../../config/config.js"; import type { OpenClawConfig, ConfigFileSnapshot } from "../../config/types.js"; import { hasConfiguredSecretInput } from "../../config/types.secrets.js"; import { readGatewayPasswordEnv, readGatewayTokenEnv } from "../../gateway/credentials.js"; +import { isLoopbackHost } from "../../gateway/net.js"; import type { GatewayProbeResult } from "../../gateway/probe.js"; import { resolveConfiguredSecretInputString } from "../../gateway/resolve-configured-secret-input-string.js"; import { pickPrimaryTailnetIPv4 } from "../../infra/tailnet.js"; @@ -116,13 +117,28 @@ export function resolveTargets(cfg: OpenClawConfig, explicitUrl?: string): Gatew return targets; } -export function resolveProbeBudgetMs(overallMs: number, kind: TargetKind): number { - if (kind === "localLoopback") { - // Full localhost detail probes can take longer than the old 800ms budget, +function isLoopbackProbeTarget(target: Pick): boolean { + if (target.kind === "localLoopback") { + return true; + } + try { + return isLoopbackHost(new URL(target.url).hostname); + } catch { + return false; + } +} + +export function resolveProbeBudgetMs( + overallMs: number, + target: Pick, +): number { + if (isLoopbackProbeTarget(target)) { + // Full localhost detail probes can take longer than the old short budgets, // especially when they exercise status + heartbeat + presence RPCs. + // Treat explicit loopback URLs the same way as discovered local loopback. return Math.min(3000, overallMs); } - if (kind === "sshTunnel") { + if (target.kind === "sshTunnel") { return Math.min(2000, overallMs); } return Math.min(1500, overallMs); diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 60a19167e33..e051dd77070 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -8,6 +8,11 @@ import { resolveGatewayPortMock as resolveGatewayPort, } from "./gateway-connection.test-mocks.js"; +const deviceIdentityState = vi.hoisted(() => ({ + value: { id: "test-device-identity" } as Record, + throwOnLoad: false, +})); + let lastClientOptions: { url?: string; token?: string; @@ -28,7 +33,6 @@ let startMode: StartMode = "hello"; let closeCode = 1006; let closeReason = ""; let helloMethods: string[] | undefined = ["health", "secrets.resolve"]; -const deviceIdentityMarker = { id: "test-device-identity" }; vi.mock("./client.js", () => ({ describeGatewayCloseCode: (code: number) => { @@ -75,7 +79,12 @@ vi.mock("./client.js", () => ({ })); vi.mock("../infra/device-identity.js", () => ({ - loadOrCreateDeviceIdentity: () => deviceIdentityMarker, + loadOrCreateDeviceIdentity: () => { + if (deviceIdentityState.throwOnLoad) { + throw new Error("read-only identity dir"); + } + return deviceIdentityState.value; + }, })); const { buildGatewayConnectionDetails, callGateway, callGatewayCli, callGatewayScoped } = @@ -92,6 +101,7 @@ function resetGatewayCallMocks() { closeCode = 1006; closeReason = ""; helloMethods = ["health", "secrets.resolve"]; + deviceIdentityState.throwOnLoad = false; } function setGatewayNetworkDefaults(port = 18789) { @@ -224,7 +234,22 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789"); expect(lastClientOptions?.token).toBe("explicit-token"); - expect(lastClientOptions?.deviceIdentity).toEqual(deviceIdentityMarker); + expect(lastClientOptions?.deviceIdentity).toEqual(deviceIdentityState.value); + }); + + it("falls back to token/password auth when device identity cannot be persisted", async () => { + setLocalLoopbackGatewayConfig(); + deviceIdentityState.throwOnLoad = true; + + await callGateway({ + method: "health", + token: "explicit-token", + }); + + expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789"); + expect(lastClientOptions?.token).toBe("explicit-token"); + expect(lastClientOptions?.deviceIdentity).toBeNull(); + expect(lastRequestOptions?.method).toBe("health"); }); it("uses OPENCLAW_GATEWAY_URL env override in remote mode when remote URL is missing", async () => { diff --git a/src/gateway/call.ts b/src/gateway/call.ts index af7d3736e1d..2ce118991da 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -81,11 +81,17 @@ export type GatewayConnectionDetails = { message: string; }; -function shouldAttachDeviceIdentityForGatewayCall(): boolean { +function resolveDeviceIdentityForGatewayCall() { // Even when local CLI calls authenticate with a shared gateway token/password, // we still want to attach device identity so paired operator scopes remain // available for detail RPCs such as status / system-presence / last-heartbeat. - return true; + try { + return loadOrCreateDeviceIdentity(); + } catch { + // Read-only or restricted environments should still be able to call the + // gateway with token/password auth without crashing before the RPC. + return null; + } } export type ExplicitGatewayAuth = { @@ -825,9 +831,7 @@ async function executeGatewayRequestWithScopes(params: { mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI, role: "operator", scopes, - deviceIdentity: shouldAttachDeviceIdentityForGatewayCall() - ? loadOrCreateDeviceIdentity() - : undefined, + deviceIdentity: resolveDeviceIdentityForGatewayCall(), minProtocol: opts.minProtocol ?? PROTOCOL_VERSION, maxProtocol: opts.maxProtocol ?? PROTOCOL_VERSION, onHelloOk: async (hello) => {