Skip to content

Commit 671a38f

Browse files
authored
fix(agents-server): sanitize attachment filename fallback
1 parent 5060b49 commit 671a38f

3 files changed

Lines changed: 43 additions & 1 deletion

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@electric-ax/agents-server": patch
3+
---
4+
5+
Sanitize attachment Content-Disposition filename fallbacks to avoid ByteString errors for Unicode filenames.

packages/agents-server/src/routing/entities-router.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -658,7 +658,12 @@ async function parseAttachmentForm(
658658
}
659659

660660
function contentDisposition(filename: string): string {
661-
const fallback = filename.replace(/["\\\r\n]/g, `_`)
661+
// Header values are converted to WebIDL ByteString by undici, so every
662+
// character in the raw header value must fit in a single byte. Keep the
663+
// RFC 5987 filename* parameter for the full UTF-8 filename, but make the
664+
// legacy filename fallback ASCII-only to avoid throwing on names containing
665+
// e.g. narrow no-break spaces or emoji.
666+
const fallback = filename.replace(/[^\x20-\x7e]|["\\]/g, `_`)
662667
return `attachment; filename="${fallback}"; filename*=UTF-8''${encodeURIComponent(filename)}`
663668
}
664669

packages/agents-server/test/electric-agents-routes.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,38 @@ describe(`ElectricAgentsRoutes schedule endpoints`, () => {
248248
})
249249
})
250250

251+
describe(`ElectricAgentsRoutes attachment endpoints`, () => {
252+
it(`serves attachments with non-ASCII filenames without throwing`, async () => {
253+
const manager = {
254+
registry: {
255+
getEntity: vi.fn().mockResolvedValue({ url: `/chat/test` }),
256+
getEntityType: vi.fn(),
257+
},
258+
readAttachment: vi.fn().mockResolvedValue({
259+
attachment: {
260+
mimeType: `image/png`,
261+
filename: `Screenshot 2026-06-09 at 12.09.29 PM.png`,
262+
},
263+
bytes: new Uint8Array([1, 2, 3]),
264+
}),
265+
} as any
266+
267+
const response = await routeResponse(
268+
manager,
269+
`GET`,
270+
`/_electric/entities/chat/test/attachments/att-1`
271+
)
272+
273+
expect(response.status).toBe(200)
274+
expect(response.headers.get(`content-disposition`)).toBe(
275+
`attachment; filename="Screenshot 2026-06-09 at 12.09.29_PM.png"; filename*=UTF-8''Screenshot%202026-06-09%20at%2012.09.29%E2%80%AFPM.png`
276+
)
277+
await expect(response.arrayBuffer()).resolves.toEqual(
278+
new Uint8Array([1, 2, 3]).buffer
279+
)
280+
})
281+
})
282+
251283
describe(`ElectricAgentsRoutes cron stream ensure endpoint`, () => {
252284
it(`rejects cron ensure requests without an expression in the schema layer`, async () => {
253285
const manager = {

0 commit comments

Comments
 (0)