From 7bbaad2eea6f7fcae6bf6d11044d2e0c1cc02060 Mon Sep 17 00:00:00 2001 From: OpenClaw Assistant Date: Sat, 14 Mar 2026 20:25:55 +0000 Subject: [PATCH 1/5] Fix local gateway detail probes on loopback --- src/commands/gateway-status/helpers.test.ts | 10 ++++++++++ src/commands/gateway-status/helpers.ts | 4 +++- src/gateway/call.test.ts | 9 +++++++-- src/gateway/call.ts | 15 +++++---------- src/gateway/probe.test.ts | 2 +- src/gateway/probe.ts | 10 ---------- 6 files changed, 26 insertions(+), 24 deletions(-) diff --git a/src/commands/gateway-status/helpers.test.ts b/src/commands/gateway-status/helpers.test.ts index e0c1ecee763..a5c045ffe6d 100644 --- a/src/commands/gateway-status/helpers.test.ts +++ b/src/commands/gateway-status/helpers.test.ts @@ -5,6 +5,7 @@ import { isProbeReachable, isScopeLimitedProbeFailure, renderProbeSummaryLine, + resolveProbeBudgetMs, resolveAuthForTarget, } from "./helpers.js"; @@ -273,3 +274,12 @@ describe("probe reachability classification", () => { expect(renderProbeSummaryLine(probe, false)).toContain("RPC: failed"); }); }); + +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); + }); +}); diff --git a/src/commands/gateway-status/helpers.ts b/src/commands/gateway-status/helpers.ts index 5f1a5e2f5ee..743e8f87fd7 100644 --- a/src/commands/gateway-status/helpers.ts +++ b/src/commands/gateway-status/helpers.ts @@ -118,7 +118,9 @@ export function resolveTargets(cfg: OpenClawConfig, explicitUrl?: string): Gatew export function resolveProbeBudgetMs(overallMs: number, kind: TargetKind): number { if (kind === "localLoopback") { - return Math.min(800, overallMs); + // Full localhost detail probes can take longer than the old 800ms budget, + // especially when they exercise status + heartbeat + presence RPCs. + return Math.min(3000, overallMs); } if (kind === "sshTunnel") { return Math.min(2000, overallMs); diff --git a/src/gateway/call.test.ts b/src/gateway/call.test.ts index 7fd9b7c84cb..60a19167e33 100644 --- a/src/gateway/call.test.ts +++ b/src/gateway/call.test.ts @@ -28,6 +28,7 @@ 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) => { @@ -73,6 +74,10 @@ vi.mock("./client.js", () => ({ }, })); +vi.mock("../infra/device-identity.js", () => ({ + loadOrCreateDeviceIdentity: () => deviceIdentityMarker, +})); + const { buildGatewayConnectionDetails, callGateway, callGatewayCli, callGatewayScoped } = await import("./call.js"); @@ -209,7 +214,7 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.token).toBe("explicit-token"); }); - it("does not attach device identity for local loopback shared-token auth", async () => { + it("attaches device identity for local loopback shared-token auth", async () => { setLocalLoopbackGatewayConfig(); await callGateway({ @@ -219,7 +224,7 @@ describe("callGateway url resolution", () => { expect(lastClientOptions?.url).toBe("ws://127.0.0.1:18789"); expect(lastClientOptions?.token).toBe("explicit-token"); - expect(lastClientOptions?.deviceIdentity).toBeUndefined(); + expect(lastClientOptions?.deviceIdentity).toEqual(deviceIdentityMarker); }); 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 f163a45ef06..2c4184e3792 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -81,20 +81,15 @@ export type GatewayConnectionDetails = { message: string; }; -function shouldAttachDeviceIdentityForGatewayCall(params: { +function shouldAttachDeviceIdentityForGatewayCall(_params: { url: string; token?: string; password?: string; }): boolean { - if (!(params.token || params.password)) { - return true; - } - try { - const parsed = new URL(params.url); - return !["127.0.0.1", "::1", "localhost"].includes(parsed.hostname); - } catch { - return true; - } + // 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; } export type ExplicitGatewayAuth = { diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index 6cd7d64fc51..1fd9a0d4bf2 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -51,7 +51,7 @@ describe("probeGateway", () => { }); expect(gatewayClientState.options?.scopes).toEqual(["operator.read"]); - expect(gatewayClientState.options?.deviceIdentity).toBeNull(); + expect(gatewayClientState.options?.deviceIdentity).toBeUndefined(); expect(gatewayClientState.requests).toEqual([ "health", "status", diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index 40740987fb0..ab9f88a213c 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -4,7 +4,6 @@ import type { SystemPresence } from "../infra/system-presence.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import { GatewayClient } from "./client.js"; import { READ_SCOPE } from "./method-scopes.js"; -import { isLoopbackHost } from "./net.js"; export type GatewayProbeAuth = { token?: string; @@ -41,14 +40,6 @@ export async function probeGateway(opts: { let connectError: string | null = null; let close: GatewayProbeClose | null = null; - const disableDeviceIdentity = (() => { - try { - return isLoopbackHost(new URL(opts.url).hostname); - } catch { - return false; - } - })(); - return await new Promise((resolve) => { let settled = false; const settle = (result: Omit) => { @@ -70,7 +61,6 @@ export async function probeGateway(opts: { clientVersion: "dev", mode: GATEWAY_CLIENT_MODES.PROBE, instanceId, - deviceIdentity: disableDeviceIdentity ? null : undefined, onConnectError: (err) => { connectError = formatErrorMessage(err); }, From 281e33e0cbf16f37ae20fa843ed2487044d2b35e Mon Sep 17 00:00:00 2001 From: OpenClaw Assistant Date: Sat, 14 Mar 2026 20:53:30 +0000 Subject: [PATCH 2/5] Address review feedback and fix check lint failures --- src/agents/model-compat.test.ts | 7 ------- src/gateway/call.ts | 8 ++------ src/gateway/probe.test.ts | 1 + src/gateway/probe.ts | 1 + src/gateway/server/ws-connection/message-handler.ts | 3 ++- 5 files changed, 6 insertions(+), 14 deletions(-) diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index f6aece9d674..9959eeb4612 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -87,13 +87,6 @@ function expectSupportsDeveloperRoleForcedOff(overrides?: Partial>): expect(supportsDeveloperRole(normalized)).toBe(false); } -function expectSupportsUsageInStreamingForcedOff(overrides?: Partial>): void { - const model = { ...baseModel(), ...overrides }; - delete (model as { compat?: unknown }).compat; - const normalized = normalizeModelCompat(model as Model); - expect(supportsUsageInStreaming(normalized)).toBe(false); -} - function expectResolvedForwardCompat( model: Model | undefined, expected: { provider: string; id: string }, diff --git a/src/gateway/call.ts b/src/gateway/call.ts index 2c4184e3792..af7d3736e1d 100644 --- a/src/gateway/call.ts +++ b/src/gateway/call.ts @@ -81,11 +81,7 @@ export type GatewayConnectionDetails = { message: string; }; -function shouldAttachDeviceIdentityForGatewayCall(_params: { - url: string; - token?: string; - password?: string; -}): boolean { +function shouldAttachDeviceIdentityForGatewayCall(): boolean { // 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. @@ -829,7 +825,7 @@ async function executeGatewayRequestWithScopes(params: { mode: opts.mode ?? GATEWAY_CLIENT_MODES.CLI, role: "operator", scopes, - deviceIdentity: shouldAttachDeviceIdentityForGatewayCall({ url, token, password }) + deviceIdentity: shouldAttachDeviceIdentityForGatewayCall() ? loadOrCreateDeviceIdentity() : undefined, minProtocol: opts.minProtocol ?? PROTOCOL_VERSION, diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index 1fd9a0d4bf2..4f3628149ba 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -79,6 +79,7 @@ describe("probeGateway", () => { }); expect(result.ok).toBe(true); + expect(gatewayClientState.options?.deviceIdentity).toBeNull(); expect(gatewayClientState.requests).toEqual([]); }); }); diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index ab9f88a213c..72d232ddaae 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -61,6 +61,7 @@ export async function probeGateway(opts: { clientVersion: "dev", mode: GATEWAY_CLIENT_MODES.PROBE, instanceId, + deviceIdentity: opts.includeDetails === false ? null : undefined, onConnectError: (err) => { connectError = formatErrorMessage(err); }, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 655558e12cb..49f70915992 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -684,7 +684,8 @@ export function attachGatewayWsMessageHandler(params: { hasBrowserOriginHeader, sharedAuthOk, authMethod, - }) || shouldSkipControlUiPairing(controlUiAuthPolicy, role, trustedProxyAuthOk); + }) || + shouldSkipControlUiPairing(controlUiAuthPolicy, role, trustedProxyAuthOk); if (device && devicePublicKey && !skipPairing) { const formatAuditList = (items: string[] | undefined): string => { if (!items || items.length === 0) { From 25ae306fbddd2b66933e9e106515f1f6beca0a7a Mon Sep 17 00:00:00 2001 From: OpenClaw Assistant Date: Sun, 15 Mar 2026 06:37:24 +0000 Subject: [PATCH 3/5] Fix stale check failures on delivery recovery branch --- src/agents/model-compat.test.ts | 7 +++++++ src/gateway/server/ws-connection/message-handler.ts | 3 +-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/agents/model-compat.test.ts b/src/agents/model-compat.test.ts index 158a3be9e01..56b9c16203c 100644 --- a/src/agents/model-compat.test.ts +++ b/src/agents/model-compat.test.ts @@ -87,6 +87,13 @@ function expectSupportsDeveloperRoleForcedOff(overrides?: Partial>): expect(supportsDeveloperRole(normalized)).toBe(false); } +function expectSupportsUsageInStreamingForcedOff(overrides?: Partial>): void { + const model = { ...baseModel(), ...overrides }; + delete (model as { compat?: unknown }).compat; + const normalized = normalizeModelCompat(model as Model); + expect(supportsUsageInStreaming(normalized)).toBe(false); +} + function expectResolvedForwardCompat( model: Model | undefined, expected: { provider: string; id: string }, diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 93f19cd41d2..e0116190009 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -681,8 +681,7 @@ export function attachGatewayWsMessageHandler(params: { hasBrowserOriginHeader, sharedAuthOk, authMethod, - }) || - shouldSkipControlUiPairing(controlUiAuthPolicy, role, trustedProxyAuthOk); + }) || shouldSkipControlUiPairing(controlUiAuthPolicy, role, trustedProxyAuthOk); if (device && devicePublicKey && !skipPairing) { const formatAuditList = (items: string[] | undefined): string => { if (!items || items.length === 0) { From d2e8a493a3af00e49047df683501419d188b93c9 Mon Sep 17 00:00:00 2001 From: OpenClaw Assistant Date: Sun, 15 Mar 2026 08:36:55 +0000 Subject: [PATCH 4/5] Handle probe identity fallback and stale mocks --- src/gateway/probe.test.ts | 42 ++++++++++++++++++++++++++++++++++++--- src/gateway/probe.ts | 16 ++++++++++++++- src/tts/tts.test.ts | 22 +++++++++++++------- 3 files changed, 69 insertions(+), 11 deletions(-) diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index 4f3628149ba..b5ece53c0d4 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -1,10 +1,15 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const gatewayClientState = vi.hoisted(() => ({ options: null as Record | null, requests: [] as string[], })); +const deviceIdentityState = vi.hoisted(() => ({ + value: { id: "test-device-identity" } as Record, + throwOnLoad: false, +})); + class MockGatewayClient { private readonly opts: Record; @@ -40,9 +45,21 @@ vi.mock("./client.js", () => ({ GatewayClient: MockGatewayClient, })); +vi.mock("../infra/device-identity.js", () => ({ + loadOrCreateDeviceIdentity: () => { + if (deviceIdentityState.throwOnLoad) { + throw new Error("read-only identity dir"); + } + return deviceIdentityState.value; + }, +})); + const { probeGateway } = await import("./probe.js"); describe("probeGateway", () => { + beforeEach(() => { + deviceIdentityState.throwOnLoad = false; + }); it("connects with operator.read scope", async () => { const result = await probeGateway({ url: "ws://127.0.0.1:18789", @@ -51,7 +68,7 @@ describe("probeGateway", () => { }); expect(gatewayClientState.options?.scopes).toEqual(["operator.read"]); - expect(gatewayClientState.options?.deviceIdentity).toBeUndefined(); + expect(gatewayClientState.options?.deviceIdentity).toEqual(deviceIdentityState.value); expect(gatewayClientState.requests).toEqual([ "health", "status", @@ -68,7 +85,7 @@ describe("probeGateway", () => { timeoutMs: 1_000, }); - expect(gatewayClientState.options?.deviceIdentity).toBeUndefined(); + expect(gatewayClientState.options?.deviceIdentity).toEqual(deviceIdentityState.value); }); it("skips detail RPCs for lightweight reachability probes", async () => { @@ -82,4 +99,23 @@ describe("probeGateway", () => { expect(gatewayClientState.options?.deviceIdentity).toBeNull(); expect(gatewayClientState.requests).toEqual([]); }); + + it("falls back to token/password auth when device identity cannot be persisted", async () => { + deviceIdentityState.throwOnLoad = true; + + const result = await probeGateway({ + url: "ws://127.0.0.1:18789", + auth: { token: "secret" }, + timeoutMs: 1_000, + }); + + expect(result.ok).toBe(true); + expect(gatewayClientState.options?.deviceIdentity).toBeNull(); + expect(gatewayClientState.requests).toEqual([ + "health", + "status", + "system-presence", + "config.get", + ]); + }); }); diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index 72d232ddaae..fa4a589ac2d 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -1,4 +1,5 @@ import { randomUUID } from "node:crypto"; +import { loadOrCreateDeviceIdentity } from "../infra/device-identity.js"; import { formatErrorMessage } from "../infra/errors.js"; import type { SystemPresence } from "../infra/system-presence.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; @@ -40,6 +41,19 @@ export async function probeGateway(opts: { let connectError: string | null = null; let close: GatewayProbeClose | null = null; + const deviceIdentity = (() => { + if (opts.includeDetails === false) { + return null; + } + try { + return loadOrCreateDeviceIdentity(); + } catch { + // Read-only or restricted environments should still be able to run + // token/password-auth detail probes without crashing on identity persistence. + return null; + } + })(); + return await new Promise((resolve) => { let settled = false; const settle = (result: Omit) => { @@ -61,7 +75,7 @@ export async function probeGateway(opts: { clientVersion: "dev", mode: GATEWAY_CLIENT_MODES.PROBE, instanceId, - deviceIdentity: opts.includeDetails === false ? null : undefined, + deviceIdentity, onConnectError: (err) => { connectError = formatErrorMessage(err); }, diff --git a/src/tts/tts.test.ts b/src/tts/tts.test.ts index b326b4835e5..2b380025390 100644 --- a/src/tts/tts.test.ts +++ b/src/tts/tts.test.ts @@ -2,7 +2,7 @@ import { completeSimple, type AssistantMessage } from "@mariozechner/pi-ai"; import { describe, expect, it, vi, beforeEach } from "vitest"; import { ensureCustomApiRegistered } from "../agents/custom-api-registry.js"; import { getApiKeyForModel } from "../agents/model-auth.js"; -import { resolveModel } from "../agents/pi-embedded-runner/model.js"; +import { resolveModelAsync } from "../agents/pi-embedded-runner/model.js"; import type { OpenClawConfig } from "../config/config.js"; import { withEnv } from "../test-utils/env.js"; import * as tts from "./tts.js"; @@ -20,8 +20,8 @@ vi.mock("@mariozechner/pi-ai/oauth", () => ({ getOAuthApiKey: vi.fn(async () => null), })); -vi.mock("../agents/pi-embedded-runner/model.js", () => ({ - resolveModel: vi.fn((provider: string, modelId: string) => ({ +vi.mock("../agents/pi-embedded-runner/model.js", () => { + const buildResolvedModel = (provider: string, modelId: string) => ({ model: { provider, id: modelId, @@ -35,8 +35,16 @@ vi.mock("../agents/pi-embedded-runner/model.js", () => ({ }, authStorage: { profiles: {} }, modelRegistry: { find: vi.fn() }, - })), -})); + }); + return { + resolveModel: vi.fn((provider: string, modelId: string) => + buildResolvedModel(provider, modelId), + ), + resolveModelAsync: vi.fn(async (provider: string, modelId: string) => + buildResolvedModel(provider, modelId), + ), + }; +}); vi.mock("../agents/model-auth.js", () => ({ getApiKeyForModel: vi.fn(async () => ({ @@ -411,11 +419,11 @@ describe("tts", () => { timeoutMs: 30_000, }); - expect(resolveModel).toHaveBeenCalledWith("openai", "gpt-4.1-mini", undefined, cfg); + expect(resolveModelAsync).toHaveBeenCalledWith("openai", "gpt-4.1-mini", undefined, cfg); }); it("registers the Ollama api before direct summarization", async () => { - vi.mocked(resolveModel).mockReturnValue({ + vi.mocked(resolveModelAsync).mockResolvedValue({ model: { provider: "ollama", id: "qwen3:8b", From 618939ef6d9bbeec097151bfe0992ae76a6ed125 Mon Sep 17 00:00:00 2001 From: ClaudeZackaryFair Date: Mon, 16 Mar 2026 09:40:08 +0800 Subject: [PATCH 5/5] 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) => {