fix(gateway): surface unreachable status diagnostics

This commit is contained in:
Vincent Koc
2026-04-29 16:58:55 -07:00
parent 601596bfe2
commit ab178403e2
5 changed files with 83 additions and 1 deletions

View File

@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Plugin SDK/testing: lazy-load TypeScript from the plugin test-contract runtime and add release checks for critical SDK contract entrypoint imports and bundle size, so published packages fail preflight before shipping ESM-incompatible or oversized contract helpers. Thanks @vincentkoc.
- 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 @namekong8-gmail.
- CLI/browser: preserve parent flags while lazy-loading browser subcommands, so `openclaw browser --json open` and `openclaw browser --json tabs` keep machine-readable output after reparsing. Fixes #74574. Thanks @devintegeritsm.
- Plugins/runtime-deps: add `openclaw plugins deps` inspection and repair with script-free package-manager defaults shared across plugin installers, so operators can repair missing bundled runtime deps without corrupting JSON output or blocking unrelated conflict-free deps. Thanks @vincentkoc.
- Agents/output: strip internal `[tool calls omitted]` replay placeholders from user-facing replies while preserving visible reply whitespace. Fixes #74573. Thanks @blaspat.

View File

@@ -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([

View File

@@ -123,6 +123,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")

View File

@@ -18,7 +18,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 {
@@ -80,6 +81,34 @@ describe("gateway status output", () => {
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:<port>");
});
it("derives summary capability from reachable probes only in json output", () => {
const runtime = createRuntimeCapture();
writeGatewayStatusJson({

View File

@@ -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:<port> -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",