Harden diagnostic stability bundle imports

This commit is contained in:
Gustavo Madeira Santana
2026-04-22 21:30:18 -04:00
parent 1489febee9
commit c992a8e5d8
3 changed files with 468 additions and 46 deletions

View File

@@ -45,6 +45,32 @@ describe("diagnostic stability bundles", () => {
return JSON.parse(fs.readFileSync(file, "utf8")) as DiagnosticStabilityBundle;
}
function createImportedBundle(): Record<string, unknown> {
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: "<redacted-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<string, unknown>, {
command: "process-command-secret",
});
Object.assign(bundle.host as Record<string, unknown>, {
hostname: "private-hostname",
fqdn: "host-extra-secret",
});
const snapshot = bundle.snapshot as Record<string, unknown>;
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: "<redacted-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: "<redacted-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<string, unknown>;
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,
},
},

View File

@@ -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<string, unknown>)[key] = parsed;
}
}
function assignOptionalCodeString(
target: object,
key: string,
value: unknown,
label: string,
): void {
const parsed = readOptionalCodeString(value, label);
if (parsed !== undefined) {
(target as Record<string, unknown>)[key] = parsed;
}
}
function readMemoryUsage(
value: unknown,
label: string,
): NonNullable<DiagnosticStabilitySnapshot["summary"]["memory"]>["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<string, number> {
const source = readObject(value, label);
const result: Record<string, number> = {};
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(

View File

@@ -392,6 +392,97 @@ describe("diagnostic support export", () => {
expect(sanitizedConfig.agents?.[0]?.instructions).toBe("<redacted>");
});
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<Record<string, unknown>>;
summary?: { byType?: Record<string, number> };
};
};
expect(stability.reason).toBe("unknown");
expect(stability.host).toEqual({ hostname: "<redacted-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: {