diff --git a/packages/core/src/__tests__/canvas-log.test.ts b/packages/core/src/__tests__/canvas-log.test.ts index 96797de58..b0b2aed2c 100644 --- a/packages/core/src/__tests__/canvas-log.test.ts +++ b/packages/core/src/__tests__/canvas-log.test.ts @@ -134,6 +134,44 @@ describe("CanvasArtifactSchema", () => { const result = CanvasArtifactSchema.safeParse({ ...validMarkdown, id: "BAD ID" }); expect(result.success).toBe(false); }); + + it("rejects diff canvases with too many hunk lines", () => { + // 1001 lines exceeds DIFF_MAX_LINES_PER_HUNK (1000). This is the cap that + // prevents agent-emitted diffs from rendering hundreds of thousands of DOM + // rows on every 5s poll. + const tooManyLines = Array.from({ length: 1001 }, (_, i) => ({ + kind: "context" as const, + text: `line ${i}`, + })); + const result = CanvasArtifactSchema.safeParse({ + version: 1, + id: "huge", + type: "diff", + title: "Too many lines", + createdAt: "2026-05-06T00:00:00Z", + updatedAt: "2026-05-06T00:00:00Z", + payload: { files: [{ path: "a.ts", status: "modified", hunks: [{ header: "@@", lines: tooManyLines }] }] }, + }); + expect(result.success).toBe(false); + }); + + it("rejects diff canvases with too many files", () => { + const tooManyFiles = Array.from({ length: 201 }, (_, i) => ({ + path: `f${i}.ts`, + status: "modified" as const, + hunks: [], + })); + const result = CanvasArtifactSchema.safeParse({ + version: 1, + id: "wide", + type: "diff", + title: "Too many files", + createdAt: "2026-05-06T00:00:00Z", + updatedAt: "2026-05-06T00:00:00Z", + payload: { files: tooManyFiles }, + }); + expect(result.success).toBe(false); + }); }); describe("parseUnifiedDiff", () => { diff --git a/packages/core/src/canvas-schema.ts b/packages/core/src/canvas-schema.ts index c9c443cee..541cb8a83 100644 --- a/packages/core/src/canvas-schema.ts +++ b/packages/core/src/canvas-schema.ts @@ -24,10 +24,19 @@ const diffLine = z }) .strict(); +// Bounds matching the synthesized git-diff path's caps in canvas-log.ts. +// Without these, an agent-emitted diff canvas could pack tens of thousands of +// hunk lines into a 256 KB payload and stall the session detail page on every +// 5s poll. Numbers chosen to be generous for legitimate review/refactor diffs +// while preventing DOM blowup. See canvas-log.ts: DIFF_MAX_FILES, DIFF_MAX_LINES. +const DIFF_MAX_LINES_PER_HUNK = 1000; +const DIFF_MAX_HUNKS_PER_FILE = 50; +const DIFF_MAX_FILES_PER_CANVAS = 200; + const diffHunk = z .object({ header: z.string(), - lines: z.array(diffLine), + lines: z.array(diffLine).max(DIFF_MAX_LINES_PER_HUNK), }) .strict(); @@ -36,13 +45,13 @@ const diffFile = z path: z.string().min(1), oldPath: z.string().optional(), status: z.enum(["added", "modified", "deleted", "renamed"]), - hunks: z.array(diffHunk), + hunks: z.array(diffHunk).max(DIFF_MAX_HUNKS_PER_FILE), }) .strict(); const diffPayload = z .object({ - files: z.array(diffFile), + files: z.array(diffFile).max(DIFF_MAX_FILES_PER_CANVAS), }) .strict();