diff --git a/CHANGELOG.md b/CHANGELOG.md index 65810f748b3..818daec3ac6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,7 +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. +- Gateway/diagnostics: enable payload-free stability recording by default and 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/docs/cli/gateway.md b/docs/cli/gateway.md index 1519a3e57fa..9799c23cbe5 100644 --- a/docs/cli/gateway.md +++ b/docs/cli/gateway.md @@ -135,7 +135,7 @@ Options: Notes: - The recorder is active by default. Set `diagnostics.enabled: false` only when you need to disable Gateway diagnostic heartbeat collection. -- Records keep operational metadata: event names, counts, byte sizes, memory readings, queue/session state, channel/plugin names, and session ids. They do not keep chat text, webhook bodies, tool outputs, raw request or response bodies, tokens, cookies, or secret values. +- Records keep operational metadata: event names, counts, byte sizes, memory readings, queue/session state, channel/plugin names, and redacted session summaries. They do not keep chat text, webhook bodies, tool outputs, raw request or response bodies, tokens, cookies, secret values, hostnames, or raw session ids. - On fatal Gateway exits, shutdown timeouts, and restart startup failures, OpenClaw writes the same diagnostic snapshot to `~/.openclaw/logs/stability/openclaw-stability-*.json` when the recorder has events. Inspect the newest bundle with `openclaw gateway stability --bundle latest`; `--limit`, `--type`, and `--since-seq` also apply to bundle output. ### `gateway diagnostics export` diff --git a/src/logging/diagnostic-stability-bundle.test.ts b/src/logging/diagnostic-stability-bundle.test.ts index 0ecf2386227..e044f268297 100644 --- a/src/logging/diagnostic-stability-bundle.test.ts +++ b/src/logging/diagnostic-stability-bundle.test.ts @@ -81,6 +81,9 @@ describe("diagnostic stability bundles", () => { name: "Error", code: "ERR_TEST", }, + host: { + hostname: "", + }, snapshot: { count: 2, }, @@ -94,6 +97,7 @@ describe("diagnostic stability bundles", () => { expect(raw).not.toContain("chat-secret"); expect(raw).not.toContain("message body"); expect(raw).not.toContain("contains secret message"); + expect(raw).not.toContain(os.hostname()); }); it("skips empty recorder snapshots by default", () => { diff --git a/src/logging/diagnostic-stability-bundle.ts b/src/logging/diagnostic-stability-bundle.ts index c55ab24dc30..d25a5e9f792 100644 --- a/src/logging/diagnostic-stability-bundle.ts +++ b/src/logging/diagnostic-stability-bundle.ts @@ -1,5 +1,4 @@ import fs from "node:fs"; -import os from "node:os"; import path from "node:path"; import process from "node:process"; import { resolveStateDir } from "../config/paths.js"; @@ -17,6 +16,7 @@ export const DEFAULT_DIAGNOSTIC_STABILITY_BUNDLE_RETENTION = 20; const SAFE_REASON_CODE = /^[A-Za-z0-9_.:-]{1,120}$/u; const BUNDLE_PREFIX = "openclaw-stability-"; const BUNDLE_SUFFIX = ".json"; +const REDACTED_HOSTNAME = ""; export type DiagnosticStabilityBundle = { version: typeof DIAGNOSTIC_STABILITY_BUNDLE_VERSION; @@ -294,7 +294,7 @@ export function writeDiagnosticStabilityBundleSync( uptimeMs: Math.round(process.uptime() * 1000), }, host: { - hostname: os.hostname(), + hostname: REDACTED_HOSTNAME, }, ...(error ? { error } : {}), snapshot, diff --git a/src/logging/diagnostic-support-export.test.ts b/src/logging/diagnostic-support-export.test.ts index 683bb19081b..8136884ce62 100644 --- a/src/logging/diagnostic-support-export.test.ts +++ b/src/logging/diagnostic-support-export.test.ts @@ -48,6 +48,12 @@ describe("diagnostic support export", () => { it("writes a shareable zip without raw chats, webhook bodies, or secrets", async () => { const fakeToken = "sk-test-support-export-secret-token-1234567890"; + const fakeAwsKey = ["AKIA", "IOSFODNN7EXAMPLE"].join(""); + const fakeJwt = [ + "eyJhbGciOiJIUzI1NiIs", + "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4i", + "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + ].join("."); const privateChat = "private user said diagnose my bank transfer"; const webhookBody = "raw webhook body with message contents"; const credentialUrl = @@ -122,7 +128,7 @@ describe("diagnostic support export", () => { subsystem: "gateway", component: "gateway/server", channel: "telegram", - msg: `gateway websocket listening at ${credentialUrl}`, + msg: `gateway websocket listening at ${credentialUrl} Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ== ${fakeAwsKey} ${fakeJwt} Cookie: sid=secret`, hostname: "support-host", message: privateChat, body: webhookBody, @@ -237,9 +243,15 @@ describe("diagnostic support export", () => { expect(combined).not.toContain("short-token"); expect(combined).not.toContain(tempDir); expect(combined).not.toContain("cron/jobs.json"); + expect(combined).not.toContain(os.hostname()); + expect(combined).not.toContain("QWxhZGRpbjpvcGVuIHNlc2FtZQ=="); + expect(combined).not.toContain("sid=secret"); + expect(combined).not.toContain(fakeAwsKey); + expect(combined).not.toContain(fakeJwt); expect(combined).toContain("payload.large"); expect(combined).toContain("gateway.http.json"); expect(combined).toContain("$OPENCLAW_STATE_DIR"); + expect(combined).toContain(""); expect(combined).toContain("gateway-status.json"); expect(combined).toContain("gateway-health.json"); expect(combined).toContain("Attach this zip to the bug report"); @@ -252,6 +264,10 @@ describe("diagnostic support export", () => { expect(sanitizedLogs).toContain( "wss://:@gateway.example/ws?token=", ); + expect(sanitizedLogs).toContain("Basic "); + expect(sanitizedLogs).toContain("Cookie: "); + expect(sanitizedLogs).toContain(""); + expect(sanitizedLogs).toContain(""); expect(sanitizedLogs).toContain('"module":"matrix-auto-reply"'); expect(sanitizedLogs).toContain('"subsystem":"gateway/channels/matrix"'); expect(sanitizedLogs).toContain('"logger":"gateway-runtime"'); @@ -350,11 +366,21 @@ describe("diagnostic support export", () => { }); it("redacts support text identifiers without hiding useful URL hosts", () => { + const fakeAwsKey = ["ASIA", "IOSFODNN7EXAMPLE"].join(""); + const fakeJwt = [ + "eyJhbGciOiJIUzI1NiIs", + "eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4i", + "SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c", + ].join("."); const cases = [ [ "connect wss://support-user:support-password@gateway.example/ws?token=short-token&ok=1", "connect wss://:@gateway.example/ws?token=", ], + ["auth Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==", "auth Basic "], + ["Cookie: sid=secret; theme=light", "Cookie: "], + [`aws ${fakeAwsKey}`, "aws "], + [`jwt ${fakeJwt}`, "jwt "], ["email alice@example.com", "email "], ["matrix @support-user:matrix.example.com", "matrix "], ["room !support-room:matrix.example.com", "room "], diff --git a/src/logging/diagnostic-support-redaction.ts b/src/logging/diagnostic-support-redaction.ts index 5d4ce422c3d..d4a9cb6a494 100644 --- a/src/logging/diagnostic-support-redaction.ts +++ b/src/logging/diagnostic-support-redaction.ts @@ -12,6 +12,10 @@ const CONFIG_PRIVATE_FIELD_RE = /(?:allow[-_]?from|allow[-_]?to|deny[-_]?from|deny[-_]?to|blocked[-_]?from|blocked[-_]?users|owner[-_]?id|sender[-_]?id|recipient[-_]?id)/iu; const SENSITIVE_COMMAND_ARG_RE = /^--(?:api[-_]?key|hook[-_]?token|password|password-file|passwd|secret|token)(?:=.*)?$/iu; +const BASIC_AUTH_RE = /\bBasic\s+[A-Za-z0-9+/]+={0,2}/giu; +const COOKIE_HEADER_RE = /\b(Cookie|Set-Cookie)\s*:\s*[^\r\n]+/giu; +const AWS_ACCESS_KEY_ID_RE = /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/gu; +const JWT_RE = /\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/gu; const URL_USERINFO_RE = /\b([a-z][a-z0-9+.-]*:\/\/)([^/@\s:?#]+):([^/@\s?#]+)@/giu; const SENSITIVE_URL_PARAM_RE = /([?&](?:api[-_]?key|access[-_]?token|auth[-_]?token|hook[-_]?token|password|passwd|refresh[-_]?token|secret|token)=)[^&#\s]+/giu; @@ -115,6 +119,7 @@ function redactKnownPathPrefixesForSupport( export function redactTextForSupport(value: string): string { let redacted = redactSensitiveTextForSupport(value); + redacted = redactCommonCredentialTextForSupport(redacted); redacted = redactUrlSecretsForSupport(redacted); redacted = redactServiceIdentifiersForSupport(redacted); redacted = redactContactIdentifiersForSupport(redacted); @@ -125,6 +130,14 @@ function redactSensitiveTextForSupport(value: string): string { return redactSensitiveText(value, { mode: "tools" }); } +function redactCommonCredentialTextForSupport(value: string): string { + return value + .replace(BASIC_AUTH_RE, "Basic ") + .replace(COOKIE_HEADER_RE, "$1: ") + .replace(AWS_ACCESS_KEY_ID_RE, "") + .replace(JWT_RE, ""); +} + function redactUrlSecretsForSupport(value: string): string { return value .replace(URL_USERINFO_RE, "$1:@")