Download files whose names have non-ASCII characters (#6278)

* fix(server): encode Unicode download filenames in Content-Disposition

* test(server): cover Korean download filenames

* test(server): validate download token and agent ID formats

* fix(server): set the download header through Express instead of a new dependency

The download route already runs on an Express response, and res.attachment()
builds Content-Disposition with the same content-disposition encoder that
Express ships. Use it and drop the direct content-disposition dependency and
its type package. The header values the download tests assert are unchanged.

Co-authored-by: jasonhnd <3293256+jasonhnd@users.noreply.github.com>

---------

Co-authored-by: Dillon <dillonzq@outlook.com>
Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Co-authored-by: jasonhnd <3293256+jasonhnd@users.noreply.github.com>
This commit is contained in:
paseo-bot[bot]
2026-10-07 06:59:53 +02:00
committed by GitHub
co-authored by jasonhnd Dillon
parent a162e1075f
commit 1e00f452ea
2 changed files with 61 additions and 31 deletions
+1 -2
View File
@@ -836,9 +836,8 @@ export async function createPaseoDaemon(
return;
}
const safeFileName = entry.fileName.replace(/["\r\n]/g, "_");
res.attachment(entry.fileName);
res.setHeader("Content-Type", entry.mimeType);
res.setHeader("Content-Disposition", `attachment; filename="${safeFileName}"`);
res.setHeader("Content-Length", fileStats.size.toString());
const stream = fileHandle.createReadStream();
@@ -24,42 +24,73 @@ describe("daemon E2E", () => {
}, 60000);
describe("file download tokens", () => {
test("issues token over WS and downloads via HTTP", async () => {
const cwd = tmpCwd();
const filePath = path.join(cwd, "download.txt");
const fileContents = "download test payload";
writeFileSync(filePath, fileContents, "utf-8");
test.each([
{ fileName: "download.txt", disposition: 'attachment; filename="download.txt"' },
{
fileName: "中文报告 (最终版).txt",
disposition:
"attachment; filename=\"???? (???).txt\"; filename*=UTF-8''%E4%B8%AD%E6%96%87%E6%8A%A5%E5%91%8A%20%28%E6%9C%80%E7%BB%88%E7%89%88%29.txt",
},
{
fileName: "報告書.md",
disposition:
"attachment; filename=\"???.md\"; filename*=UTF-8''%E5%A0%B1%E5%91%8A%E6%9B%B8.md",
},
{
fileName: "보고서.txt",
disposition:
"attachment; filename=\"???.txt\"; filename*=UTF-8''%EB%B3%B4%EA%B3%A0%EC%84%9C.txt",
},
{
fileName: "report-📄.txt",
disposition:
"attachment; filename=\"report-??.txt\"; filename*=UTF-8''report-%F0%9F%93%84.txt",
},
])(
"issues token over WS and downloads $fileName via HTTP",
async ({ fileName, disposition }) => {
const cwd = tmpCwd();
try {
const filePath = path.join(cwd, fileName);
const fileContents = "download test payload";
writeFileSync(filePath, fileContents, "utf-8");
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Download Token Test Agent",
});
const agent = await ctx.client.createAgent({
provider: "codex",
model: CODEX_TEST_MODEL,
thinkingOptionId: CODEX_TEST_THINKING_OPTION_ID,
cwd,
title: "Download Token Test Agent",
});
expect(agent.id).toBeTruthy();
expect(agent.id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
const tokenResponse = await ctx.client.requestDownloadToken(cwd, "download.txt");
const tokenResponse = await ctx.client.requestDownloadToken(cwd, fileName);
expect(tokenResponse.error).toBeNull();
expect(tokenResponse.token).toBeTruthy();
expect(tokenResponse.fileName).toBe("download.txt");
expect(tokenResponse.error).toBeNull();
expect(tokenResponse.token).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
expect(tokenResponse.fileName).toBe(fileName);
const response = await fetch(
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`,
);
const response = await fetch(
`http://127.0.0.1:${ctx.daemon.port}/api/files/download?token=${tokenResponse.token}`,
);
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType);
const disposition = response.headers.get("content-disposition") ?? "";
expect(disposition).toContain("download.txt");
expect(response.status).toBe(200);
expect(response.headers.get("content-type")).toBe(tokenResponse.mimeType);
expect(response.headers.get("content-disposition")).toBe(disposition);
const body = await response.text();
expect(body).toBe(fileContents);
rmSync(cwd, { recursive: true, force: true });
}, 60000);
const body = await response.text();
expect(body).toBe(fileContents);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
},
60000,
);
test("rejects invalid token", async () => {
const response = await fetch(