From 6e8f0f8d2a518b46c31a8549344a70e08e4a82d9 Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Mon, 20 Apr 2026 23:06:17 -0400 Subject: [PATCH] fix: redact channel status json errors --- .../channels.status.command-flow.test.ts | 13 +++++- src/commands/channels/status.ts | 40 ++++++++++++++++++- 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/src/commands/channels.status.command-flow.test.ts b/src/commands/channels.status.command-flow.test.ts index 82e165930ad..701141ed5f5 100644 --- a/src/commands/channels.status.command-flow.test.ts +++ b/src/commands/channels.status.command-flow.test.ts @@ -251,7 +251,15 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { }); it("keeps JSON fallback structured without rendering config-only text", async () => { - mocks.callGateway.mockRejectedValue(new Error("gateway closed")); + mocks.callGateway.mockRejectedValue( + new Error( + [ + "gateway timeout after 3000ms", + "Gateway target: wss://user:pass@gateway.example.com/socket?token=secret-token&keep=visible", + "Source: env OPENCLAW_GATEWAY_URL", + ].join("\n"), + ), + ); mocks.requireValidConfigSnapshot.mockResolvedValue({ secretResolved: false, channels: {} }); mocks.resolveCommandConfigWithSecrets.mockResolvedValue({ resolvedConfig: { secretResolved: true, channels: {} }, @@ -270,6 +278,9 @@ describe("channelsStatusCommand SecretRef fallback flow", () => { }), ); const payload = JSON.parse(logs.at(-1) ?? "{}"); + expect(payload.error).toContain("Gateway target:"); + expect(payload.error).not.toContain("user:pass"); + expect(payload.error).not.toContain("secret-token"); expect(payload).toEqual( expect.objectContaining({ gatewayReachable: false, diff --git a/src/commands/channels/status.ts b/src/commands/channels/status.ts index 52c394e160f..4bed0fdf99a 100644 --- a/src/commands/channels/status.ts +++ b/src/commands/channels/status.ts @@ -5,6 +5,7 @@ import { withProgress } from "../../cli/progress.js"; import { readConfigFileSnapshot } from "../../config/config.js"; import { callGateway } from "../../gateway/call.js"; import { collectChannelStatusIssues } from "../../infra/channels-status-issues.js"; +import { formatErrorMessage } from "../../infra/errors.js"; import { formatTimeAgo } from "../../infra/format-time/format-relative.ts"; import { listConfiguredChannelIdsForReadOnlyScope } from "../../plugins/channel-plugin-ids.js"; import { defaultRuntime, type RuntimeEnv, writeRuntimeJson } from "../../runtime.js"; @@ -29,6 +30,43 @@ export type ChannelsStatusOptions = { timeout?: string; }; +const SENSITIVE_GATEWAY_URL_QUERY_KEYS = new Set([ + "access_token", + "api_key", + "auth", + "key", + "password", + "secret", + "signature", + "token", +]); + +function redactGatewayUrlSecretsInText(text: string): string { + return text.replace(/\b(?:wss?|https?):\/\/[^\s"'<>]+/gi, (rawUrl) => { + try { + const url = new URL(rawUrl); + if (url.username) { + url.username = "redacted"; + } + if (url.password) { + url.password = "redacted"; + } + for (const key of url.searchParams.keys()) { + if (SENSITIVE_GATEWAY_URL_QUERY_KEYS.has(key.toLowerCase())) { + url.searchParams.set(key, "redacted"); + } + } + return url.toString(); + } catch { + return rawUrl; + } + }); +} + +function formatChannelsStatusJsonError(err: unknown): string { + return redactGatewayUrlSecretsInText(formatErrorMessage(err)); +} + export function formatGatewayChannelsStatusLines(payload: Record): string[] { const lines: string[] = []; lines.push(theme.success("Gateway reachable.")); @@ -190,7 +228,7 @@ export async function channelsStatusCommand( if (opts.json) { writeRuntimeJson(runtime, { gatewayReachable: false, - error: String(err), + error: formatChannelsStatusJsonError(err), configOnly: true, config: { path: snapshot.path,