From fbc83613c0ceffc6658bd1a5a9c52c9d335c6f48 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 05:01:09 +0200 Subject: [PATCH] Keep original file names for chat uploads (#5317) * fix(server): keep original file names for chat uploads Uploads replaced every character outside ASCII letters, digits, dot, dash, and space with an underscore, so CJK, Korean, accented, and parenthesized names reached disk and the agent as underscores. Replace only characters no file system accepts in a file name, and shorten names past 255 bytes while keeping the extension. * test(server): make upload separator test independent of the daemon OS --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/file-upload/index.test.ts | 64 ++++++++++++++++++- .../server/src/server/file-upload/index.ts | 23 ++++++- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/packages/server/src/server/file-upload/index.test.ts b/packages/server/src/server/file-upload/index.test.ts index 7eb97a07c3..8761529ec6 100644 --- a/packages/server/src/server/file-upload/index.test.ts +++ b/packages/server/src/server/file-upload/index.test.ts @@ -1,6 +1,6 @@ import { existsSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; -import { dirname, join } from "node:path"; +import { basename, dirname, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { @@ -56,6 +56,49 @@ describe("file uploads", () => { expect(readFileSync(path, "utf8")).toBe("hello world"); }); + it("keeps the original file name for non-ASCII and punctuated names", async () => { + const uploads = new FileUploadStore({ paseoHome: makePaseoHome() }); + + for (const fileName of [ + "2026年9月绩效计划表.xlsx", + "테스트 파일 (1).xlsx", + "résumé [final] & notes, v2.pdf", + "cafe\u0301 हिंदी.txt", + ]) { + const file = await uploadNamed(uploads, fileName); + expect(file?.fileName).toBe(fileName); + expect(basename(file!.path)).toBe(fileName); + expect(readFileSync(file!.path, "utf8")).toBe("hello world"); + } + }); + + it("replaces path separators, control characters, and characters Windows rejects", async () => { + const uploads = new FileUploadStore({ paseoHome: makePaseoHome() }); + + await expect(uploadNamed(uploads, "../../etc/passwd")).resolves.toMatchObject({ + fileName: "passwd", + }); + const backslashed = await uploadNamed(uploads, "dir\\name.txt"); + expect(backslashed?.fileName).not.toContain("\\"); + expect(backslashed?.fileName).toMatch(/name\.txt$/); + await expect(uploadNamed(uploads, 'a:"c|?*.txt')).resolves.toMatchObject({ + fileName: "a_b___c___.txt", + }); + await expect(uploadNamed(uploads, "line\nbreak.txt")).resolves.toMatchObject({ + fileName: "line_break.txt", + }); + }); + + it("shortens a long non-ASCII name to the file system limit and keeps its extension", async () => { + const uploads = new FileUploadStore({ paseoHome: makePaseoHome() }); + + const file = await uploadNamed(uploads, `${"绩".repeat(100)}.xlsx`); + + expect(file?.fileName).toBe(`${"绩".repeat(83)}.xlsx`); + expect(Buffer.byteLength(file!.fileName)).toBeLessThanOrEqual(255); + expect(readFileSync(file!.path, "utf8")).toBe("hello world"); + }); + it("rejects chunks beyond the declared size and removes the partial file", async () => { const paseoHome = makePaseoHome(); const uploads = new FileUploadStore({ paseoHome }); @@ -202,6 +245,25 @@ describe("file uploads", () => { }); }); +let uploadCount = 0; + +async function uploadNamed(uploads: FileUploadStore, fileName: string) { + const requestId = `req-named-${uploadCount++}`; + uploads.beginUpload({ + type: "file.upload.request", + fileName, + mimeType: "text/plain", + size: 11, + modifiedAt: "2026-05-02T00:00:00.000Z", + requestId, + }); + await uploads.receiveFrame(uploadBegins(requestId)); + await uploads.receiveFrame(uploadChunk(requestId, "hello world")); + const response = await uploads.receiveFrame(uploadEnds(requestId)); + expect(response?.payload.error).toBeNull(); + return response?.payload.file; +} + function makePaseoHome(): string { const root = realpathSync(mkdtempSync(join(tmpdir(), "file-upload-test-"))); tempDirs.push(root); diff --git a/packages/server/src/server/file-upload/index.ts b/packages/server/src/server/file-upload/index.ts index 8b93e31f2f..979130d875 100644 --- a/packages/server/src/server/file-upload/index.ts +++ b/packages/server/src/server/file-upload/index.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import { appendFile, mkdir, rm, writeFile } from "node:fs/promises"; -import { basename, join } from "node:path"; +import { basename, extname, join } from "node:path"; import { FileTransferOpcode, type FileTransferFrame } from "@getpaseo/protocol/binary-frames/index"; import { getErrorMessage } from "@getpaseo/protocol/error-utils"; @@ -220,9 +220,26 @@ function buildUploadResponse(upload: PendingUpload, error: string | null): FileU }; } +// Most file systems cap a single file name at 255 bytes. +const MAX_FILE_NAME_BYTES = 255; + +// Keeps the client's file name, replacing only what cannot appear in a single +// file name on Linux, macOS, or Windows. function sanitizeFileName(value: string): string { const name = basename(value) - .replace(/[^a-zA-Z0-9._ -]/g, "_") + .replace(/[\p{Cc}\\/:*?"<>|]/gu, "_") .trim(); - return name.length > 0 && name !== "." && name !== ".." ? name : "upload"; + return fitFileNameLength(name.length > 0 && name !== "." && name !== ".." ? name : "upload"); +} + +function fitFileNameLength(name: string): string { + if (Buffer.byteLength(name) <= MAX_FILE_NAME_BYTES) return name; + const extension = extname(name); + const keptExtension = Buffer.byteLength(extension) < MAX_FILE_NAME_BYTES ? extension : ""; + let stem = ""; + for (const char of name.slice(0, name.length - keptExtension.length)) { + if (Buffer.byteLength(stem + char + keptExtension) > MAX_FILE_NAME_BYTES) break; + stem += char; + } + return stem + keptExtension; }