diff --git a/src/logging/diagnostic-stability-bundle.test.ts b/src/logging/diagnostic-stability-bundle.test.ts index 666076e7f73..1453cba001a 100644 --- a/src/logging/diagnostic-stability-bundle.test.ts +++ b/src/logging/diagnostic-stability-bundle.test.ts @@ -45,6 +45,32 @@ describe("diagnostic stability bundles", () => { return JSON.parse(fs.readFileSync(file, "utf8")) as DiagnosticStabilityBundle; } + function createImportedBundle(): Record { + return { + version: 1, + generatedAt: "2026-04-22T12:00:00.000Z", + reason: "gateway.restart_startup_failed", + process: { + pid: 123, + platform: "darwin", + arch: "arm64", + node: "24.14.1", + uptimeMs: 1000, + }, + host: { + hostname: "", + }, + snapshot: { + generatedAt: "2026-04-22T12:00:00.000Z", + capacity: 1000, + count: 1, + dropped: 0, + events: [{ seq: 1, ts: 1, type: "webhook.received" }], + summary: { byType: { "webhook.received": 1 } }, + }, + }; + } + it("writes a payload-free bundle with safe failure metadata", () => { startDiagnosticStabilityRecorder(); emitDiagnosticEvent({ @@ -207,6 +233,85 @@ describe("diagnostic stability bundles", () => { ); }); + it("sanitizes imported bundles before returning them", () => { + const file = path.join(tempDir, "imported.json"); + const bundle = createImportedBundle(); + Object.assign(bundle, { + reason: "private reason token=secret", + privateTopLevel: "top-level-secret", + error: { + name: "private error name", + code: "ERR_TEST", + message: "error-message-secret", + }, + }); + Object.assign(bundle.process as Record, { + command: "process-command-secret", + }); + Object.assign(bundle.host as Record, { + hostname: "private-hostname", + fqdn: "host-extra-secret", + }); + const snapshot = bundle.snapshot as Record; + Object.assign(snapshot, { + privateSnapshot: "snapshot-secret", + events: [ + { + seq: 1, + ts: 1, + type: "webhook.error", + channel: "telegram", + reason: "private event reason", + chatId: "chat-id-secret", + error: "event-error-secret", + }, + ], + summary: { + byType: { + "webhook.error": 1, + "private summary type": 1, + }, + privateSummary: "summary-secret", + }, + }); + fs.writeFileSync(file, `${JSON.stringify(bundle, null, 2)}\n`, "utf8"); + + const result = readDiagnosticStabilityBundleFileSync(file); + + expect(result.status).toBe("found"); + if (result.status !== "found") { + return; + } + expect(result.bundle.reason).toBe("unknown"); + expect(result.bundle.host).toEqual({ hostname: "" }); + expect(result.bundle.error).toEqual({ code: "ERR_TEST" }); + expect(result.bundle.snapshot.events[0]).toEqual({ + seq: 1, + ts: 1, + type: "webhook.error", + channel: "telegram", + }); + expect(result.bundle.snapshot.summary.byType).toEqual({ "webhook.error": 1 }); + const sanitized = JSON.stringify(result.bundle); + for (const secret of [ + "private reason", + "top-level-secret", + "private error name", + "error-message-secret", + "process-command-secret", + "private-hostname", + "host-extra-secret", + "snapshot-secret", + "private event reason", + "chat-id-secret", + "event-error-secret", + "private summary type", + "summary-secret", + ]) { + expect(sanitized).not.toContain(secret); + } + }); + it("rejects malformed bundle files", () => { const file = path.join(tempDir, "invalid.json"); fs.writeFileSync(file, "{}\n", "utf8"); @@ -233,36 +338,15 @@ describe("diagnostic stability bundles", () => { }); it("rejects malformed bundle snapshots before returning them", () => { - const baseBundle = { - version: 1, - generatedAt: "2026-04-22T12:00:00.000Z", - reason: "gateway.restart_startup_failed", - process: { - pid: 123, - platform: "darwin", - arch: "arm64", - node: "24.14.1", - uptimeMs: 1000, - }, - host: { - hostname: "", - }, - snapshot: { - generatedAt: "2026-04-22T12:00:00.000Z", - capacity: 1000, - count: 1, - dropped: 0, - events: [{ seq: 1, ts: 1, type: "webhook.received" }], - summary: { byType: { "webhook.received": 1 } }, - }, - }; + const baseBundle = createImportedBundle(); + const baseSnapshot = baseBundle.snapshot as Record; const cases = [ { name: "malformed-event", bundle: { ...baseBundle, snapshot: { - ...baseBundle.snapshot, + ...baseSnapshot, events: [{ type: "webhook.received", ts: 1 }], }, }, @@ -273,7 +357,7 @@ describe("diagnostic stability bundles", () => { bundle: { ...baseBundle, snapshot: { - ...baseBundle.snapshot, + ...baseSnapshot, events: [{ seq: 1, ts: 9e15, type: "webhook.received" }], }, }, @@ -284,7 +368,7 @@ describe("diagnostic stability bundles", () => { bundle: { ...baseBundle, snapshot: { - ...baseBundle.snapshot, + ...baseSnapshot, summary: null, }, }, diff --git a/src/logging/diagnostic-stability-bundle.ts b/src/logging/diagnostic-stability-bundle.ts index 2d30f5f4a48..664db1fdde5 100644 --- a/src/logging/diagnostic-stability-bundle.ts +++ b/src/logging/diagnostic-stability-bundle.ts @@ -191,26 +191,260 @@ function readString(value: unknown, label: string): string { return value; } +function readTimestampString(value: unknown, label: string): string { + const timestamp = readString(value, label); + if (Number.isNaN(new Date(timestamp).getTime())) { + throw new Error(`Invalid stability bundle: ${label} must be a valid timestamp`); + } + return timestamp; +} + +function readCodeString(value: unknown, label: string): string { + const code = readString(value, label); + if (!SAFE_REASON_CODE.test(code)) { + throw new Error(`Invalid stability bundle: ${label} must be a safe diagnostic code`); + } + return code; +} + +function readOptionalCodeString(value: unknown, label: string): string | undefined { + if (value === undefined) { + return undefined; + } + const code = readString(value, label); + return SAFE_REASON_CODE.test(code) ? code : undefined; +} + +function assignOptionalNumber(target: object, key: string, value: unknown, label: string): void { + const parsed = readOptionalNumber(value, label); + if (parsed !== undefined) { + (target as Record)[key] = parsed; + } +} + +function assignOptionalCodeString( + target: object, + key: string, + value: unknown, + label: string, +): void { + const parsed = readOptionalCodeString(value, label); + if (parsed !== undefined) { + (target as Record)[key] = parsed; + } +} + +function readMemoryUsage( + value: unknown, + label: string, +): NonNullable["latest"] { + const memory = readObject(value, label); + return { + rssBytes: readNumber(memory.rssBytes, `${label}.rssBytes`), + heapTotalBytes: readNumber(memory.heapTotalBytes, `${label}.heapTotalBytes`), + heapUsedBytes: readNumber(memory.heapUsedBytes, `${label}.heapUsedBytes`), + externalBytes: readNumber(memory.externalBytes, `${label}.externalBytes`), + arrayBuffersBytes: readNumber(memory.arrayBuffersBytes, `${label}.arrayBuffersBytes`), + }; +} + +function readNumberMap(value: unknown, label: string): Record { + const source = readObject(value, label); + const result: Record = {}; + for (const [key, entry] of Object.entries(source)) { + if (!SAFE_REASON_CODE.test(key)) { + continue; + } + result[key] = readNumber(entry, `${label}.${key}`); + } + return result; +} + +function readOptionalMemorySummary( + value: unknown, +): DiagnosticStabilitySnapshot["summary"]["memory"] | undefined { + if (value === undefined) { + return undefined; + } + const memory = readObject(value, "snapshot.summary.memory"); + const latest = + memory.latest === undefined + ? undefined + : readMemoryUsage(memory.latest, "snapshot.summary.memory.latest"); + return { + ...(latest ? { latest } : {}), + ...(memory.maxRssBytes !== undefined + ? { maxRssBytes: readNumber(memory.maxRssBytes, "snapshot.summary.memory.maxRssBytes") } + : {}), + ...(memory.maxHeapUsedBytes !== undefined + ? { + maxHeapUsedBytes: readNumber( + memory.maxHeapUsedBytes, + "snapshot.summary.memory.maxHeapUsedBytes", + ), + } + : {}), + pressureCount: readNumber(memory.pressureCount, "snapshot.summary.memory.pressureCount"), + }; +} + +function readOptionalPayloadLargeSummary( + value: unknown, +): DiagnosticStabilitySnapshot["summary"]["payloadLarge"] | undefined { + if (value === undefined) { + return undefined; + } + const payloadLarge = readObject(value, "snapshot.summary.payloadLarge"); + return { + count: readNumber(payloadLarge.count, "snapshot.summary.payloadLarge.count"), + rejected: readNumber(payloadLarge.rejected, "snapshot.summary.payloadLarge.rejected"), + truncated: readNumber(payloadLarge.truncated, "snapshot.summary.payloadLarge.truncated"), + chunked: readNumber(payloadLarge.chunked, "snapshot.summary.payloadLarge.chunked"), + bySurface: readNumberMap(payloadLarge.bySurface, "snapshot.summary.payloadLarge.bySurface"), + }; +} + +function readStabilityEventRecord( + value: unknown, + label: string, +): DiagnosticStabilitySnapshot["events"][number] { + const record = readObject(value, label); + const sanitized: DiagnosticStabilitySnapshot["events"][number] = { + seq: readNumber(record.seq, `${label}.seq`), + ts: readTimestampMs(record.ts, `${label}.ts`), + type: readCodeString( + record.type, + `${label}.type`, + ) as DiagnosticStabilitySnapshot["events"][number]["type"], + }; + + assignOptionalCodeString(sanitized, "channel", record.channel, `${label}.channel`); + assignOptionalCodeString(sanitized, "pluginId", record.pluginId, `${label}.pluginId`); + assignOptionalCodeString(sanitized, "source", record.source, `${label}.source`); + assignOptionalCodeString(sanitized, "surface", record.surface, `${label}.surface`); + assignOptionalCodeString(sanitized, "action", record.action, `${label}.action`); + assignOptionalCodeString(sanitized, "reason", record.reason, `${label}.reason`); + assignOptionalCodeString(sanitized, "outcome", record.outcome, `${label}.outcome`); + assignOptionalCodeString(sanitized, "level", record.level, `${label}.level`); + assignOptionalCodeString(sanitized, "detector", record.detector, `${label}.detector`); + assignOptionalCodeString(sanitized, "toolName", record.toolName, `${label}.toolName`); + assignOptionalCodeString( + sanitized, + "pairedToolName", + record.pairedToolName, + `${label}.pairedToolName`, + ); + assignOptionalCodeString(sanitized, "provider", record.provider, `${label}.provider`); + assignOptionalCodeString(sanitized, "model", record.model, `${label}.model`); + + assignOptionalNumber(sanitized, "durationMs", record.durationMs, `${label}.durationMs`); + assignOptionalNumber(sanitized, "costUsd", record.costUsd, `${label}.costUsd`); + assignOptionalNumber(sanitized, "count", record.count, `${label}.count`); + assignOptionalNumber(sanitized, "bytes", record.bytes, `${label}.bytes`); + assignOptionalNumber(sanitized, "limitBytes", record.limitBytes, `${label}.limitBytes`); + assignOptionalNumber( + sanitized, + "thresholdBytes", + record.thresholdBytes, + `${label}.thresholdBytes`, + ); + assignOptionalNumber( + sanitized, + "rssGrowthBytes", + record.rssGrowthBytes, + `${label}.rssGrowthBytes`, + ); + assignOptionalNumber(sanitized, "windowMs", record.windowMs, `${label}.windowMs`); + assignOptionalNumber(sanitized, "ageMs", record.ageMs, `${label}.ageMs`); + assignOptionalNumber(sanitized, "queueDepth", record.queueDepth, `${label}.queueDepth`); + assignOptionalNumber(sanitized, "queueSize", record.queueSize, `${label}.queueSize`); + assignOptionalNumber(sanitized, "waitMs", record.waitMs, `${label}.waitMs`); + assignOptionalNumber(sanitized, "active", record.active, `${label}.active`); + assignOptionalNumber(sanitized, "waiting", record.waiting, `${label}.waiting`); + assignOptionalNumber(sanitized, "queued", record.queued, `${label}.queued`); + + if (record.webhooks !== undefined) { + const webhooks = readObject(record.webhooks, `${label}.webhooks`); + sanitized.webhooks = { + received: readNumber(webhooks.received, `${label}.webhooks.received`), + processed: readNumber(webhooks.processed, `${label}.webhooks.processed`), + errors: readNumber(webhooks.errors, `${label}.webhooks.errors`), + }; + } + if (record.memory !== undefined) { + sanitized.memory = readMemoryUsage(record.memory, `${label}.memory`); + } + if (record.usage !== undefined) { + const usage = readObject(record.usage, `${label}.usage`); + sanitized.usage = { + ...(usage.input !== undefined + ? { input: readNumber(usage.input, `${label}.usage.input`) } + : {}), + ...(usage.output !== undefined + ? { output: readNumber(usage.output, `${label}.usage.output`) } + : {}), + ...(usage.cacheRead !== undefined + ? { cacheRead: readNumber(usage.cacheRead, `${label}.usage.cacheRead`) } + : {}), + ...(usage.cacheWrite !== undefined + ? { cacheWrite: readNumber(usage.cacheWrite, `${label}.usage.cacheWrite`) } + : {}), + ...(usage.promptTokens !== undefined + ? { promptTokens: readNumber(usage.promptTokens, `${label}.usage.promptTokens`) } + : {}), + ...(usage.total !== undefined + ? { total: readNumber(usage.total, `${label}.usage.total`) } + : {}), + }; + } + if (record.context !== undefined) { + const context = readObject(record.context, `${label}.context`); + sanitized.context = { + ...(context.limit !== undefined + ? { limit: readNumber(context.limit, `${label}.context.limit`) } + : {}), + ...(context.used !== undefined + ? { used: readNumber(context.used, `${label}.context.used`) } + : {}), + }; + } + + return sanitized; +} + function readStabilitySnapshot(value: unknown): DiagnosticStabilitySnapshot { const snapshot = readObject(value, "snapshot"); - readString(snapshot.generatedAt, "snapshot.generatedAt"); - readNumber(snapshot.capacity, "snapshot.capacity"); - readNumber(snapshot.count, "snapshot.count"); - readNumber(snapshot.dropped, "snapshot.dropped"); - readOptionalNumber(snapshot.firstSeq, "snapshot.firstSeq"); - readOptionalNumber(snapshot.lastSeq, "snapshot.lastSeq"); + const generatedAt = readTimestampString(snapshot.generatedAt, "snapshot.generatedAt"); + const capacity = readNumber(snapshot.capacity, "snapshot.capacity"); + const count = readNumber(snapshot.count, "snapshot.count"); + const dropped = readNumber(snapshot.dropped, "snapshot.dropped"); + const firstSeq = readOptionalNumber(snapshot.firstSeq, "snapshot.firstSeq"); + const lastSeq = readOptionalNumber(snapshot.lastSeq, "snapshot.lastSeq"); if (!Array.isArray(snapshot.events)) { throw new Error("Invalid stability bundle: snapshot.events must be an array"); } - for (const [index, event] of snapshot.events.entries()) { - const record = readObject(event, `snapshot.events[${index}]`); - readNumber(record.seq, `snapshot.events[${index}].seq`); - readTimestampMs(record.ts, `snapshot.events[${index}].ts`); - readString(record.type, `snapshot.events[${index}].type`); - } + const events = snapshot.events.map((event, index) => + readStabilityEventRecord(event, `snapshot.events[${index}]`), + ); const summary = readObject(snapshot.summary, "snapshot.summary"); - readObject(summary.byType, "snapshot.summary.byType"); - return snapshot as DiagnosticStabilitySnapshot; + return { + generatedAt, + capacity, + count, + dropped, + ...(firstSeq !== undefined ? { firstSeq } : {}), + ...(lastSeq !== undefined ? { lastSeq } : {}), + events, + summary: { + byType: readNumberMap(summary.byType, "snapshot.summary.byType"), + ...(summary.memory !== undefined + ? { memory: readOptionalMemorySummary(summary.memory) } + : {}), + ...(summary.payloadLarge !== undefined + ? { payloadLarge: readOptionalPayloadLargeSummary(summary.payloadLarge) } + : {}), + }, + }; } function parseDiagnosticStabilityBundle(value: unknown): DiagnosticStabilityBundle { @@ -218,13 +452,26 @@ function parseDiagnosticStabilityBundle(value: unknown): DiagnosticStabilityBund if (bundle.version !== DIAGNOSTIC_STABILITY_BUNDLE_VERSION) { throw new Error(`Unsupported stability bundle version: ${String(bundle.version)}`); } - if (typeof bundle.generatedAt !== "string" || typeof bundle.reason !== "string") { - throw new Error("Invalid stability bundle: missing generatedAt or reason"); - } - readObject(bundle.process, "process"); + const processInfo = readObject(bundle.process, "process"); readObject(bundle.host, "host"); - readStabilitySnapshot(bundle.snapshot); - return bundle as DiagnosticStabilityBundle; + const error = bundle.error === undefined ? undefined : readSafeErrorMetadata(bundle.error); + return { + version: DIAGNOSTIC_STABILITY_BUNDLE_VERSION, + generatedAt: readTimestampString(bundle.generatedAt, "generatedAt"), + reason: normalizeReason(readString(bundle.reason, "reason")), + process: { + pid: readNumber(processInfo.pid, "process.pid"), + platform: readCodeString(processInfo.platform, "process.platform") as NodeJS.Platform, + arch: readCodeString(processInfo.arch, "process.arch"), + node: readCodeString(processInfo.node, "process.node"), + uptimeMs: readNumber(processInfo.uptimeMs, "process.uptimeMs"), + }, + host: { + hostname: REDACTED_HOSTNAME, + }, + ...(error ? { error } : {}), + snapshot: readStabilitySnapshot(bundle.snapshot), + }; } export function listDiagnosticStabilityBundleFilesSync( diff --git a/src/logging/diagnostic-support-export.test.ts b/src/logging/diagnostic-support-export.test.ts index 01219617409..552593e9223 100644 --- a/src/logging/diagnostic-support-export.test.ts +++ b/src/logging/diagnostic-support-export.test.ts @@ -392,6 +392,97 @@ describe("diagnostic support export", () => { expect(sanitizedConfig.agents?.[0]?.instructions).toBe(""); }); + it("sanitizes imported stability bundles before adding them to support exports", async () => { + const bundlePath = path.join(tempDir, "imported-stability.json"); + const outputPath = path.join(tempDir, "support-imported-stability.zip"); + const importedBundle = { + version: 1, + generatedAt: "2026-04-22T12:00:00.000Z", + reason: "private reason token=secret", + process: { pid: 123, platform: "darwin", arch: "arm64", node: "24.14.1", uptimeMs: 1000 }, + host: { hostname: "private-hostname" }, + error: { name: "private error name", code: "ERR_TEST" }, + snapshot: { + generatedAt: "2026-04-22T12:00:00.000Z", + capacity: 1000, + count: 1, + dropped: 0, + events: [ + { + seq: 1, + ts: 1, + type: "webhook.error", + channel: "telegram", + reason: "private event reason", + error: "event-error-secret", + }, + ], + summary: { + byType: { + "webhook.error": 1, + "private summary type": 1, + }, + privateSummary: "summary-secret", + }, + }, + }; + fs.writeFileSync(bundlePath, `${JSON.stringify(importedBundle, null, 2)}\n`, "utf8"); + + await writeDiagnosticSupportExport({ + env: { + ...process.env, + HOME: tempDir, + OPENCLAW_STATE_DIR: tempDir, + }, + stateDir: tempDir, + outputPath, + stabilityBundle: bundlePath, + now: new Date("2026-04-22T12:00:01.000Z"), + readLogTail: async () => ({ + file: path.join(tempDir, "logs", "openclaw.log"), + cursor: 0, + size: 0, + truncated: false, + reset: false, + lines: [], + }), + }); + + const entries = await readZipTextEntries(outputPath); + const stability = JSON.parse(entries["stability/latest.json"] ?? "{}") as { + reason?: string; + host?: { hostname?: string }; + error?: { code?: string; name?: string }; + snapshot?: { + events?: Array>; + summary?: { byType?: Record }; + }; + }; + expect(stability.reason).toBe("unknown"); + expect(stability.host).toEqual({ hostname: "" }); + expect(stability.error).toEqual({ code: "ERR_TEST" }); + expect(stability.snapshot?.events?.[0]).toEqual({ + seq: 1, + ts: 1, + type: "webhook.error", + channel: "telegram", + }); + expect(stability.snapshot?.summary?.byType).toEqual({ "webhook.error": 1 }); + + const combined = Object.values(entries).join("\n"); + for (const secret of [ + "private reason", + "private-hostname", + "private error name", + "private event reason", + "event-error-secret", + "private summary type", + "summary-secret", + ]) { + expect(combined).not.toContain(secret); + } + }); + it("redacts numeric private fields in support snapshots and config", () => { const redaction = { env: {