feat(files): raise the download cap to 2GB and stream /api/download

The 50MB cap on file-raw, the attachment /raw route and /api/download was
memory protection for a `readFile()` that no longer exists: file-raw and
/raw were rewritten to stream through `sendFileBody()` and answer Range
requests, so size costs a read stream rather than RSS (measured: a 600MB
download moved peak RSS by ~37MB). All the cap still did was refuse
legitimate downloads of build artifacts, videos and archives.

It is now MAX_FILE_DOWNLOAD_BYTES in config/buffer-limits.ts, default 2GB,
env CODEMAN_MAX_DOWNLOAD_BYTES, 0 = unlimited. `parseByteLimitEnv()` is
separate from the `parseInt(...) || default` idiom used elsewhere in that
file precisely because that idiom reads 0 as falsy and would silently
restore the default for the one value that means "no limit".

/api/download was the last route that really did buffer the whole file. It
now shares sendFileBody() with the other two, so it streams, advertises
Accept-Ranges, and is resumable. Its Content-Disposition also goes through
buildContentDisposition() rather than raw interpolation.

Refusals move from 400 to 413 across all three, which is the correct status
for the case; with the cap at 2GB it is a path almost nothing reaches now.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer
2026-09-10 02:57:07 +02:00
parent 5130ca6633
commit d4fe3afc9d
7 changed files with 118 additions and 54 deletions
+16 -2
View File
@@ -191,7 +191,9 @@ describe('file-raw range requests', () => {
});
it('still refuses files past the raw size cap before looking at Range', async () => {
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024, isFile: () => true } as never);
// 3GB, past the 2GB CODEMAN_MAX_DOWNLOAD_BYTES default. The cap is checked
// before the range, so a small slice of an oversized file is refused too.
mockedStat.mockResolvedValue({ size: 3 * 1024 * 1024 * 1024, isFile: () => true } as never);
const res = await harness.app.inject({
method: 'GET',
@@ -199,6 +201,18 @@ describe('file-raw range requests', () => {
headers: { range: 'bytes=0-99' },
});
expect(res.statusCode).toBe(400);
expect(res.statusCode).toBe(413);
});
it('serves a 100MB file that the historical 50MB cap would have refused', async () => {
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024, isFile: () => true } as never);
const res = await harness.app.inject({
method: 'GET',
url: rawUrl('big.mp4'),
headers: { range: 'bytes=0-99' },
});
expect(res.statusCode).toBe(206);
});
});
+32 -6
View File
@@ -802,14 +802,27 @@ describe('file-routes', () => {
expect(res.statusCode).toBe(404);
});
it('rejects overly large raw files', async () => {
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024 } as never); // 100MB
it('serves a file past the historical 50MB cap', async () => {
// The body is streamed and Range-aware, so size costs a read stream, not
// RSS. The old 50MB refusal only blocked legitimate artifact downloads.
mockedStat.mockResolvedValue({ size: 100 * 1024 * 1024, isFile: () => true } as never); // 100MB
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=huge.bin`,
});
expect(res.statusCode).toBe(400);
expect(res.statusCode).toBe(200);
});
it('still refuses a file past the configured download cap', async () => {
mockedStat.mockResolvedValue({ size: 3 * 1024 * 1024 * 1024, isFile: () => true } as never); // 3GB > 2GB default
const res = await harness.app.inject({
method: 'GET',
url: `/api/sessions/${harness.ctx._sessionId}/file-raw?path=enormous.bin`,
});
expect(res.statusCode).toBe(413);
expect(JSON.parse(res.body).error).toContain('CODEMAN_MAX_DOWNLOAD_BYTES');
});
});
@@ -863,8 +876,9 @@ describe('file-routes', () => {
});
it('downloads files scoped to the session working directory', async () => {
const content = Buffer.from('download content');
mockedReadFile.mockResolvedValue(content as never);
// The body is streamed (shared sendFileBody path), so the bytes come from
// the createReadStream mock rather than from readFile.
const content = Buffer.from('fake file bytes');
mockedStat.mockResolvedValue({ size: content.length, isFile: () => true } as never);
const res = await harness.app.inject({
@@ -874,7 +888,19 @@ describe('file-routes', () => {
expect(res.statusCode).toBe(200);
expect(res.headers['content-disposition']).toContain('filename="report.txt"');
expect(res.body).toBe('download content');
expect(res.headers['accept-ranges']).toBe('bytes');
expect(res.body).toBe('fake file bytes');
});
it('refuses a download past the configured cap', async () => {
mockedStat.mockResolvedValue({ size: 3 * 1024 * 1024 * 1024, isFile: () => true } as never); // 3GB > 2GB default
const res = await harness.app.inject({
method: 'GET',
url: `/api/download?sessionId=${harness.ctx._sessionId}&path=enormous.bin`,
});
expect(res.statusCode).toBe(413);
});
it('rejects absolute paths outside the session working directory', async () => {