diff --git a/.changeset/fn-103-large-text-payloads.md b/.changeset/fn-103-large-text-payloads.md new file mode 100644 index 0000000000..119f6c90b7 --- /dev/null +++ b/.changeset/fn-103-large-text-payloads.md @@ -0,0 +1,7 @@ +--- +"@runfusion/fusion": patch +--- + +summary: Allow larger chat logs and supported file-editor saves without payload errors. +category: fix +dev: Adds finite route-scoped 2 MiB chat and escaped-file JSON parsers while retaining default limits. diff --git a/packages/dashboard/README.md b/packages/dashboard/README.md index 9bf89270c4..135ffe67ed 100644 --- a/packages/dashboard/README.md +++ b/packages/dashboard/README.md @@ -459,12 +459,25 @@ Browse and edit task worktree files directly from the task detail modal: - **Safety Features**: - Path traversal prevention (blocks `..` patterns) - Binary file detection (prevents editing images, executables, etc.) - - 1MB file size limit + - 1MB UTF-8 file-content size limit + - File-save JSON requests allow up to 6,292,480 bytes so a supported 1MB control-character file + can survive JSON escaping; this transport envelope does not increase the file-content limit - Unsaved change indicators - **Keyboard Shortcuts**: - `Ctrl/Cmd+S` to save - `Escape` to close +### Large chat and file requests + +Pasted Direct, Planner, and Room Chat text sent as JSON can be up to 2 MiB per request. The limit +is a transport limit, not a model-context promise: provider context also includes conversation +history, instructions, tool input, reasoning, and output. Chat attachment uploads remain multipart +with their existing file count and size limits. + +File-editor saves have an approximately 6 MiB JSON transport envelope only to accommodate JSON +escaping of the unchanged 1 MiB UTF-8 file-content maximum. File-browser operations such as mkdir, +copy, move, delete, and rename, plus unrelated JSON endpoints, retain the 100 KiB request limit. + ### Activity Log View a centralized timeline of all task lifecycle events. Click the history icon in the header to open the Activity Log modal. diff --git a/packages/dashboard/app/api/__tests__/chat-rooms-api.test.ts b/packages/dashboard/app/api/__tests__/chat-rooms-api.test.ts index be825cd268..e5f4c626f4 100644 --- a/packages/dashboard/app/api/__tests__/chat-rooms-api.test.ts +++ b/packages/dashboard/app/api/__tests__/chat-rooms-api.test.ts @@ -71,6 +71,17 @@ describe("chat room legacy API client", () => { expect((fetchMock.mock.calls[2] as [string])[0]).toContain("/api/chat/rooms/room-1/members/agent-2?projectId=proj-2"); }); + it("serializes a large room log byte-for-byte", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => jsonResponse({ message: {} })); + const log = "2026-08-21T04:35:00Z INFO repeated room line\n".repeat(3_000); + + await postChatRoomMessage("room-1", { content: log }, "proj-3"); + + const [, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(init.body).toBe(JSON.stringify({ content: log })); + expect(JSON.parse(String(init.body)).content).toBe(log); + }); + it("builds message endpoints", async () => { const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => jsonResponse({ success: true })); diff --git a/packages/dashboard/app/api/__tests__/legacy-chat-stream.test.ts b/packages/dashboard/app/api/__tests__/legacy-chat-stream.test.ts index 22cd7ddc83..8aa5d20ecd 100644 --- a/packages/dashboard/app/api/__tests__/legacy-chat-stream.test.ts +++ b/packages/dashboard/app/api/__tests__/legacy-chat-stream.test.ts @@ -51,6 +51,25 @@ describe("streamChatResponse SSE parser", () => { expect(fetchMock).toHaveBeenCalledTimes(1); }); + it("preserves a large no-attachment log as JSON while attachments remain multipart", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( + new Response(createChunkedStream(["event: done\ndata: {\"messageId\":\"m-large\"}\n\n"]), { status: 200 }), + ); + const log = "2026-08-21T04:35:00Z INFO repeated log line\n".repeat(3_000); + + streamChatResponse("s-1", log, { onDone: vi.fn(), onError: vi.fn() }); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1)); + const jsonRequest = fetchMock.mock.calls[0]?.[1]; + expect(jsonRequest?.body).toBe(JSON.stringify({ content: log })); + expect(JSON.parse(String(jsonRequest?.body)).content).toBe(log); + + streamChatResponse("s-1", log, { onDone: vi.fn(), onError: vi.fn() }, [new File(["file"], "note.txt")]); + await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + const multipartBody = fetchMock.mock.calls[1]?.[1]?.body as FormData; + expect(multipartBody).toBeInstanceOf(FormData); + expect(multipartBody.get("content")).toBe(log); + }); + it("serializes replacement identity in JSON and multipart requests", async () => { const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue( new Response(createChunkedStream(["event: done\ndata: {\"messageId\":\"m-1\"}\n\n"]), { status: 200 }), diff --git a/packages/dashboard/app/api/__tests__/workspace-files-api.test.ts b/packages/dashboard/app/api/__tests__/workspace-files-api.test.ts new file mode 100644 index 0000000000..6d8cefedb8 --- /dev/null +++ b/packages/dashboard/app/api/__tests__/workspace-files-api.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { saveFileContent, saveWorkspaceFileContent } from "../projects/workspace-files"; + +function jsonResponse(payload: unknown): Response { + return new Response(JSON.stringify(payload), { status: 200, headers: { "content-type": "application/json" } }); +} + +/** + * FNXC:LargeTextPayloads 2026-08-21-04:35: + * File-editor clients must keep canonical JSON serialization so the server's finite escaped-file + * envelope covers every shared editor without introducing client-specific truncation. + */ +describe("workspace file save API", () => { + afterEach(() => vi.restoreAllMocks()); + + it("serializes large workspace and compatibility task-file content exactly", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => jsonResponse({ success: true, mtime: "now", size: 1 })); + const content = "2026-08-21T04:35:00Z INFO repeated file log\n".repeat(3_000); + + await saveWorkspaceFileContent("project", "logs/large.log", content, "project-1"); + await saveFileContent("task-1", "logs/large.log", content, "project-1"); + + const [workspaceUrl, workspaceInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(workspaceUrl).toContain("/api/files/logs%2Flarge.log?workspace=project&projectId=project-1"); + expect(workspaceInit.body).toBe(JSON.stringify({ content })); + const [taskUrl, taskInit] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect(taskUrl).toContain("/api/tasks/task-1/files/logs%2Flarge.log?projectId=project-1"); + expect(taskInit.body).toBe(JSON.stringify({ content })); + }); +}); diff --git a/packages/dashboard/src/__tests__/large-text-body-parser-integration.test.ts b/packages/dashboard/src/__tests__/large-text-body-parser-integration.test.ts new file mode 100644 index 0000000000..826c4f4852 --- /dev/null +++ b/packages/dashboard/src/__tests__/large-text-body-parser-integration.test.ts @@ -0,0 +1,195 @@ +// @vitest-environment node + +import { EventEmitter } from "node:events"; +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { Settings, TaskStore } from "@fusion/core"; + +const scopedChatManager = vi.hoisted(() => ({ current: undefined as never })); +vi.mock("../chat-project-services.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getOrCreateScopedChatManager: () => scopedChatManager.current, + createProjectScopedChatManager: async () => scopedChatManager.current, + }; +}); + +import { createServer } from "../server.js"; +import { MAX_FILE_SIZE } from "../file-service.js"; +import { chatStreamManager } from "../chat.js"; +import { request } from "../test-request.js"; + +const LARGE_LOG = "2026-08-21T04:35:00Z INFO repeated log line\n".repeat(3_000).trimEnd(); +const largeJson = (content = LARGE_LOG) => JSON.stringify({ content }); + +/** Minimal production-stack store: route tests intentionally use createServer, not a router. */ +class LargeTextParserStore extends EventEmitter { + constructor(private readonly rootDir: string) { super(); } + getRootDir() { return this.rootDir; } + getFusionDir() { return join(this.rootDir, ".fusion"); } + getSettings = vi.fn(async (): Promise => ({} as Settings)); + getSettingsFast = this.getSettings; + getGlobalSettingsStore = () => ({ getSettings: async () => ({}) }); + getAsyncLayer = vi.fn(() => ({ db: { update: vi.fn(() => ({ set: vi.fn(() => ({ where: vi.fn(() => ({ returning: vi.fn(async () => []) })) })) })) } })); + getProjectScopedPluginMcpServers = vi.fn().mockResolvedValue([]); + getTask = vi.fn(async () => ({ worktree: this.rootDir })); + getTaskWorkflowSelection = vi.fn(); + getWorkflowDefinition = vi.fn(async () => undefined); + getWorkflowSettingValues = vi.fn(() => ({})); + getWorkflowSettingsProjectId = vi.fn(() => "default"); +} + +const roots: string[] = []; +afterEach(async () => { + chatStreamManager.reset(); + await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true }))); +}); + +async function app() { + const root = await mkdtemp(join(tmpdir(), "fusion-large-text-")); + roots.push(root); + const store = new LargeTextParserStore(root); + const chatStore = { + getSession: vi.fn(async (id: string) => { + if (id === "session-1") return { id, agentId: "__fn_agent__", projectId: null }; + if (id === "planner-session") return { id, agentId: "task-planner:task-1", projectId: null }; + return undefined; + }), + getRoom: vi.fn(async (id: string) => id === "room-1" ? { id, projectId: null } : undefined), + }; + const chatManager = { + prepareReplacement: vi.fn(async () => ({ generationId: 1 })), + beginGeneration: vi.fn(() => ({ generationId: 2, abortController: new AbortController() })), + isGenerating: vi.fn(() => false), + sendMessage: vi.fn(async (sessionId: string, content: string, _provider: unknown, _model: unknown, _attachments: unknown, options: { generationId?: number }) => { + chatStreamManager.broadcast(sessionId, { type: "done", data: { messageId: "assistant-1" } }, { generationId: options.generationId }); + }), + sendRoomMessage: vi.fn(async (_roomId: string, content: string) => ({ userMessage: { id: "room-message-1", content } })), + }; + scopedChatManager.current = chatManager as never; + return { + root, + chatManager, + app: createServer(store as unknown as TaskStore, { + noAuth: true, + chatStore: chatStore as never, + chatManager: chatManager as never, + }), + }; +} + +/** + * FNXC:LargeTextPayloads 2026-08-21-04:35: + * These requests boot the complete server middleware stack because registrar tests cannot prove + * that the central 100 KiB parser admits only the finite chat and file-save envelopes. + */ +describe("large-text body parser integration", () => { + it("delivers an exact over-100-KiB log once through direct, planner, and room chat", async () => { + const server = await app(); + const body = largeJson(); + expect(Buffer.byteLength(body)).toBeGreaterThan(100 * 1024); + expect(Buffer.byteLength(body)).toBeLessThanOrEqual(2 * 1024 * 1024); + + const direct = await request(server.app, "POST", "/api/chat/sessions/session-1/messages", body, { "content-type": "application/json" }); + const planner = await request(server.app, "POST", "/api/chat/sessions/planner-session/messages", JSON.stringify({ content: LARGE_LOG, taskId: "task-1" }), { "content-type": "application/json" }); + const room = await request(server.app, "POST", "/api/chat/rooms/room-1/messages/", body, { "content-type": "application/json" }); + + expect(direct.status).toBe(200); + expect(planner.status).toBe(200); + expect(room.status).toBe(201); + expect(server.chatManager.sendMessage).toHaveBeenCalledTimes(2); + expect(server.chatManager.sendMessage).toHaveBeenNthCalledWith(1, "session-1", LARGE_LOG, undefined, undefined, undefined, { generationId: 2 }); + expect(server.chatManager.sendMessage).toHaveBeenNthCalledWith(2, "planner-session", LARGE_LOG, undefined, undefined, undefined, { generationId: 2 }); + expect(server.chatManager.sendRoomMessage).toHaveBeenCalledTimes(1); + expect(server.chatManager.sendRoomMessage).toHaveBeenCalledWith("room-1", LARGE_LOG, undefined); + expect((room.body as { message: { content: string } }).message.content).toBe(LARGE_LOG); + }); + + it("persists over-100-KiB workspace saves exactly, including encoded paths and operation-named files", async () => { + const server = await app(); + const body = largeJson(); + await mkdir(join(server.root, "src")); + const encodedPath = await request(server.app, "POST", "/api/files/src%2Fcopy?workspace=project", body, { "content-type": "application/json" }); + const operationNamedFile = await request(server.app, "POST", "/api/files/copy?workspace=project", body, { "content-type": "application/json" }); + + expect(encodedPath.status).toBe(200); + expect(operationNamedFile.status).toBe(200); + expect(await readFile(join(server.root, "src", "copy"), "utf8")).toBe(LARGE_LOG); + expect(await readFile(join(server.root, "copy"), "utf8")).toBe(LARGE_LOG); + }); + + it("persists the worst-case escaped 1 MiB content through workspace and task saves", async () => { + const server = await app(); + const content = "\0".repeat(MAX_FILE_SIZE); + const body = JSON.stringify({ content }); + const fileLimit = 6 * MAX_FILE_SIZE + 1024; + expect(Buffer.byteLength(content, "utf8")).toBe(MAX_FILE_SIZE); + expect(Buffer.byteLength(body)).toBeGreaterThan(2 * 1024 * 1024); + expect(Buffer.byteLength(body)).toBeLessThanOrEqual(fileLimit); + + const workspace = await request(server.app, "POST", "/api/files/escaped.txt?workspace=project", body, { "content-type": "application/json" }); + const taskFile = await request(server.app, "POST", "/api/tasks/task-1/files/escaped-task.txt", body, { "content-type": "application/json" }); + expect(workspace.status).toBe(200); + expect(taskFile.status).toBe(200); + expect(await readFile(join(server.root, "escaped.txt"))).toEqual(Buffer.from(content)); + expect(await readFile(join(server.root, "escaped-task.txt"))).toEqual(Buffer.from(content)); + }); + + it("keeps the 1 MiB file-service cap and finite transport envelope separate", async () => { + const server = await app(); + const applicationTooLarge = "x".repeat(MAX_FILE_SIZE + 1); + const applicationBody = JSON.stringify({ content: applicationTooLarge }); + const transportLimit = 6 * MAX_FILE_SIZE + 1024; + expect(Buffer.byteLength(applicationBody)).toBeLessThanOrEqual(transportLimit); + + const applicationResponse = await request(server.app, "POST", "/api/files/too-large.txt?workspace=project", applicationBody, { "content-type": "application/json" }); + expect(applicationResponse.status).toBe(413); + + const transportBody = JSON.stringify({ content: "", ignoredPadding: "x".repeat(transportLimit) }); + expect(Buffer.byteLength(transportBody)).toBeGreaterThan(transportLimit); + const transportResponse = await request(server.app, "POST", "/api/files/transport-too-large.txt?workspace=project", transportBody, { "content-type": "application/json" }); + expect(transportResponse.status).toBe(413); + expect(transportResponse.body).toEqual({ error: "payload-too-large" }); + }); + + it.each([ + "/api/files/mkdir", + "/api/files/mkdir/", + "/api/files/src%2Findex.ts/copy", + "/api/files/src%2Findex.ts/move/", + "/api/files/src%2Findex.ts/delete", + "/api/files/src%2Findex.ts/rename/", + "/api/files", + "/api/files-extra/save", + "/api/chat/sessions/session-1/messages-extra", + ])("keeps %s on the default 100-KiB parser", async (path) => { + const server = await app(); + const response = await request(server.app, "POST", path, largeJson(), { "content-type": "application/json" }); + expect(response.status).toBe(413); + expect(response.body).toEqual({ error: "payload-too-large" }); + }); + + it("keeps non-POST chat and file lookalikes on the default parser", async () => { + const server = await app(); + for (const path of ["/api/chat/sessions/session-1/messages", "/api/files/large.txt"]) { + const response = await request(server.app, "PUT", path, largeJson(), { "content-type": "application/json" }); + expect(response.status).toBe(413); + expect(response.body).toEqual({ error: "payload-too-large" }); + } + }); + + it("rejects oversized replacement chat bodies before generation or rewind work", async () => { + const server = await app(); + const body = JSON.stringify({ content: "x".repeat(2 * 1024 * 1024), replacementMessageId: "message-1" }); + expect(Buffer.byteLength(body)).toBeGreaterThan(2 * 1024 * 1024); + const response = await request(server.app, "POST", "/api/chat/sessions/session-1/messages", body, { "content-type": "application/json" }); + expect(response.status).toBe(413); + expect(response.body).toEqual({ error: "payload-too-large" }); + expect(server.chatManager.prepareReplacement).not.toHaveBeenCalled(); + expect(server.chatManager.beginGeneration).not.toHaveBeenCalled(); + expect(server.chatManager.sendMessage).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/dashboard/src/routes/README.md b/packages/dashboard/src/routes/README.md index 9231a81ba1..6af2bc07b6 100644 --- a/packages/dashboard/src/routes/README.md +++ b/packages/dashboard/src/routes/README.md @@ -180,3 +180,24 @@ return 404. Download returns 202 with queued/downloading state; poll status for `server.ts` excludes only `/api/voice/transcribe` from its global 100 KiB JSON parser so the route's 2 MiB parser can return JSON 413/400 errors; other routes retain raw-body HMAC capture. + +## Large text JSON parsing + +`server.ts` preserves raw bodies and selects a finite parser before metrics, authentication, and +all API registrars. The default remains 100 KiB. Only `POST /api/chat/sessions/:id/messages` and +`POST /api/chat/rooms/:id/messages` (including optional trailing slashes) receive a 2 MiB JSON +limit. Multipart requests on those paths are not parsed by `express.json()` and continue to Multer. + +Only non-empty `POST /api/tasks/:id/files/{*filepath}` and generic +`POST /api/files/{*filepath}` saves receive `6 * MAX_FILE_SIZE + 1024` bytes (6,292,480 bytes). +A supported 1 MiB UTF-8 string can expand to six bytes per control character in canonical JSON; +the extra 1 KiB covers object framing. `/api/files/mkdir` and literal terminal `/copy`, `/move`, +`/delete`, and `/rename` operation paths retain 100 KiB, including optional trailing slashes. The +selector uses query-free, undecoded `req.path`, so an encoded filepath such as `src%2Fcopy` remains +a generic save rather than an operation. + +The roughly 6 MiB file envelope is a deliberate exact-route trade-off: Express warns that larger +bodies consume memory and can add latency. It is not derived from model context windows, because +HTTP parsing precedes model selection, bytes are not tokens, and model context also includes +history, system/tool input, reasoning, and output. GitHub raw webhook parsing stays first; +Voice keeps its route-owned 2 MiB parser and Planning keeps its route-owned 5 MiB parser. diff --git a/packages/dashboard/src/server.ts b/packages/dashboard/src/server.ts index 34acc39d6f..79083b5946 100644 --- a/packages/dashboard/src/server.ts +++ b/packages/dashboard/src/server.ts @@ -33,6 +33,7 @@ import { setOnProjectFirstCreated, } from "./project-store-resolver.js"; import { getOrCreateScopedChatStore } from "./chat-project-services.js"; +import { MAX_FILE_SIZE } from "./file-service.js"; import { TerminalViewportRegistry } from "./terminal-viewport.js"; import { getTerminalService, STALE_SESSION_THRESHOLD_MS } from "./terminal-service.js"; import { WebSocketServer, type WebSocket } from "ws"; @@ -1018,13 +1019,39 @@ export function createServer(store: TaskStore, options?: ServerOptions): ReturnT }; const jsonParser = express.json({ verify: preserveRawBody }); const planningImageCaptureParser = express.json({ limit: "5mb", verify: preserveRawBody }); + const chatMessageParser = express.json({ limit: 2 * 1024 * 1024, verify: preserveRawBody }); + const fileSaveParser = express.json({ limit: 6 * MAX_FILE_SIZE + 1024, verify: preserveRawBody }); + + /* + FNXC:LargeTextPayloads 2026-08-21-04:35: + Large pasted logs must reach only production chat-message endpoints within a finite 2 MiB JSON + envelope, while generic workspace and task-file saves receive 6 * MAX_FILE_SIZE + 1 KiB. A + supported 1 MiB UTF-8 file can serialize each control byte as six JSON bytes; 1 KiB covers the + canonical object framing. Express warns that large bodies increase memory and latency, so the + approximately 6 MiB parser is limited to exact save routes: mkdir and literal copy/move/delete/ + rename operations retain the 100 KiB default. Model context windows cannot define HTTP bytes: + parsing precedes model resolution, bytes are not tokens, and context is shared with history, + system/tool input, reasoning, and output. + */ + const isChatMessagePath = (path: string): boolean => + /^\/api\/chat\/(?:sessions|rooms)\/[^/]+\/messages\/?$/.test(path); + const isTaskFileSavePath = (path: string): boolean => + /^\/api\/tasks\/[^/]+\/files\/.+\/?$/.test(path); + const isWorkspaceFileSavePath = (path: string): boolean => { + if (!/^\/api\/files\/.+/.test(path) || /^\/api\/files\/mkdir\/?$/.test(path)) return false; + return !/^\/api\/files\/.+\/(?:copy|move|delete|rename)\/?$/.test(path); + }; app.use((req, res, next) => { // Express treats trailing slashes as equivalent, so parser boundaries must do the same; // no broader prefix is exempted from the global rawBody-preserving parser. if (req.path === "/api/voice/transcribe" || req.path === "/api/voice/transcribe/") return next(); const parser = req.path === "/api/planning/start-streaming" || req.path === "/api/planning/start-streaming/" ? planningImageCaptureParser - : jsonParser; + : req.method === "POST" && isChatMessagePath(req.path) + ? chatMessageParser + : req.method === "POST" && (isTaskFileSavePath(req.path) || isWorkspaceFileSavePath(req.path)) + ? fileSaveParser + : jsonParser; return parser(req, res, (error) => { // Keep the established global and route-specific size rejections observable as 413 instead // of allowing Express's parser error to fall through to the generic 500 handler.