diff --git a/CHANGELOG.md b/CHANGELOG.md index ad67c5d6694..31edbf32772 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -114,6 +114,7 @@ Docs: https://docs.openclaw.ai - Gateway/plugins: reuse the session workspace when building HTTP `/tools/invoke` tool lists and harden tool construction to infer the session agent workspace by default, so workspace plugins do not re-register on repeated HTTP tool calls. (#56101) thanks @neeravmakwana - Brave/web search: normalize unsupported Brave `country` filters to `ALL` before request and cache-key generation so locale-derived values like `VN` stop failing with upstream 422 validation errors. (#55695) Thanks @chen-zhang-cs-code. - Discord/replies: preserve leading indentation when stripping inline reply tags so reply-tagged plain text and fenced code blocks keep their formatting. (#55960) Thanks @Nanako0129. +- Daemon/status: surface immediate gateway close reasons from lightweight probes and prefer those concrete auth or pairing failures over generic timeouts in `openclaw daemon status`. (#56282) Thanks @mbelinky. ## 2026.3.24 diff --git a/src/cli/daemon-cli/probe.test.ts b/src/cli/daemon-cli/probe.test.ts index ed39db0271e..35e6b9e8fa6 100644 --- a/src/cli/daemon-cli/probe.test.ts +++ b/src/cli/daemon-cli/probe.test.ts @@ -92,6 +92,26 @@ describe("probeGatewayStatus", () => { }); }); + it("prefers the close reason over a generic timeout when both are present", async () => { + callGatewayMock.mockReset(); + probeGatewayMock.mockReset(); + probeGatewayMock.mockResolvedValueOnce({ + ok: false, + error: "timeout", + close: { code: 1008, reason: "pairing required" }, + }); + + const result = await probeGatewayStatus({ + url: "ws://127.0.0.1:19191", + timeoutMs: 5_000, + }); + + expect(result).toEqual({ + ok: false, + error: "gateway closed (1008): pairing required", + }); + }); + it("surfaces status RPC errors when requireRpc is enabled", async () => { callGatewayMock.mockReset(); probeGatewayMock.mockReset(); diff --git a/src/cli/daemon-cli/probe.ts b/src/cli/daemon-cli/probe.ts index 22ed7d32a99..16b809fc412 100644 --- a/src/cli/daemon-cli/probe.ts +++ b/src/cli/daemon-cli/probe.ts @@ -1,5 +1,18 @@ import { withProgress } from "../progress.js"; +function resolveProbeFailureMessage(result: { + error?: string | null; + close?: { code: number; reason: string } | null; +}): string { + const closeHint = result.close + ? `gateway closed (${result.close.code}): ${result.close.reason}` + : null; + if (closeHint && (!result.error || result.error === "timeout")) { + return closeHint; + } + return result.error ?? closeHint ?? "gateway probe failed"; +} + export async function probeGatewayStatus(opts: { url: string; token?: string; @@ -47,12 +60,9 @@ export async function probeGatewayStatus(opts: { if (result.ok) { return { ok: true } as const; } - const closeHint = result.close - ? `gateway closed (${result.close.code}): ${result.close.reason}` - : null; return { ok: false, - error: result.error ?? closeHint ?? "gateway probe failed", + error: resolveProbeFailureMessage(result), } as const; } catch (err) { return { diff --git a/src/gateway/probe.test.ts b/src/gateway/probe.test.ts index 04f965ab252..74a76cbca00 100644 --- a/src/gateway/probe.test.ts +++ b/src/gateway/probe.test.ts @@ -3,6 +3,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const gatewayClientState = vi.hoisted(() => ({ options: null as Record | null, requests: [] as string[], + startMode: "hello" as "hello" | "close", + close: { code: 1008, reason: "pairing required" }, })); const deviceIdentityState = vi.hoisted(() => ({ @@ -22,6 +24,13 @@ class MockGatewayClient { start(): void { void Promise.resolve() .then(async () => { + if (gatewayClientState.startMode === "close") { + const onClose = this.opts.onClose; + if (typeof onClose === "function") { + onClose(gatewayClientState.close.code, gatewayClientState.close.reason); + } + return; + } const onHelloOk = this.opts.onHelloOk; if (typeof onHelloOk === "function") { await onHelloOk(); @@ -59,6 +68,8 @@ const { clampProbeTimeoutMs, probeGateway } = await import("./probe.js"); describe("probeGateway", () => { beforeEach(() => { deviceIdentityState.throwOnLoad = false; + gatewayClientState.startMode = "hello"; + gatewayClientState.close = { code: 1008, reason: "pairing required" }; }); it("clamps probe timeout to timer-safe bounds", () => { @@ -172,4 +183,22 @@ describe("probeGateway", () => { expect(gatewayClientState.options?.tlsFingerprint).toBe("sha256:abc"); }); + + it("surfaces immediate close failures before the probe timeout", async () => { + gatewayClientState.startMode = "close"; + + const result = await probeGateway({ + url: "ws://127.0.0.1:18789", + auth: { token: "secret" }, + timeoutMs: 5_000, + includeDetails: false, + }); + + expect(result).toMatchObject({ + ok: false, + error: "gateway closed (1008): pairing required", + close: { code: 1008, reason: "pairing required" }, + }); + expect(gatewayClientState.requests).toEqual([]); + }); }); diff --git a/src/gateway/probe.ts b/src/gateway/probe.ts index 5ae951bf908..f2b67986d55 100644 --- a/src/gateway/probe.ts +++ b/src/gateway/probe.ts @@ -36,6 +36,10 @@ export function clampProbeTimeoutMs(timeoutMs: number): number { return Math.min(MAX_TIMER_DELAY_MS, Math.max(MIN_PROBE_TIMEOUT_MS, timeoutMs)); } +function formatProbeCloseError(close: GatewayProbeClose): string { + return `gateway closed (${close.code}): ${close.reason}`; +} + export async function probeGateway(opts: { url: string; auth?: GatewayProbeAuth; @@ -113,6 +117,18 @@ export async function probeGateway(opts: { }, onClose: (code, reason) => { close = { code, reason }; + if (connectLatencyMs == null) { + settle({ + ok: false, + connectLatencyMs, + error: formatProbeCloseError(close), + close, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + } }, onHelloOk: async () => { connectLatencyMs = Date.now() - startedAt;