From 02b220388f641457276eda973e47ccd541976bdc Mon Sep 17 00:00:00 2001 From: Gustavo Madeira Santana Date: Wed, 22 Apr 2026 16:09:58 -0400 Subject: [PATCH] fix: harden diagnostics support export privacy --- CHANGELOG.md | 1 + src/cli/gateway-cli/register.ts | 1 - src/gateway/gateway-stability.test.ts | 28 ++++++------------- src/logging/diagnostic-stability.test.ts | 2 ++ src/logging/diagnostic-stability.ts | 16 ----------- src/logging/diagnostic-support-export.test.ts | 9 ++++++ src/logging/diagnostic-support-export.ts | 5 ++-- src/logging/diagnostic-support-redaction.ts | 8 ++++-- src/logging/diagnostic.test.ts | 5 +++- 9 files changed, 33 insertions(+), 42 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf2669d1704..65810f748b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Docs: https://docs.openclaw.ai - TUI: add local embedded mode for running terminal chats without a Gateway while keeping plugin approval gates enforced. (#66767) Thanks @fuller-stack-dev. - CLI/Claude: default `claude-cli` runs to warm stdio sessions, including custom configs that omit transport fields, and resume from the stored Claude session after Gateway restarts or idle exits. (#69679) Thanks @obviyus. - Control UI/settings+chat: add a browser-local personal identity for the operator (name plus local-safe avatar), route user identity rendering through the shared chat/avatar path used by assistant and agent surfaces, and tighten Quick Settings, agent fallback chips, and narrow-screen chat layouts so personalization no longer wastes space or clips controls. (#70362) Thanks @BunsDev. +- Gateway/diagnostics: add a support-ready diagnostics export with sanitized logs, status, health, config, and stability snapshots for bug reports. (#70324) Thanks @gumadeiras. ### Fixes diff --git a/src/cli/gateway-cli/register.ts b/src/cli/gateway-cli/register.ts index 4a416277a31..e273f0cc8d4 100644 --- a/src/cli/gateway-cli/register.ts +++ b/src/cli/gateway-cli/register.ts @@ -195,7 +195,6 @@ function formatStabilityEvent(record: DiagnosticStabilityEventRecord): string { record.surface ? `surface=${record.surface}` : "", record.channel ? `channel=${record.channel}` : "", record.pluginId ? `plugin=${record.pluginId}` : "", - record.sessionId ? `session=${record.sessionId}` : "", record.reason ? `reason=${record.reason}` : "", record.bytes !== undefined ? `bytes=${formatBytes(record.bytes)}` : "", record.limitBytes !== undefined ? `limit=${formatBytes(record.limitBytes)}` : "", diff --git a/src/gateway/gateway-stability.test.ts b/src/gateway/gateway-stability.test.ts index d7259fa5f8f..e53dc3a3d00 100644 --- a/src/gateway/gateway-stability.test.ts +++ b/src/gateway/gateway-stability.test.ts @@ -9,7 +9,6 @@ import { resetDiagnosticStabilityRecorderForTest, startDiagnosticStabilityRecorder, stopDiagnosticStabilityRecorder, - type DiagnosticStabilityEventRecord, } from "../logging/diagnostic-stability.js"; const MB = 1024 * 1024; @@ -95,18 +94,6 @@ function emitSyntheticGatewayStabilityLoad(): number { return maxRssBytes; } -function latestSessionStates( - events: DiagnosticStabilityEventRecord[], -): Map { - const latest = new Map(); - for (const event of events) { - if (event.type === "session.state" && event.sessionKey) { - latest.set(event.sessionKey, event); - } - } - return latest; -} - describe("gateway stability lane", () => { beforeEach(() => { resetDiagnosticEventsForTest(); @@ -145,13 +132,16 @@ describe("gateway stability lane", () => { expect(snapshot.summary.payloadLarge?.chunked).toBeGreaterThan(0); expect(snapshot.summary.payloadLarge?.bySurface["gateway.stability.probe"]).toBeGreaterThan(0); - const latestStates = latestSessionStates(snapshot.events); - expect(latestStates.size).toBe(SYNTHETIC_SESSION_COUNT); - for (const event of latestStates.values()) { - expect(event.outcome).toBe("idle"); - expect(event.queueDepth).toBe(0); - expect(event.reason).toBe(STABILITY_REASON); + const sessionEvents = snapshot.events.filter((event) => event.type === "session.state"); + expect(sessionEvents.length).toBeGreaterThan(0); + for (const event of sessionEvents) { + expect(event).not.toHaveProperty("sessionId"); + expect(event).not.toHaveProperty("sessionKey"); } + expect(sessionEvents.some((event) => event.outcome === "idle" && event.queueDepth === 0)).toBe( + true, + ); + expect(sessionEvents.every((event) => event.reason === STABILITY_REASON)).toBe(true); stopDiagnosticStabilityRecorder(); emitDiagnosticEvent({ diff --git a/src/logging/diagnostic-stability.test.ts b/src/logging/diagnostic-stability.test.ts index ff78eb5973e..dac0603fcae 100644 --- a/src/logging/diagnostic-stability.test.ts +++ b/src/logging/diagnostic-stability.test.ts @@ -64,6 +64,8 @@ describe("diagnostic stability recorder", () => { count: 3, }); expect(snapshot.events[1]).not.toHaveProperty("message"); + expect(snapshot.events[1]).not.toHaveProperty("sessionId"); + expect(snapshot.events[1]).not.toHaveProperty("sessionKey"); }); it("keeps stable reason codes but drops free-form reason text", () => { diff --git a/src/logging/diagnostic-stability.ts b/src/logging/diagnostic-stability.ts index 2d9d6477831..1965159b589 100644 --- a/src/logging/diagnostic-stability.ts +++ b/src/logging/diagnostic-stability.ts @@ -14,8 +14,6 @@ export type DiagnosticStabilityEventRecord = { seq: number; ts: number; type: DiagnosticEventPayload["type"]; - sessionKey?: string; - sessionId?: string; channel?: string; pluginId?: string; source?: string; @@ -172,8 +170,6 @@ function sanitizeDiagnosticEvent(event: DiagnosticEventPayload): DiagnosticStabi switch (event.type) { case "model.usage": - record.sessionKey = event.sessionKey; - record.sessionId = event.sessionId; record.channel = event.channel; record.provider = event.provider; record.model = event.model; @@ -193,30 +189,22 @@ function sanitizeDiagnosticEvent(event: DiagnosticEventPayload): DiagnosticStabi record.channel = event.channel; break; case "message.queued": - record.sessionKey = event.sessionKey; - record.sessionId = event.sessionId; record.channel = event.channel; record.source = event.source; record.queueDepth = event.queueDepth; break; case "message.processed": - record.sessionKey = event.sessionKey; - record.sessionId = event.sessionId; record.channel = event.channel; record.durationMs = event.durationMs; record.outcome = event.outcome; assignReasonCode(record, event.reason); break; case "session.state": - record.sessionKey = event.sessionKey; - record.sessionId = event.sessionId; record.outcome = event.state; assignReasonCode(record, event.reason); record.queueDepth = event.queueDepth; break; case "session.stuck": - record.sessionKey = event.sessionKey; - record.sessionId = event.sessionId; record.outcome = event.state; record.ageMs = event.ageMs; record.queueDepth = event.queueDepth; @@ -231,8 +219,6 @@ function sanitizeDiagnosticEvent(event: DiagnosticEventPayload): DiagnosticStabi record.waitMs = event.waitMs; break; case "run.attempt": - record.sessionKey = event.sessionKey; - record.sessionId = event.sessionId; record.count = event.attempt; break; case "diagnostic.heartbeat": @@ -242,8 +228,6 @@ function sanitizeDiagnosticEvent(event: DiagnosticEventPayload): DiagnosticStabi record.queued = event.queued; break; case "tool.loop": - record.sessionKey = event.sessionKey; - record.sessionId = event.sessionId; record.toolName = event.toolName; record.level = event.level; record.action = event.action; diff --git a/src/logging/diagnostic-support-export.test.ts b/src/logging/diagnostic-support-export.test.ts index e947c3c938f..683bb19081b 100644 --- a/src/logging/diagnostic-support-export.test.ts +++ b/src/logging/diagnostic-support-export.test.ts @@ -66,6 +66,9 @@ describe("diagnostic support export", () => { token: fakeToken, }, }, + logging: { + redactSensitive: "off", + }, channels: { telegram: { accounts: { @@ -319,6 +322,9 @@ describe("diagnostic support export", () => { accounts?: Record; }; }; + logging?: { + redactSensitive?: string; + }; agents?: Array<{ name?: string; instructions?: string }>; }; expect(sanitizedConfig.gateway).toMatchObject({ @@ -329,6 +335,9 @@ describe("diagnostic support export", () => { token: "", }, }); + expect(sanitizedConfig.logging).toMatchObject({ + redactSensitive: "off", + }); expect(Object.keys(sanitizedConfig.channels?.telegram?.accounts ?? {})).toEqual([ "", ]); diff --git a/src/logging/diagnostic-support-export.ts b/src/logging/diagnostic-support-export.ts index 8ba8f6075e7..233931bf86c 100644 --- a/src/logging/diagnostic-support-export.ts +++ b/src/logging/diagnostic-support-export.ts @@ -21,7 +21,6 @@ import { type SupportRedactionContext, } from "./diagnostic-support-redaction.js"; import { readConfiguredLogTail, type LogTailPayload } from "./log-tail.js"; -import { redactSensitiveText } from "./redact.js"; export const DIAGNOSTIC_SUPPORT_EXPORT_VERSION = 1; @@ -209,7 +208,7 @@ function safeScalar(value: unknown): unknown { return value; } if (typeof value === "string") { - const redacted = redactSensitiveText(value); + const redacted = redactTextForSupport(value); return redacted === value && /^[A-Za-z0-9_.:-]{1,120}$/u.test(value) ? value : ""; } return undefined; @@ -287,7 +286,7 @@ function configShapeReadFailure(params: { shape.mtime = params.stat.mtime.toISOString(); } if (params.error) { - shape.error = redactSensitiveText(params.error); + shape.error = redactTextForSupport(params.error); } return shape; } diff --git a/src/logging/diagnostic-support-redaction.ts b/src/logging/diagnostic-support-redaction.ts index 87b0623557e..5d4ce422c3d 100644 --- a/src/logging/diagnostic-support-redaction.ts +++ b/src/logging/diagnostic-support-redaction.ts @@ -99,7 +99,7 @@ export function redactPathForSupport(file: string, options: SupportRedactionCont return `${label}${next.slice(prefix.length)}`; } } - return redactSensitiveText(next); + return redactSensitiveTextForSupport(next); } function redactKnownPathPrefixesForSupport( @@ -114,13 +114,17 @@ function redactKnownPathPrefixesForSupport( } export function redactTextForSupport(value: string): string { - let redacted = redactSensitiveText(value); + let redacted = redactSensitiveTextForSupport(value); redacted = redactUrlSecretsForSupport(redacted); redacted = redactServiceIdentifiersForSupport(redacted); redacted = redactContactIdentifiersForSupport(redacted); return redactLongIdentifiersForSupport(redacted); } +function redactSensitiveTextForSupport(value: string): string { + return redactSensitiveText(value, { mode: "tools" }); +} + function redactUrlSecretsForSupport(value: string): string { return value .replace(URL_USERINFO_RE, "$1:@") diff --git a/src/logging/diagnostic.test.ts b/src/logging/diagnostic.test.ts index 0b9b2b17a4e..66000e04eaa 100644 --- a/src/logging/diagnostic.test.ts +++ b/src/logging/diagnostic.test.ts @@ -146,9 +146,12 @@ describe("stuck session diagnostics threshold", () => { expect(getDiagnosticStabilitySnapshot({ limit: 10 }).events).toContainEqual( expect.objectContaining({ type: "session.state", - sessionId: "s1", + outcome: "processing", }), ); + const [event] = getDiagnosticStabilitySnapshot({ limit: 10 }).events; + expect(event).not.toHaveProperty("sessionId"); + expect(event).not.toHaveProperty("sessionKey"); resetDiagnosticStateForTest(); emitDiagnosticEvent({ type: "webhook.received", channel: "telegram" });