FN-8791: prevent NUL-containing chat checkpoints from crashing

Prevent malformed tool output from breaking chat checkpoint persistence.

- Sanitize NUL characters at chat JSONB persistence boundaries without mutating clean values.
- Observe rejected best-effort checkpoint writes so streaming sessions do not emit unhandled rejections.
- Add PostgreSQL, dashboard, and sanitizer regression coverage with operator-facing release notes.

Files changed:
 .changeset/fn-8791-chat-nul-checkpoint.md          |  7 ++
 .../reliability/chat-jsonb-nul-sanitization.md     | 44 +++++++++++
 packages/core/src/__tests__/nul-sanitize.test.ts   | 21 ++++-
 .../chat-store-content-search-edit.pg.test.ts      | 91 +++++++++++++++++++++-
 packages/core/src/async-stores/async-chat-store.ts | 61 ++++++++++-----
 packages/core/src/postgres/nul-sanitize.ts         | 17 ++--
 .../dashboard/src/__tests__/chat-manager.test.ts   | 90 +++++++++++++++++++++
 packages/dashboard/src/chat.ts                     | 21 ++++-
 8 files changed, 322 insertions(+), 30 deletions(-)

Fusion-Task-Id: FN-8791

Fusion-Task-Lineage: 51e13634-04cc-4827-b72f-f33e40760940

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-08-04 19:15:20 -07:00
parent 71b13e7888
commit b9c5df04af
8 changed files with 322 additions and 30 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Prevent chat checkpoints from failing on NUL-containing tool output.
category: fix
dev: Sanitizes chat JSONB persistence boundaries and observes best-effort checkpoint failures.

View File

@@ -0,0 +1,44 @@
---
title: "Chat JSONB persistence must strip U+0000 at every PostgreSQL boundary"
date: 2026-08-05
problem_type: reliability
module: "@fusion/core"
component: chat-persistence
tags:
- postgres
- jsonb
- chat
- nul
- checkpoints
symptoms:
- "unsupported Unicode escape sequence during chat session checkpointing"
- "unhandled rejection after a tool reads NUL-bearing output"
root_cause: "Raw model and tool text reached PostgreSQL jsonb bindings without U+0000 sanitization, while fire-and-forget checkpoint promises had no rejection observer."
resolution_type: code_fix
---
## Problem
PostgreSQL rejects U+0000 in `text` and `jsonb`. Chat tool results are arbitrary environment-controlled output, so a tool reading redirected Fusion logs can include the logger marker `\u0000fnlvl=info\u0000`. Persisting that result in an in-flight generation snapshot caused the checkpoint write to reject; because checkpoints are intentionally fire-and-forget, the dashboard then emitted an unhandled rejection.
## Protected surfaces
The invariant belongs at PostgreSQL persistence boundaries, not provider callbacks. `sanitizeJsonbValue` now recursively strips U+0000 from JSONB values and keys before chat-session snapshot creation/checkpoint writes, message and room-message inserts, attachment appends, and message metadata merges. This covers direct chat, QuickChat, all provider callbacks, and future tool providers without mutating the caller's value. Clean values retain identity; `null` and `undefined` retain their existing semantics.
## Degradation behavior
In-flight checkpoints are crash-recovery state, not turn control flow. `ChatManager` routes debounced timer writes and immediate flushes through one observing helper. A failed checkpoint emits a concise session-scoped warning without payload or database-error text, drops that attempt, and allows streaming, completion, cancellation, and later clears to continue. It does not retry, wait, or create another scheduler.
## Why strip U+0000
Stripping is deterministic, preserves the readable surrounding tool output, and is already the established PostgreSQL sanitizer behavior. Escaping into a literal sequence would change tool text semantics and still require every reader to understand a special encoding.
## Regression commands
```bash
pnpm --filter @fusion/core exec vitest run src/__tests__/nul-sanitize.test.ts --silent=passed-only --reporter=default
pnpm --filter @fusion/core exec vitest run src/__tests__/postgres/chat-store-content-search-edit.pg.test.ts --silent=passed-only --reporter=default
pnpm --filter @fusion/dashboard exec vitest run src/__tests__/chat-manager.test.ts --project dashboard-api-quality --reporter=default --silent=passed-only
```
The PostgreSQL test persists the reported full in-flight tool-result shape and reads it back; dashboard tests exercise both debounced and flush rejection paths with no unhandled rejection.

View File

@@ -59,8 +59,25 @@ describe("sanitizeTextValue", () => {
});
describe("sanitizeJsonbValue", () => {
it("deep-strips NUL from an object destined for a jsonb column", () => {
expect(sanitizeJsonbValue({ note: "tail\u0000end" })).toEqual({ note: "tailend" });
it("deep-strips NUL from nested values, keys, and repeated logger markers", () => {
const snapshot = {
["tool\u0000result"]: {
content: ["\u0000fnlvl=info\u0000first", { text: "\u0000fnlvl=warn\u0000second" }],
},
};
expect(sanitizeJsonbValue(snapshot)).toEqual({
toolresult: {
content: ["fnlvl=infofirst", { text: "fnlvl=warnsecond" }],
},
});
expect(snapshot["tool\u0000result"].content[0]).toBe("\u0000fnlvl=info\u0000first");
});
it("preserves clean JSON identity while leaving null and undefined intact", () => {
const clean = { nested: ["clean", { value: true }] };
expect(sanitizeJsonbValue(clean)).toBe(clean);
expect(sanitizeJsonbValue(null)).toBe(null);
expect(sanitizeJsonbValue(undefined)).toBe(undefined);
});
it("passes through null/undefined unchanged", () => {

View File

@@ -19,13 +19,16 @@ import { execSync } from "node:child_process";
import { createAsyncDataLayer, type AsyncDataLayer } from "../../postgres/data-layer.js";
import {
addChatMessage,
addChatMessageAttachment,
createChatSession,
deleteChatMessagesFrom,
getChatMessages,
getChatSession,
setInFlightGeneration,
searchChatSessionsByMessageContent,
updateChatMessageMetadata,
} from "../../async-stores/async-chat-store.js";
import type { ChatMessage, ChatSession } from "../../chat/chat-types.js";
import type { ChatInFlightGenerationState, ChatMessage, ChatSession } from "../../chat/chat-types.js";
const PG_TEST_URL_BASE =
process.env.FUSION_PG_TEST_URL_BASE ?? "postgresql://localhost:5432";
@@ -216,6 +219,40 @@ pgDescribe("async chat store content search + edit primitives (PostgreSQL)", ()
expect((await getChatMessages(ctx.layer.db, sessionA.id)).length).toBe(1);
});
/*
FNXC:ChatPersistence 2026-08-05-01:54:
Attachment appends and metadata merges are separate jsonb update paths from
initial inserts. They must independently enforce the arbitrary-text NUL
invariant so a later tool-derived mutation cannot poison a chat row.
*/
it("sanitizes attachment appends and metadata merge updates at their jsonb boundaries", async () => {
ctx = await setupCtx();
const session = await makeSession(ctx);
const message = await addMessage(ctx, session.id, "user", "hello", { clean: true });
const attached = await addChatMessageAttachment(ctx.layer.db, session.id, message.id, {
id: "att\u0000-1",
filename: "fu\u0000sion.log",
originalName: "fu\u0000sion.log",
mimeType: "text/plain\u0000",
size: 1,
createdAt: "2026-08-05T01:54:00.000Z",
});
expect(attached.attachments).toEqual([{
id: "att-1",
filename: "fusion.log",
originalName: "fusion.log",
mimeType: "text/plain",
size: 1,
createdAt: "2026-08-05T01:54:00.000Z",
}]);
const merged = await updateChatMessageMetadata(ctx.layer.db, message.id, {
["tool\u0000result"]: { text: "\u0000fnlvl=info\u0000" },
});
expect(merged.metadata).toEqual({ clean: true, toolresult: { text: "fnlvl=info" } });
});
it("updateChatMessageMetadata merges by default, replaces on merge:false, and throws for missing messages", async () => {
ctx = await setupCtx();
@@ -235,6 +272,58 @@ pgDescribe("async chat store content search + edit primitives (PostgreSQL)", ()
).rejects.toThrow(/not found/);
});
/*
FNXC:ChatPersistence 2026-08-05-01:54:
A completed tool result can contain Fusion's raw logger framing
(U+0000fnlvl=infoU+0000). Checkpoint persistence must strip it recursively
before jsonb binding, preserve all replay fields, and leave the input owned
by the live chat callback untouched.
*/
it("persists the NUL-marked in-flight tool snapshot and reads back its sanitized shape", async () => {
ctx = await setupCtx();
const session = await makeSession(ctx);
const snapshot: ChatInFlightGenerationState = {
status: "generating",
streamingText: "prefix\u0000fnlvl=info\u0000suffix",
streamingThinking: "thought\u0000stream",
toolCalls: [{
toolName: "ba\u0000sh",
args: { command: "tail\u0000 /tmp/fusion.log" },
isError: false,
result: {
content: [{ type: "text", text: "log \u0000fnlvl=info\u0000 line" }],
["nested\u0000key"]: ["a\u0000b"],
},
status: "completed",
}],
replayFromEventId: 4,
updatedAt: "2026-08-05T01:54:00.000Z",
};
const updated = await setInFlightGeneration(ctx.layer.db, session.id, snapshot);
expect(updated?.inFlightGeneration).toMatchObject({
status: "generating",
streamingText: "prefixfnlvl=infosuffix",
streamingThinking: "thoughtstream",
replayFromEventId: 4,
toolCalls: [{
toolName: "bash",
args: { command: "tail /tmp/fusion.log" },
result: {
content: [{ type: "text", text: "log fnlvl=info line" }],
nestedkey: ["ab"],
},
}],
});
expect(snapshot.streamingText).toBe("prefix\u0000fnlvl=info\u0000suffix");
expect((snapshot.toolCalls[0].result as { content: Array<{ text: string }> }).content[0]?.text).toBe("log \u0000fnlvl=info\u0000 line");
const persisted = await getChatSession(ctx.layer.db, session.id);
expect(persisted?.inFlightGeneration).toEqual(updated?.inFlightGeneration);
await expect(setInFlightGeneration(ctx.layer.db, session.id, null)).resolves.toBeDefined();
expect((await getChatSession(ctx.layer.db, session.id))?.inFlightGeneration).toBeNull();
});
/*
FNXC:PostgresMigrationNulSanitize 2026-07-20:
Production incident: the CEO agent's chat turn crashed mid-conversation

View File

@@ -128,24 +128,34 @@ function rowToRoomMessage(row: Record<string, unknown>): ChatRoomMessage {
* Create a chat session.
*/
export async function createChatSession(handle: QueryHandle, session: ChatSession): Promise<ChatSession> {
/*
FNXC:ChatPersistence 2026-08-05-01:54:
Chat snapshot fields may include arbitrary model and tool text. PostgreSQL
rejects U+0000 anywhere in jsonb, so strip it only at this persistence
boundary and return the same sanitized shape that is stored.
*/
const sanitized: ChatSession = {
...session,
inFlightGeneration: sanitizeJsonbValue(session.inFlightGeneration),
};
await handle.insert(schema.project.chatSessions).values({
id: session.id,
agentId: session.agentId,
title: session.title,
status: session.status,
id: sanitized.id,
agentId: sanitized.agentId,
title: sanitized.title,
status: sanitized.status,
// FNXC:MultiProjectIsolation 2026-07-15-23:40: write the caller's domain project to owner_project_id and never project_id — the trigger/GUC owns the partition.
ownerProjectId: session.projectId,
modelProvider: session.modelProvider,
modelId: session.modelId,
thinkingLevel: session.thinkingLevel ?? null,
createdAt: session.createdAt,
updatedAt: session.updatedAt,
pinnedAt: session.pinnedAt,
cliSessionFile: session.cliSessionFile,
inFlightGeneration: session.inFlightGeneration,
cliExecutorAdapterId: session.cliExecutorAdapterId,
ownerProjectId: sanitized.projectId,
modelProvider: sanitized.modelProvider,
modelId: sanitized.modelId,
thinkingLevel: sanitized.thinkingLevel ?? null,
createdAt: sanitized.createdAt,
updatedAt: sanitized.updatedAt,
pinnedAt: sanitized.pinnedAt,
cliSessionFile: sanitized.cliSessionFile,
inFlightGeneration: sanitized.inFlightGeneration,
cliExecutorAdapterId: sanitized.cliExecutorAdapterId,
});
return session;
return sanitized;
}
/**
@@ -767,9 +777,16 @@ export async function setInFlightGeneration(
): Promise<ChatSession | undefined> {
const existing = await getChatSession(handle, id);
if (!existing) return undefined;
/*
FNXC:ChatPersistence 2026-08-05-01:54:
Every checkpoint reaches this JSONB boundary from live streaming callbacks,
including tool results. Sanitizing here protects regular chat, QuickChat,
and future providers without mutating the caller's snapshot.
*/
const sanitizedInFlightGeneration = sanitizeJsonbValue(inFlightGeneration);
await handle
.update(schema.project.chatSessions)
.set({ inFlightGeneration })
.set({ inFlightGeneration: sanitizedInFlightGeneration })
.where(eq(schema.project.chatSessions.id, id));
return getChatSession(handle, id);
}
@@ -790,7 +807,10 @@ export async function addChatMessageAttachment(
if (!message || message.sessionId !== sessionId) {
throw new Error(`Message ${messageId} not found in session ${sessionId}`);
}
const updatedAttachments = [...(message.attachments ?? []), attachment];
const updatedAttachments = sanitizeJsonbValue([
...(message.attachments ?? []),
attachment,
]);
await handle
.update(schema.project.chatMessages)
.set({ attachments: updatedAttachments })
@@ -930,7 +950,7 @@ export async function updateChatMessageMetadata(
await handle
.update(schema.project.chatMessages)
.set({ metadata: nextMetadata ?? null })
.set({ metadata: sanitizeJsonbValue(nextMetadata) ?? null })
.where(eq(schema.project.chatMessages.id, messageId));
const updated = await getChatMessage(handle, messageId);
@@ -1109,7 +1129,10 @@ export async function addChatRoomMessageAttachment(
if (!message || message.roomId !== roomId) {
throw new Error(`Message ${messageId} not found in room ${roomId}`);
}
const updatedAttachments = [...(message.attachments ?? []), attachment];
const updatedAttachments = sanitizeJsonbValue([
...(message.attachments ?? []),
attachment,
]);
await handle
.update(schema.project.chatRoomMessages)
.set({ attachments: updatedAttachments })

View File

@@ -32,14 +32,19 @@ export function deepStripNulChars(value: unknown): unknown {
return stripNulChars(value);
}
if (Array.isArray(value)) {
return value.map(deepStripNulChars);
const sanitized = value.map(deepStripNulChars);
return sanitized.some((entry, index) => entry !== value[index]) ? sanitized : value;
}
if (value !== null && typeof value === "object") {
return Object.fromEntries(
Object.entries(value as Record<string, unknown>).map(
([key, entry]) => [stripNulChars(key), deepStripNulChars(entry)],
),
);
const entries = Object.entries(value as Record<string, unknown>);
const sanitizedEntries = entries.map(([key, entry]) => [
stripNulChars(key),
deepStripNulChars(entry),
] as const);
const changed = sanitizedEntries.some(([key, entry], index) => (
key !== entries[index]?.[0] || entry !== entries[index]?.[1]
));
return changed ? Object.fromEntries(sanitizedEntries) : value;
}
return value;
}

View File

@@ -169,6 +169,7 @@ describe("ChatManager.sendMessage", () => {
});
mockChatStore.getMessages.mockReturnValue([]);
mockChatStore.getRoomMessages.mockReturnValue([]);
mockChatStore.setInFlightGeneration.mockResolvedValue(undefined);
mockAgentStore.init.mockResolvedValue(undefined);
mockAgentStore.getAgent.mockResolvedValue({
@@ -203,6 +204,8 @@ describe("ChatManager.sendMessage", () => {
});
afterEach(() => {
__setChatDiagnostics(null);
vi.useRealTimers();
vi.restoreAllMocks();
});
@@ -1019,6 +1022,93 @@ describe("ChatManager.sendMessage", () => {
expect(mockChatStore.setInFlightGeneration).toHaveBeenLastCalledWith("chat-001", null);
});
it("observes a debounced checkpoint rejection without an unhandled rejection", async () => {
vi.useFakeTimers();
const warn = vi.fn();
const unhandled = vi.fn();
process.on("unhandledRejection", unhandled);
__setChatDiagnostics({ log: vi.fn(), warn, error: vi.fn() });
let resolvePrompt: (() => void) | undefined;
__setCreateFnAgent(async (options: any) => ({
session: {
prompt: vi.fn().mockImplementation(() => new Promise<void>((resolve) => {
options.onToolEnd("bash", false, {
content: [{ type: "text", text: "log \u0000fnlvl=info\u0000 line" }],
});
resolvePrompt = resolve;
})),
dispose: vi.fn(),
state: { messages: [{ role: "assistant", content: "done" }] },
},
}));
mockChatStore.setInFlightGeneration
.mockResolvedValueOnce(undefined)
.mockRejectedValueOnce(new Error("jsonb rejected"))
.mockResolvedValue(undefined);
const sending = createChatManager().sendMessage("chat-001", "Read the log");
await vi.advanceTimersByTimeAsync(200);
await Promise.resolve();
expect(warn.mock.calls.filter(([message]) => message === "Failed to persist in-flight chat checkpoint for session chat-001")).toEqual([
["Failed to persist in-flight chat checkpoint for session chat-001"],
]);
expect(unhandled).not.toHaveBeenCalled();
expect(mockChatStore.setInFlightGeneration).toHaveBeenCalledWith("chat-001", expect.objectContaining({
toolCalls: [expect.objectContaining({
result: { content: [{ type: "text", text: "log \u0000fnlvl=info\u0000 line" }] },
})],
}));
resolvePrompt?.();
await sending;
process.off("unhandledRejection", unhandled);
vi.useRealTimers();
});
it("observes an immediate flush rejection and preserves the final clear", async () => {
const warn = vi.fn();
__setChatDiagnostics({ log: vi.fn(), warn, error: vi.fn() });
mockChatStore.setInFlightGeneration
.mockRejectedValueOnce(new Error("checkpoint unavailable"))
.mockResolvedValue(undefined);
__setCreateFnAgent(async () => ({
session: {
prompt: vi.fn().mockResolvedValue(undefined),
dispose: vi.fn(),
state: { messages: [{ role: "assistant", content: "done" }] },
},
}));
await expect(createChatManager().sendMessage("chat-001", "Hello")).resolves.toBeUndefined();
await Promise.resolve();
expect(warn.mock.calls.filter(([message]) => message === "Failed to persist in-flight chat checkpoint for session chat-001")).toEqual([
["Failed to persist in-flight chat checkpoint for session chat-001"],
]);
expect(mockChatStore.setInFlightGeneration).toHaveBeenLastCalledWith("chat-001", null);
});
it("cancels a stale debounced snapshot before flushing the latest clear", async () => {
vi.useFakeTimers();
let onText: ((delta: string) => void) | undefined;
__setCreateFnAgent(async (options: any) => {
onText = options.onText;
return {
session: {
prompt: vi.fn().mockImplementation(async () => onText?.("queued")),
dispose: vi.fn(),
state: { messages: [{ role: "assistant", content: "done" }] },
},
};
});
await createChatManager().sendMessage("chat-001", "Hello");
await vi.advanceTimersByTimeAsync(200);
expect(mockChatStore.setInFlightGeneration).toHaveBeenCalledTimes(2);
expect(mockChatStore.setInFlightGeneration).toHaveBeenLastCalledWith("chat-001", null);
vi.useRealTimers();
});
it("broadcasts done with persisted assistant message snapshot", async () => {
const events: Array<{ type: string; data: unknown }> = [];
const unsubscribe = chatStreamManager.subscribe("chat-001", (event) => {

View File

@@ -1518,6 +1518,23 @@ export class ChatManager {
this.cliChatProjectId = projectId;
}
/*
FNXC:ChatPersistence 2026-08-05-01:54:
Checkpoint snapshots contain environment-controlled tool output. Persistence
is best-effort for crash recovery, but every fire-and-forget write must have
its rejection observed so one failed jsonb write cannot become a process-wide
unhandled rejection or interrupt the streaming turn.
*/
private persistInFlightGeneration(sessionId: string, snapshot: ChatInFlightGenerationState | null): void {
try {
void this.chatStore.setInFlightGeneration(sessionId, snapshot).catch(() => {
diagnostics.warn(`Failed to persist in-flight chat checkpoint for session ${sessionId}`);
});
} catch {
diagnostics.warn(`Failed to persist in-flight chat checkpoint for session ${sessionId}`);
}
}
private queueInFlightGenerationPersist(sessionId: string, snapshot: ChatInFlightGenerationState | null): void {
const existingTimer = this.inFlightPersistTimers.get(sessionId);
if (existingTimer) {
@@ -1526,7 +1543,7 @@ export class ChatManager {
const timer = setTimeout(() => {
this.inFlightPersistTimers.delete(sessionId);
this.chatStore.setInFlightGeneration(sessionId, snapshot);
this.persistInFlightGeneration(sessionId, snapshot);
}, IN_FLIGHT_PERSIST_DEBOUNCE_MS);
this.inFlightPersistTimers.set(sessionId, timer);
}
@@ -1537,7 +1554,7 @@ export class ChatManager {
clearTimeout(existingTimer);
this.inFlightPersistTimers.delete(sessionId);
}
this.chatStore.setInFlightGeneration(sessionId, snapshot);
this.persistInFlightGeneration(sessionId, snapshot);
}
private async getChatModelSettings(): Promise<{