fix: harden diagnostics support export privacy

This commit is contained in:
Gustavo Madeira Santana
2026-04-22 16:09:58 -04:00
parent b8646c9ec2
commit 02b220388f
9 changed files with 33 additions and 42 deletions

View File

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

View File

@@ -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)}` : "",

View File

@@ -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<string, DiagnosticStabilityEventRecord> {
const latest = new Map<string, DiagnosticStabilityEventRecord>();
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({

View File

@@ -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", () => {

View File

@@ -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;

View File

@@ -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<string, { botToken?: string; allowFrom?: { redacted?: boolean } }>;
};
};
logging?: {
redactSensitive?: string;
};
agents?: Array<{ name?: string; instructions?: string }>;
};
expect(sanitizedConfig.gateway).toMatchObject({
@@ -329,6 +335,9 @@ describe("diagnostic support export", () => {
token: "<redacted>",
},
});
expect(sanitizedConfig.logging).toMatchObject({
redactSensitive: "off",
});
expect(Object.keys(sanitizedConfig.channels?.telegram?.accounts ?? {})).toEqual([
"<redacted-account-1>",
]);

View File

@@ -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 : "<redacted>";
}
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;
}

View File

@@ -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<redacted>:<redacted>@")

View File

@@ -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" });