From 9414397568d3cd16a487399fefbc52c6dc6e15d2 Mon Sep 17 00:00:00 2001 From: William Wang Date: Sun, 23 Aug 2026 13:35:55 +0800 Subject: [PATCH] feat: name byte-path file downloads via Content-Disposition --- CHANGELOG.md | 11 +++++++++++ package.json | 2 +- src/remote/file-endpoint.ts | 21 +++++++++++++++++++-- tests/remote-file-endpoint.test.ts | 26 ++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bbed940..877716e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.11.6] - 2026-08-23 + +### Added + +- Remote file downloads carry a `Content-Disposition` filename: the byte + path's URL basename is always "file" (the real name lives in a query + param), so browser direct-downloads saved nameless. Responses now send + `inline; filename="…"; filename*=UTF-8''…` (RFC 6266/5987 — non-ASCII + names survive via the `filename*` form, the ASCII fallback degrades to + underscores). Line windows (viewer-internal partial views) stay unnamed. + ## [0.11.5] - 2026-08-22 ### Fixed diff --git a/package.json b/package.json index c24e9d7..ae86216 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zcode-acp-server", - "version": "0.11.5", + "version": "0.11.6", "description": "Agent Client Protocol (ACP) server bridging headless ZCode to editors like Zed and JetBrains.", "type": "module", "license": "Apache-2.0", diff --git a/src/remote/file-endpoint.ts b/src/remote/file-endpoint.ts index d3dec2d..97e545b 100644 --- a/src/remote/file-endpoint.ts +++ b/src/remote/file-endpoint.ts @@ -91,6 +91,17 @@ function mimeFor(file: string): string { return MIME_BY_EXT[path.extname(file).toLowerCase()] ?? "application/octet-stream"; } +/** + * Content-Disposition for the byte path. The URL's basename is "file" (the + * real name lives in a query param), so without this header browsers save + * direct-link downloads nameless. `inline` keeps previewable types rendering + * in-browser; the filename* form preserves non-ASCII names (RFC 6266/5987). + */ +function contentDisposition(name: string): string { + const ascii = name.replace(/["\\]/g, "_").replace(/[^\x20-\x7e]/g, "_"); + return `inline; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`; +} + function sendText(res: ServerResponse, code: number, message: string): void { if (res.writableEnded) return; res.writeHead(code, { "Content-Type": "text/plain" }); @@ -335,7 +346,10 @@ async function handleFile( if (head) { // Same headers the GET would send, no body. - const headers: Record = { "Content-Type": mime }; + const headers: Record = { + "Content-Type": mime, + "Content-Disposition": contentDisposition(path.basename(file)), + }; if (hasByte) { headers["Content-Range"] = `bytes ${start}-${end}/${s.size}`; headers["Content-Length"] = String(end! - start + 1); @@ -354,7 +368,10 @@ async function handleFile( res.destroy(); }); req.on("close", () => stream.destroy()); - const headers: Record = { "Content-Type": mime }; + const headers: Record = { + "Content-Type": mime, + "Content-Disposition": contentDisposition(path.basename(file)), + }; if (hasByte) { headers["Content-Range"] = `bytes ${start}-${end}/${s.size}`; headers["Content-Length"] = String(end! - start + 1); diff --git a/tests/remote-file-endpoint.test.ts b/tests/remote-file-endpoint.test.ts index 136868a..f2b137f 100644 --- a/tests/remote-file-endpoint.test.ts +++ b/tests/remote-file-endpoint.test.ts @@ -152,6 +152,32 @@ describe("session files over the hub proxy", () => { expect(await res.text()).toBe(content.slice(2, 6)); }); + it("names byte-path downloads via Content-Disposition", async () => { + const { base, dir } = await spawnFixture(); + await writeFile(path.join(dir, "年度报告.md"), "# 总结\n"); + + const res = await fsFetch(base, "/file?sessionId=s-fs&path=README.md"); + expect(res.status).toBe(200); + expect(res.headers.get("content-disposition")).toBe( + `inline; filename="README.md"; filename*=UTF-8''README.md`, + ); + + // Non-ASCII names survive via the RFC 5987 filename* form; the ASCII + // fallback degrades to underscores instead of mojibake. + const zh = await fsFetch( + base, + `/file?sessionId=s-fs&path=${encodeURIComponent("年度报告.md")}`, + ); + expect(zh.status).toBe(200); + expect(zh.headers.get("content-disposition")).toBe( + `inline; filename="____.md"; filename*=UTF-8''${encodeURIComponent("年度报告.md")}`, + ); + + // Line windows are viewer-internal partial views — no download name. + const win = await fsFetch(base, "/file?sessionId=s-fs&path=README.md&line=1&limit=1"); + expect(win.headers.get("content-disposition")).toBeNull(); + }); + it("serves text line windows with X-Zcode-First-Line", async () => { const { base } = await spawnFixture(); const res = await fsFetch(base, "/file?sessionId=s-fs&path=README.md&line=2&limit=1");