FN-103: support large request payloads
Allow large chat logs and workspace file-editor saves to pass through the dashboard without payload-too-large failures. - Increase request-body handling capacity for large text endpoints. - Document the payload limits and add integration coverage for chat and workspace-file APIs. - Add the FN-103 release changeset. Files changed: .changeset/fn-103-large-text-payloads.md | 7 + packages/dashboard/README.md | 15 +- .../app/api/__tests__/chat-rooms-api.test.ts | 11 ++ .../app/api/__tests__/legacy-chat-stream.test.ts | 19 ++ .../app/api/__tests__/workspace-files-api.test.ts | 30 ++++ .../large-text-body-parser-integration.test.ts | 195 +++++++++++++++++++++ packages/dashboard/src/routes/README.md | 21 +++ packages/dashboard/src/server.ts | 29 ++- 8 files changed, 325 insertions(+), 2 deletions(-) Fusion-Task-Id: FN-103 Fusion-Task-Lineage: 05cb2642-1f55-4939-8a28-78badf9b7caa Co-authored-by: Fusion <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fn-103-large-text-payloads.md
Normal file
7
.changeset/fn-103-large-text-payloads.md
Normal file
@@ -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.
|
||||||
@@ -459,12 +459,25 @@ Browse and edit task worktree files directly from the task detail modal:
|
|||||||
- **Safety Features**:
|
- **Safety Features**:
|
||||||
- Path traversal prevention (blocks `..` patterns)
|
- Path traversal prevention (blocks `..` patterns)
|
||||||
- Binary file detection (prevents editing images, executables, etc.)
|
- 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
|
- Unsaved change indicators
|
||||||
- **Keyboard Shortcuts**:
|
- **Keyboard Shortcuts**:
|
||||||
- `Ctrl/Cmd+S` to save
|
- `Ctrl/Cmd+S` to save
|
||||||
- `Escape` to close
|
- `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
|
### Activity Log
|
||||||
View a centralized timeline of all task lifecycle events. Click the history icon in the header to open the Activity Log modal.
|
View a centralized timeline of all task lifecycle events. Click the history icon in the header to open the Activity Log modal.
|
||||||
|
|
||||||
|
|||||||
@@ -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");
|
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 () => {
|
it("builds message endpoints", async () => {
|
||||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => jsonResponse({ success: true }));
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockImplementation(async () => jsonResponse({ success: true }));
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,25 @@ describe("streamChatResponse SSE parser", () => {
|
|||||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
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 () => {
|
it("serializes replacement identity in JSON and multipart requests", async () => {
|
||||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
new Response(createChunkedStream(["event: done\ndata: {\"messageId\":\"m-1\"}\n\n"]), { status: 200 }),
|
new Response(createChunkedStream(["event: done\ndata: {\"messageId\":\"m-1\"}\n\n"]), { status: 200 }),
|
||||||
|
|||||||
@@ -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 }));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<typeof import("../chat-project-services.js")>();
|
||||||
|
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<Settings> => ({} 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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
|
`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.
|
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.
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
setOnProjectFirstCreated,
|
setOnProjectFirstCreated,
|
||||||
} from "./project-store-resolver.js";
|
} from "./project-store-resolver.js";
|
||||||
import { getOrCreateScopedChatStore } from "./chat-project-services.js";
|
import { getOrCreateScopedChatStore } from "./chat-project-services.js";
|
||||||
|
import { MAX_FILE_SIZE } from "./file-service.js";
|
||||||
import { TerminalViewportRegistry } from "./terminal-viewport.js";
|
import { TerminalViewportRegistry } from "./terminal-viewport.js";
|
||||||
import { getTerminalService, STALE_SESSION_THRESHOLD_MS } from "./terminal-service.js";
|
import { getTerminalService, STALE_SESSION_THRESHOLD_MS } from "./terminal-service.js";
|
||||||
import { WebSocketServer, type WebSocket } from "ws";
|
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 jsonParser = express.json({ verify: preserveRawBody });
|
||||||
const planningImageCaptureParser = express.json({ limit: "5mb", 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) => {
|
app.use((req, res, next) => {
|
||||||
// Express treats trailing slashes as equivalent, so parser boundaries must do the same;
|
// Express treats trailing slashes as equivalent, so parser boundaries must do the same;
|
||||||
// no broader prefix is exempted from the global rawBody-preserving parser.
|
// no broader prefix is exempted from the global rawBody-preserving parser.
|
||||||
if (req.path === "/api/voice/transcribe" || req.path === "/api/voice/transcribe/") return next();
|
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/"
|
const parser = req.path === "/api/planning/start-streaming" || req.path === "/api/planning/start-streaming/"
|
||||||
? planningImageCaptureParser
|
? 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) => {
|
return parser(req, res, (error) => {
|
||||||
// Keep the established global and route-specific size rejections observable as 413 instead
|
// 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.
|
// of allowing Express's parser error to fall through to the generic 500 handler.
|
||||||
|
|||||||
Reference in New Issue
Block a user