diff --git a/src/logging/diagnostic-stability-bundle.test.ts b/src/logging/diagnostic-stability-bundle.test.ts index f5ac2d52a0e..4b68c5f0eea 100644 --- a/src/logging/diagnostic-stability-bundle.test.ts +++ b/src/logging/diagnostic-stability-bundle.test.ts @@ -9,6 +9,7 @@ import { readDiagnosticStabilityBundleFileSync, readLatestDiagnosticStabilityBundleSync, resetDiagnosticStabilityBundleForTest, + writeDiagnosticStabilityBundleForFailureSync, writeDiagnosticStabilityBundleSync, type DiagnosticStabilityBundle, } from "./diagnostic-stability-bundle.js"; @@ -110,6 +111,33 @@ describe("diagnostic stability bundles", () => { expect(fs.existsSync(path.join(tempDir, "logs", "stability"))).toBe(false); }); + it("writes failure bundles even when the recorder snapshot is empty", () => { + const result = writeDiagnosticStabilityBundleForFailureSync( + "gateway.restart_startup_failed", + Object.assign(new Error("raw startup config payload"), { code: "ERR_CONFIG_PARSE" }), + { + stateDir: tempDir, + now: new Date("2026-04-22T12:00:00.000Z"), + }, + ); + + expect(result.status).toBe("written"); + const bundle = readBundle(result.status === "written" ? result.path : ""); + const raw = fs.readFileSync(result.status === "written" ? result.path : "", "utf8"); + expect(bundle).toMatchObject({ + reason: "gateway.restart_startup_failed", + error: { + name: "Error", + code: "ERR_CONFIG_PARSE", + }, + snapshot: { + count: 0, + events: [], + }, + }); + expect(raw).not.toContain("raw startup config payload"); + }); + it("registers a fatal hook only while installed", () => { startDiagnosticStabilityRecorder(); emitDiagnosticEvent({ type: "webhook.received", channel: "telegram" }); diff --git a/src/logging/diagnostic-stability-bundle.ts b/src/logging/diagnostic-stability-bundle.ts index d91bedadc59..a020d5cd68f 100644 --- a/src/logging/diagnostic-stability-bundle.ts +++ b/src/logging/diagnostic-stability-bundle.ts @@ -365,7 +365,7 @@ export function writeDiagnosticStabilityBundleForFailureSync( ...options, reason, error, - includeEmpty: false, + includeEmpty: true, }); if (result.status === "written") { return { diff --git a/src/logging/diagnostic-support-export.test.ts b/src/logging/diagnostic-support-export.test.ts index a4717d5d1cc..f5e9d70bd1a 100644 --- a/src/logging/diagnostic-support-export.test.ts +++ b/src/logging/diagnostic-support-export.test.ts @@ -2,7 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import JSZip from "jszip"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { emitDiagnosticEvent, resetDiagnosticEventsForTest } from "../infra/diagnostic-events.js"; import { resetDiagnosticStabilityBundleForTest, @@ -549,4 +549,51 @@ describe("diagnostic support export", () => { expect(combined).toContain("log-tail-read-failed"); expect(combined).toContain("sanitized log tail unavailable"); }); + + it("keeps writing when config stat fails", async () => { + const fakeToken = "sk-test-config-stat-secret-token-1234567890"; + const configPath = path.join(tempDir, "openclaw.json"); + const outputPath = path.join(tempDir, "support-failed-config-stat.zip"); + fs.writeFileSync(configPath, "{}\n", "utf8"); + + const originalStatSync = fs.statSync.bind(fs); + const statSpy = vi.spyOn(fs, "statSync").mockImplementation((target, options) => { + if (target === configPath) { + throw new Error(`config stat failed with token ${fakeToken}`); + } + return originalStatSync(target, options as never); + }); + + try { + await writeDiagnosticSupportExport({ + env: { + ...process.env, + HOME: tempDir, + OPENCLAW_CONFIG_PATH: configPath, + OPENCLAW_STATE_DIR: tempDir, + }, + stateDir: tempDir, + outputPath, + now: new Date("2026-04-22T12:00:03.000Z"), + readLogTail: async () => ({ + file: path.join(tempDir, "logs", "openclaw.log"), + cursor: 0, + size: 0, + truncated: false, + reset: false, + lines: [], + }), + }); + } finally { + statSpy.mockRestore(); + } + + const entries = await readZipTextEntries(outputPath); + const combined = Object.values(entries).join("\n"); + expect(Object.keys(entries).toSorted()).toContain("config/shape.json"); + expect(combined).not.toContain(fakeToken); + expect(combined).toContain('"parseOk": false'); + expect(combined).toContain("config stat failed with token"); + expect(combined).toContain("Attach this zip to the bug report"); + }); }); diff --git a/src/logging/diagnostic-support-export.ts b/src/logging/diagnostic-support-export.ts index eae999aeaf1..b401385bc7f 100644 --- a/src/logging/diagnostic-support-export.ts +++ b/src/logging/diagnostic-support-export.ts @@ -284,19 +284,22 @@ function configShapeReadFailure(params: { return shape; } +function isMissingPathError(error: unknown): boolean { + if (!error || typeof error !== "object" || !("code" in error)) { + return false; + } + return error.code === "ENOENT" || error.code === "ENOTDIR"; +} + function readConfigExport(options: { configPath: string; env: NodeJS.ProcessEnv; stateDir: string; }): ConfigExport { const redactedConfigPath = redactPathForSupport(options.configPath, options); - const stat = fs.existsSync(options.configPath) ? fs.statSync(options.configPath) : null; - if (!stat) { - return { - shape: configShapeReadFailure({ configPath: redactedConfigPath, redaction: options }), - }; - } + let stat: fs.Stats | undefined; try { + stat = fs.statSync(options.configPath); const parsed = parseConfigJson5(fs.readFileSync(options.configPath, "utf8")); if (!parsed.ok) { return { @@ -313,12 +316,13 @@ function readConfigExport(options: { sanitized: sanitizeConfigDetails(parsed.parsed, options), }; } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error); return { shape: configShapeReadFailure({ configPath: redactedConfigPath, redaction: options, stat, - error: error instanceof Error ? error.message : String(error), + error: !stat && isMissingPathError(error) ? undefined : errorMessage, }), }; }