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); },