From 08ce17c33ddbb0f185032f1641a875f3b856a1ef Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Sat, 2 May 2026 13:00:29 -0700 Subject: [PATCH] fix(gateway): surface unreachable status diagnostics (#74691) Summary: - The PR adds a `no_gateway_reachable` gateway probe warning, passes discovery count into warning construction, adds focused coverage, and updates the changelog. - Reproducibility: yes. for the missing diagnostic: current main can reach an all-unreachable, zero-discovery ... -count input. No for the underlying #49012 freeze itself; that remains a separate root-cause investigation. ClawSweeper fixups: - Included follow-up commit: fix(gateway): surface unreachable status diagnostics Validation: - ClawSweeper review passed for head 50fb29c359864fc532a5c51de1fabfc97b973bb8. - Required merge gates passed before the squash merge. Prepared head SHA: 50fb29c359864fc532a5c51de1fabfc97b973bb8 Review: https://github.com/openclaw/openclaw/pull/74691#issuecomment-4348514748 Co-authored-by: Vincent Koc Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com> --- CHANGELOG.md | 1 + src/commands/gateway-status.test.ts | 40 ++++++++++++++++++++ src/commands/gateway-status.ts | 1 + src/commands/gateway-status/output.test.ts | 43 +++++++++++++++++++--- src/commands/gateway-status/output.ts | 11 ++++++ 5 files changed, 90 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d6e771475dd..eb39b22d065 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Docs: https://docs.openclaw.ai - Status: show the `openai-codex` OAuth profile for `openai/gpt-*` sessions running through the native Codex runtime instead of reporting auth as unknown. (#76197) Thanks @mbelinky. - Plugins/externalization: keep diagnostics ClawHub packages and persisted bundled-plugin relocation on npm-first install metadata for launch, and omit Discord from the core package now that its external package is published. Thanks @vincentkoc. - Plugins/Codex: allow the official npm Codex plugin to install without the unsafe-install override, keep `/codex` command ownership, and cover the real npm Docker live path through managed `.openclaw/npm` dependencies plus uninstall failure proof. +- Gateway/status: add concrete service, config, listener-owner, and log collection next steps when gateway probes fail and Bonjour finds no local gateway, so frozen or port-conflict reports include the data needed for root-cause triage. Refs #49012. Thanks @vincentkoc. ## 2026.5.2 diff --git a/src/commands/gateway-status.test.ts b/src/commands/gateway-status.test.ts index d7205441b83..152d99f168b 100644 --- a/src/commands/gateway-status.test.ts +++ b/src/commands/gateway-status.test.ts @@ -312,6 +312,46 @@ describe("gateway-status command", () => { expect(targets[0]?.summary).toBeTruthy(); }); + it("includes diagnostic next steps when no gateway is reachable or discoverable", async () => { + const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); + const defaultProbeGateway = probeGateway.getMockImplementation(); + try { + probeGateway.mockImplementation(async (opts: { url: string }) => ({ + ok: false, + url: opts.url, + connectLatencyMs: null, + error: "connection refused", + close: null, + auth: { + role: null, + scopes: [], + capability: "unknown", + }, + health: null, + status: null, + presence: null, + configSnapshot: null, + })); + + await expect(runGatewayStatus(runtime, { timeout: "1000", json: true })).rejects.toThrow( + "__exit__:1", + ); + } finally { + probeGateway.mockReset(); + if (defaultProbeGateway) { + probeGateway.mockImplementation(defaultProbeGateway); + } + } + + expect(runtimeErrors).toHaveLength(0); + const parsed = JSON.parse(runtimeLogs.join("\n")) as { + warnings?: Array<{ code?: string; message?: string }>; + }; + const warning = parsed.warnings?.find((entry) => entry.code === "no_gateway_reachable"); + expect(warning?.message).toContain("openclaw gateway status --deep --require-rpc"); + expect(warning?.message).toContain("ss -ltnp"); + }); + it("omits discovery wsUrl when only TXT hints are present", async () => { const { runtime, runtimeLogs, runtimeErrors } = createRuntimeCapture(); discoverGatewayBeacons.mockResolvedValueOnce([ diff --git a/src/commands/gateway-status.ts b/src/commands/gateway-status.ts index 1f6d8a79e83..099a8b44be6 100644 --- a/src/commands/gateway-status.ts +++ b/src/commands/gateway-status.ts @@ -121,6 +121,7 @@ export async function gatewayStatusCommand( sshTarget: probePass.sshTarget, sshTunnelStarted: probePass.sshTunnelStarted, sshTunnelError: probePass.sshTunnelError, + discoveryCount: probePass.discovery.length, localTlsLoadError: localTlsRuntime && !localTlsRuntime.enabled && localTlsRuntime.required ? (localTlsRuntime.error ?? "gateway tls is enabled but local TLS runtime could not load") diff --git a/src/commands/gateway-status/output.test.ts b/src/commands/gateway-status/output.test.ts index 87b3164ed2a..8adeee1f1db 100644 --- a/src/commands/gateway-status/output.test.ts +++ b/src/commands/gateway-status/output.test.ts @@ -3,10 +3,12 @@ import type { GatewayProbeResult } from "../../gateway/probe.js"; import type { RuntimeEnv } from "../../runtime.js"; import type { GatewayStatusProbedTarget } from "./probe-run.js"; -const writeRuntimeJson = vi.fn(); +const mocks = vi.hoisted(() => ({ + writeRuntimeJson: vi.fn(), +})); vi.mock("../../runtime.js", () => ({ - writeRuntimeJson: (...args: unknown[]) => writeRuntimeJson(...args), + writeRuntimeJson: (...args: unknown[]) => mocks.writeRuntimeJson(...args), })); vi.mock("../../terminal/theme.js", async () => { @@ -18,7 +20,8 @@ vi.mock("../../terminal/theme.js", async () => { }; }); -const { writeGatewayStatusJson, writeGatewayStatusText } = await import("./output.js"); +const { buildGatewayStatusWarnings, writeGatewayStatusJson, writeGatewayStatusText } = + await import("./output.js"); function createRuntimeCapture(): RuntimeEnv { return { @@ -77,7 +80,35 @@ function createTarget(id: string, probe: GatewayProbeResult): GatewayStatusProbe describe("gateway status output", () => { beforeEach(() => { - writeRuntimeJson.mockReset(); + mocks.writeRuntimeJson.mockReset(); + }); + + it("warns with diagnostic next steps when no probes or Bonjour discovery find a gateway", () => { + const warnings = buildGatewayStatusWarnings({ + probed: [ + createTarget( + "localLoopback", + createProbe("unknown", { + ok: false, + connectLatencyMs: null, + error: "connection refused", + }), + ), + ], + sshTarget: null, + sshTunnelStarted: false, + sshTunnelError: null, + discoveryCount: 0, + }); + + expect(warnings).toContainEqual( + expect.objectContaining({ + code: "no_gateway_reachable", + message: expect.stringContaining("openclaw gateway status --deep --require-rpc"), + targetIds: ["localLoopback"], + }), + ); + expect(warnings.at(0)?.message).toContain("lsof -nP -iTCP:"); }); it("derives summary capability from reachable probes only in json output", () => { @@ -114,7 +145,7 @@ describe("gateway status output", () => { primaryTargetId: "reachable-read", }); - expect(writeRuntimeJson).toHaveBeenCalledWith( + expect(mocks.writeRuntimeJson).toHaveBeenCalledWith( runtime, expect.objectContaining({ ok: true, @@ -188,7 +219,7 @@ describe("gateway status output", () => { primaryTargetId: "detail-timeout", }); - expect(writeRuntimeJson).toHaveBeenCalledWith( + expect(mocks.writeRuntimeJson).toHaveBeenCalledWith( runtime, expect.objectContaining({ ok: true, diff --git a/src/commands/gateway-status/output.ts b/src/commands/gateway-status/output.ts index a17c31d3377..3bf89ce80c7 100644 --- a/src/commands/gateway-status/output.ts +++ b/src/commands/gateway-status/output.ts @@ -18,6 +18,9 @@ export type GatewayStatusWarning = { targetIds?: string[]; }; +const noReachableGatewayDiagnostic = + "No gateway answered any probe and Bonjour discovery returned no local gateways. Run `openclaw gateway status --deep --require-rpc` to inspect service state, config paths, listener owners, and logs; include `ss -ltnp` or `lsof -nP -iTCP: -sTCP:LISTEN` for the configured port when filing a report."; + export function pickPrimaryProbedTarget(probed: GatewayStatusProbedTarget[]) { const reachable = probed.filter((entry) => isProbeReachable(entry.probe)); return ( @@ -35,6 +38,7 @@ export function buildGatewayStatusWarnings(params: { sshTunnelStarted: boolean; sshTunnelError: string | null; localTlsLoadError?: string | null; + discoveryCount?: number; }): GatewayStatusWarning[] { const reachable = params.probed.filter((entry) => isProbeReachable(entry.probe)); const degradedScopeLimited = params.probed.filter((entry) => @@ -59,6 +63,13 @@ export function buildGatewayStatusWarnings(params: { targetIds: ["localLoopback"], }); } + if (reachable.length === 0 && params.discoveryCount === 0) { + warnings.push({ + code: "no_gateway_reachable", + message: noReachableGatewayDiagnostic, + targetIds: params.probed.map((entry) => entry.target.id), + }); + } if (reachable.length > 1) { warnings.push({ code: "multiple_gateways",