Skip to content

Commit e06782d

Browse files
authored
fix(gateway): land linked diagnostics fixes
Fix logs.tail credential-header redaction and JSON-mode gateway transport errors.\n\nFixes #66832.\nFixes #79108.\nSupersedes #67041.\nSupersedes #79233.\n\nCo-authored-by: Mil Wang <mingjwan@microsoft.com>\nCo-authored-by: Andy Ye <35905412+TurboTheTurtle@users.noreply.github.com>
1 parent d77c4bb commit e06782d

12 files changed

Lines changed: 362 additions & 44 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ Docs: https://docs.openclaw.ai
1919
### Fixes
2020

2121
- Gateway/diagnostics: redact credential-bearing gateway target URLs and client diagnostics while preserving raw connection URLs for programmatic use, so connect-failure logs no longer surface embedded tokens.
22+
- Logs: redact raw Basic auth and named security headers from `logs.tail` output before returning lines to read-scoped clients. Fixes #66832. Thanks @Magicray1217.
23+
- CLI/gateway: emit structured JSON for gateway transport close/timeout failures when `--json` is requested by health, gateway health, and devices list commands. Fixes #79108. Thanks @TurboTheTurtle.
2224
- Telegram: normalize announce group targets via a new `resolveSessionTarget` channel hook so scheduled announcements resolve consistently against the same Telegram session conversation registry as inbound turns. Fixes #81229. Thanks @giodl73-repo.
2325
- Discord: bind delayed gateway `identify` retries to the originating socket generation so retries triggered after a reconnect do not identify against a fresh socket. Fixes #82225. Thanks @giodl73-repo.
2426
- ACP/control plane: refresh cached runtime handles when agent config changes so ACP sessions stop using stale runtimes after `agents.defaults` edits. Fixes #82237. Thanks @giodl73-repo.

src/cli/devices-cli.runtime.ts

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
1+
import {
2+
buildGatewayConnectionDetails,
3+
callGateway,
4+
formatGatewayTransportErrorJson,
5+
} from "../gateway/call.js";
26
import { ADMIN_SCOPE, PAIRING_SCOPE, type OperatorScope } from "../gateway/method-scopes.js";
37
import { isLoopbackHost } from "../gateway/net.js";
48
import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../gateway/protocol/client-info.js";
@@ -504,7 +508,20 @@ function resolveRequiredDeviceRole(
504508
}
505509

506510
export async function runDevicesListCommand(opts: DevicesRpcOpts): Promise<void> {
507-
const list = await listPairingWithFallback(opts);
511+
let list: DevicePairingList;
512+
try {
513+
list = await listPairingWithFallback(opts);
514+
} catch (error) {
515+
if (opts.json) {
516+
const payload = formatGatewayTransportErrorJson(error);
517+
if (payload) {
518+
defaultRuntime.writeJson(payload);
519+
defaultRuntime.exit(1);
520+
return;
521+
}
522+
}
523+
throw error;
524+
}
508525
const pairedByDeviceId = indexPairedDevices(list.paired);
509526
if (opts.json) {
510527
defaultRuntime.writeJson(list);

src/cli/devices-cli.test.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { Command } from "commander";
22
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
import { stripAnsi } from "../terminal/ansi.js";
34
import { registerDevicesCli } from "./devices-cli.js";
45

56
const mocks = vi.hoisted(() => ({
@@ -10,6 +11,7 @@ const mocks = vi.hoisted(() => ({
1011
writeJson: vi.fn(),
1112
},
1213
callGateway: vi.fn(),
14+
formatGatewayTransportErrorJson: vi.fn(),
1315
buildGatewayConnectionDetails: vi.fn(() => ({
1416
url: "ws://127.0.0.1:18789",
1517
urlSource: "local loopback",
@@ -24,6 +26,7 @@ const mocks = vi.hoisted(() => ({
2426
const {
2527
runtime,
2628
callGateway,
29+
formatGatewayTransportErrorJson,
2730
buildGatewayConnectionDetails,
2831
listDevicePairing,
2932
approveDevicePairing,
@@ -32,6 +35,7 @@ const {
3235

3336
vi.mock("../gateway/call.js", () => ({
3437
callGateway: mocks.callGateway,
38+
formatGatewayTransportErrorJson: mocks.formatGatewayTransportErrorJson,
3539
buildGatewayConnectionDetails: mocks.buildGatewayConnectionDetails,
3640
}));
3741

@@ -676,18 +680,43 @@ describe("devices cli list", () => {
676680

677681
await runDevicesCommand(["list"]);
678682

679-
const output = readRuntimeOutput();
683+
const output = stripAnsi(readRuntimeOutput());
680684
expect(output).not.toContain("\u001b");
681685
expect(output).not.toContain("\r");
682686
expect(output).toContain("BadName");
683687
expect(output).toContain("spoof");
684688
expect(output).toContain("Paired");
685689
});
690+
691+
it("emits JSON when the gateway transport fails in JSON mode", async () => {
692+
const error = new Error("gateway closed (1006)");
693+
const payload = {
694+
ok: false,
695+
error: {
696+
type: "gateway_transport_error",
697+
kind: "closed",
698+
message: "gateway closed (1006)",
699+
},
700+
gateway: {
701+
url: "ws://127.0.0.1:18789",
702+
urlSource: "local loopback",
703+
},
704+
};
705+
callGateway.mockRejectedValueOnce(error);
706+
formatGatewayTransportErrorJson.mockReturnValueOnce(payload);
707+
708+
await runDevicesCommand(["list", "--json"]);
709+
710+
expect(formatGatewayTransportErrorJson).toHaveBeenCalledWith(error);
711+
expect(runtime.writeJson).toHaveBeenCalledWith(payload);
712+
expect(runtime.exit).toHaveBeenCalledWith(1);
713+
});
686714
});
687715

688716
beforeEach(() => {
689717
vi.clearAllMocks();
690718
runtime.exit.mockImplementation(() => {});
719+
formatGatewayTransportErrorJson.mockReturnValue(null);
691720
});
692721

693722
afterEach(() => {

src/cli/gateway-cli.coverage.test.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ type DiscoveredBeacon = Awaited<
1212
>[number];
1313

1414
const callGateway = vi.fn<(opts: unknown) => Promise<{ ok: true }>>(async () => ({ ok: true }));
15+
const formatGatewayTransportErrorJson = vi.fn();
1516
const startGatewayServer = vi.fn<
1617
(port: number, opts?: unknown) => Promise<{ close: () => Promise<void> }>
1718
>(async () => ({
@@ -44,6 +45,7 @@ vi.mock(
4445
new URL("../../gateway/call.ts", new URL("./gateway-cli/call.ts", import.meta.url)).href,
4546
() => ({
4647
callGateway: (opts: unknown) => callGateway(opts),
48+
formatGatewayTransportErrorJson: (error: unknown) => formatGatewayTransportErrorJson(error),
4749
randomIdempotencyKey: () => "rk_test",
4850
}),
4951
);
@@ -143,6 +145,8 @@ describe("gateway-cli coverage", () => {
143145
startGatewayServer.mockClear();
144146
inspectPortUsage.mockClear();
145147
formatPortDiagnostics.mockClear();
148+
formatGatewayTransportErrorJson.mockReset();
149+
formatGatewayTransportErrorJson.mockReturnValue(null);
146150
});
147151

148152
it("registers call/health commands and routes to callGateway", async () => {
@@ -184,6 +188,30 @@ describe("gateway-cli coverage", () => {
184188
});
185189
});
186190

191+
it("writes JSON for gateway health transport failures in JSON mode", async () => {
192+
const error = new Error("gateway closed (1006)");
193+
const payload = {
194+
ok: false,
195+
error: {
196+
type: "gateway_transport_error",
197+
kind: "closed",
198+
message: "gateway closed (1006)",
199+
},
200+
gateway: {
201+
url: "ws://127.0.0.1:18789",
202+
urlSource: "local loopback",
203+
},
204+
};
205+
callGateway.mockRejectedValueOnce(error);
206+
formatGatewayTransportErrorJson.mockReturnValueOnce(payload);
207+
208+
await expectGatewayExit(["gateway", "health", "--json"]);
209+
210+
expect(formatGatewayTransportErrorJson).toHaveBeenCalledWith(error);
211+
expect(defaultRuntime.writeJson).toHaveBeenCalledWith(payload);
212+
expect(runtimeErrors.join("\n")).not.toContain("gateway closed");
213+
});
214+
187215
it("prints the latest stability bundle without calling Gateway", async () => {
188216
callGateway.mockClear();
189217
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-gateway-cli-bundle-"));

src/cli/gateway-cli/register.ts

Lines changed: 37 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Command } from "commander";
22
import type { HealthSummary } from "../../commands/health.js";
3+
import { formatGatewayTransportErrorJson } from "../../gateway/call.js";
34
import type { CostUsageSummary } from "../../infra/session-cost-usage.js";
45
import type {
56
DiagnosticStabilityBundle,
@@ -97,8 +98,16 @@ function loadDaemonStatusGatherModule() {
9798
return daemonStatusGatherModuleLoader.load();
9899
}
99100

100-
function runGatewayCommand(action: () => Promise<void>, label?: string) {
101+
function runGatewayCommand(action: () => Promise<void>, label?: string, opts?: { json?: boolean }) {
101102
return runCommandWithRuntime(defaultRuntime, action, (err) => {
103+
if (opts?.json) {
104+
const payload = formatGatewayTransportErrorJson(err);
105+
if (payload) {
106+
defaultRuntime.writeJson(payload);
107+
defaultRuntime.exit(1);
108+
return;
109+
}
110+
}
102111
const message = String(err);
103112
defaultRuntime.error(label ? `${label}: ${message}` : message);
104113
defaultRuntime.exit(1);
@@ -459,30 +468,34 @@ export function registerGatewayCli(program: Command) {
459468
.command("health")
460469
.description("Fetch Gateway health")
461470
.action(async (opts, command) => {
462-
await runGatewayCommand(async () => {
463-
const rpcOpts = resolveGatewayRpcOptions(opts, command);
464-
const [{ formatHealthChannelLines }, { styleHealthChannelLine }] = await Promise.all([
465-
loadGatewayHealthModule(),
466-
loadHealthStyleModule(),
467-
]);
468-
const result = await callGatewayCli("health", rpcOpts);
469-
if (rpcOpts.json) {
470-
defaultRuntime.writeJson(result);
471-
return;
472-
}
473-
const rich = isRich();
474-
const obj: Record<string, unknown> = result && typeof result === "object" ? result : {};
475-
const durationMs = typeof obj.durationMs === "number" ? obj.durationMs : null;
476-
defaultRuntime.log(colorize(rich, theme.heading, "Gateway Health"));
477-
defaultRuntime.log(
478-
`${colorize(rich, theme.success, "OK")}${durationMs != null ? ` (${durationMs}ms)` : ""}`,
479-
);
480-
if (obj.channels && typeof obj.channels === "object") {
481-
for (const line of formatHealthChannelLines(obj as HealthSummary)) {
482-
defaultRuntime.log(styleHealthChannelLine(line, rich));
471+
await runGatewayCommand(
472+
async () => {
473+
const rpcOpts = resolveGatewayRpcOptions(opts, command);
474+
const [{ formatHealthChannelLines }, { styleHealthChannelLine }] = await Promise.all([
475+
loadGatewayHealthModule(),
476+
loadHealthStyleModule(),
477+
]);
478+
const result = await callGatewayCli("health", rpcOpts);
479+
if (rpcOpts.json) {
480+
defaultRuntime.writeJson(result);
481+
return;
483482
}
484-
}
485-
});
483+
const rich = isRich();
484+
const obj: Record<string, unknown> = result && typeof result === "object" ? result : {};
485+
const durationMs = typeof obj.durationMs === "number" ? obj.durationMs : null;
486+
defaultRuntime.log(colorize(rich, theme.heading, "Gateway Health"));
487+
defaultRuntime.log(
488+
`${colorize(rich, theme.success, "OK")}${durationMs != null ? ` (${durationMs}ms)` : ""}`,
489+
);
490+
if (obj.channels && typeof obj.channels === "object") {
491+
for (const line of formatHealthChannelLines(obj as HealthSummary)) {
492+
defaultRuntime.log(styleHealthChannelLine(line, rich));
493+
}
494+
}
495+
},
496+
undefined,
497+
{ json: Boolean(opts.json) },
498+
);
486499
}),
487500
);
488501

src/commands/health.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,8 +52,11 @@ const createHealthSummary = (params: {
5252
};
5353

5454
const callGatewayMock = vi.fn();
55+
const formatGatewayTransportErrorJsonMock = vi.fn();
5556
vi.mock("../gateway/call.js", () => ({
5657
callGateway: (...args: unknown[]) => callGatewayMock(...args),
58+
formatGatewayTransportErrorJson: (...args: unknown[]) =>
59+
formatGatewayTransportErrorJsonMock(...args),
5760
}));
5861

5962
function requireFirstRuntimeLog(): string {
@@ -83,6 +86,7 @@ function requireFirstGatewayRequest(): Record<string, unknown> {
8386
describe("healthCommand", () => {
8487
beforeEach(() => {
8588
vi.clearAllMocks();
89+
formatGatewayTransportErrorJsonMock.mockReturnValue(null);
8690
});
8791

8892
it("outputs JSON from gateway", async () => {
@@ -146,6 +150,33 @@ describe("healthCommand", () => {
146150
expect(gatewayRequest.password).toBe("setup-password");
147151
});
148152

153+
it("outputs JSON for gateway transport failures in JSON mode", async () => {
154+
const error = new Error("gateway closed (1006)");
155+
const payload = {
156+
ok: false,
157+
error: {
158+
type: "gateway_transport_error",
159+
kind: "closed",
160+
message: "gateway closed (1006)",
161+
code: 1006,
162+
reason: "no close reason",
163+
},
164+
gateway: {
165+
url: "ws://127.0.0.1:18789",
166+
urlSource: "local loopback",
167+
bindDetail: "Bind: loopback",
168+
},
169+
};
170+
callGatewayMock.mockRejectedValueOnce(error);
171+
formatGatewayTransportErrorJsonMock.mockReturnValueOnce(payload);
172+
173+
await healthCommand({ json: true, timeoutMs: 5000, config: {} }, runtime as never);
174+
175+
expect(formatGatewayTransportErrorJsonMock).toHaveBeenCalledWith(error);
176+
expect(runtime.exit).toHaveBeenCalledWith(1);
177+
expect(JSON.parse(requireFirstRuntimeLog())).toEqual(payload);
178+
});
179+
149180
it("prints degraded model-pricing health without failing the command", async () => {
150181
const snapshot = createHealthSummary({
151182
channels: {},

src/commands/health.ts

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ import { withProgress } from "../cli/progress.js";
1313
import { getRuntimeConfig } from "../config/config.js";
1414
import { resolveStorePath } from "../config/sessions/paths.js";
1515
import type { OpenClawConfig } from "../config/types.openclaw.js";
16-
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
16+
import {
17+
buildGatewayConnectionDetails,
18+
callGateway,
19+
formatGatewayTransportErrorJson,
20+
} from "../gateway/call.js";
1721
import {
1822
DEFAULT_CHANNEL_CONNECT_GRACE_MS,
1923
DEFAULT_CHANNEL_STALE_EVENT_THRESHOLD_MS,
@@ -603,22 +607,35 @@ export async function healthCommand(
603607
) {
604608
const cfg = opts.config ?? (await readBestEffortHealthConfig());
605609
// Always query the running gateway; do not open a direct Baileys socket here.
606-
const summary = await withProgress(
607-
{
608-
label: "Checking gateway health…",
609-
indeterminate: true,
610-
enabled: opts.json !== true,
611-
},
612-
async () =>
613-
await callGateway<HealthSummary>({
614-
method: "health",
615-
params: opts.verbose ? { probe: true } : undefined,
616-
timeoutMs: opts.timeoutMs,
617-
config: cfg,
618-
token: opts.token,
619-
password: opts.password,
620-
}),
621-
);
610+
let summary: HealthSummary;
611+
try {
612+
summary = await withProgress(
613+
{
614+
label: "Checking gateway health…",
615+
indeterminate: true,
616+
enabled: opts.json !== true,
617+
},
618+
async () =>
619+
await callGateway<HealthSummary>({
620+
method: "health",
621+
params: opts.verbose ? { probe: true } : undefined,
622+
timeoutMs: opts.timeoutMs,
623+
config: cfg,
624+
token: opts.token,
625+
password: opts.password,
626+
}),
627+
);
628+
} catch (error) {
629+
if (opts.json) {
630+
const payload = formatGatewayTransportErrorJson(error);
631+
if (payload) {
632+
writeRuntimeJson(runtime, payload);
633+
runtime.exit(1);
634+
return;
635+
}
636+
}
637+
throw error;
638+
}
622639
// Gateway reachability defines success; channel issues are reported but not fatal here.
623640
const fatal = false;
624641

0 commit comments

Comments
 (0)