feat(html-view): answer Range requests through the shared sendFileBody()

A rendered page's own <video> or <audio> could not seek, because the
route streamed a plain 200 without Accept-Ranges. sendFileBody() and
localFileSource() are now exported from file-routes.ts and the serving
route uses them, so it advertises Accept-Ranges and answers a Range with
206 + Content-Range (or 416), exactly like file-raw, the attachment raw
route and /api/download. The body sender hijacks the reply and copies
every header set before it (the sandbox CSP, CORS, no-store), which the
new test pins on a 206.

Media types stay in ASSET_TYPES: with Range support they work, and they
are served under the same sandbox CSP as everything else.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Codeman maintainer committed 2026-10-13 17:11:50 +02:00
1 parent bc940a3953
commit fa23f8be2a
5 files changed
+37 -8

No files matched your search

+1 -1
View File
@@ -311,7 +311,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph
**Git status indicator** (`showGitStatus`, per-device, default OFF; `src/git-workspace-status.ts`, `routes/git-status-routes.ts`, `git-status-ui.js`): the bottom-bar indicator and its panel read `GET /api/sessions/:id/git-status` and `/git-diff`, read-only and offline (it never fetches and never writes). ⚠️ git never runs on a repository at or inside a Docker case workspace (walk-up, scan and diff alike, and a cached repository list is re-checked against the CURRENT Docker roots), since a container could plant a clean filter that runs on the host; remote and Docker sessions answer `unsupported`. ⚠️ `git-diff` takes `repo`/`path`/`kind` only as keys matched against the workspace's own repository list (`findWorkspaceRepo()`) and that repository's current status, never as paths. ⚠️ Keep `--no-optional-locks`, `core.fsmonitor=false` and `log.showSignature=false` on every call and `--no-ext-diff --no-textconv` on diffs; clean filters still run, which is why the Docker rule exists. ⚠️ An enclosing repository at `$HOME` or above is ignored (`isUnrelatedAncestor()`), and is identified by one cached `rev-parse` before any full status runs. Everything git supplies renders via `textContent`. Tests: `test/git-workspace-status.test.ts`, `test/routes/git-status-routes.test.ts`, and `test/git-status.browser.test.ts` (browser suite, not in the gate).
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route and `GET /api/download` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view/<cap>/...` share `sendFileBody()`, advertise `Accept-Ranges: bytes` and answer `Range` with `206` + `Content-Range` (single-range, parser in `src/web/http-range.ts`); without it `<video>` cannot seek. The size cap (`MAX_FILE_DOWNLOAD_BYTES`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited) is a sanity bound, not memory protection; never reintroduce a whole-file buffer. ⚠️ Bodies go out via `reply.hijack()`, so `sendRawStream` must copy the status onto `reply.raw` by hand or a partial body ships as `200`. ⚠️ Closing the preview must pause and unload media (`_stopFilePreviewMedia`), since a detached `HTMLMediaElement` keeps playing. → [architecture-invariants#raw-file-bodies-streamed-and-range-aware](docs/architecture-invariants.md#raw-file-bodies-streamed-and-range-aware)
**Ultracode / workflow-run visualization** (opt-in, default OFF): the Workflow tool writes a completion artifact only at run *end*, so live in-flight runs exist solely as transcript dirs. `workflow-run-watcher.ts` therefore synthesizes ACTIVE runs from transcripts until the completion artifact appears and supersedes them. It is **STANDALONE** and deliberately never imports or touches `subagent-watcher.ts`, despite reading the same tree. Two independent toggles: `showUltracodeAgents` (docked panel) and `ultracodeFloatingWindows` (floating windows); the watcher starts if **either** is on. → [architecture-invariants#ultracode--workflow-run-visualization](docs/architecture-invariants.md#ultracode-and-workflow-run-visualization)
+1 -1
View File
@@ -658,7 +658,7 @@ Matching semantics: a query containing `/` matches the relative path, otherwise
### Raw file bodies: streamed and range-aware
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route and `GET /api/download` always advertise `Accept-Ranges: bytes` and answer a `Range` header with `206` + `Content-Range` (single-range only; parser is pure + unit-tested in `src/web/http-range.ts`, a malformed spec is ignored → 200 while an out-of-bounds one is a 416).
**Raw file bodies are streamed and range-aware**: `file-raw`, the attachments `/raw` route, `GET /api/download` and the rendered-HTML route `GET /html-view/<cap>/...` (so a rendered page's own `<video>`/`<audio>` can seek) always advertise `Accept-Ranges: bytes` and answer a `Range` header with `206` + `Content-Range` (single-range only; parser is pure + unit-tested in `src/web/http-range.ts`, a malformed spec is ignored → 200 while an out-of-bounds one is a 416).
⚠️ **The size cap on all three is a sanity bound, not memory protection** (`MAX_FILE_DOWNLOAD_BYTES` in `config/buffer-limits.ts`, default 2GB, env `CODEMAN_MAX_DOWNLOAD_BYTES`, `0` = unlimited): the bodies stream, so size costs a read stream and not RSS (measured: a 600MB download moved peak RSS by ~37MB). Its predecessor was a hardcoded 50MB whose comment still said "prevent memory exhaustion" long after the `readFile()` it described was replaced by `sendFileBody()`, so all it did was refuse legitimate downloads of build artifacts, videos and archives. `/api/download` was the last route that really did buffer the whole file, and now shares `sendFileBody()` with the other two.
+6 -3
View File
@@ -200,8 +200,8 @@ interface FileBodySource {
cleanup?: () => void;
}
/** Byte source for a LOCAL file (the historical, only path). */
function localFileSource(resolvedPath: string) {
/** Byte source for a LOCAL file (the historical, only path). Shared with html-view-routes.ts. */
export function localFileSource(resolvedPath: string) {
return (range?: { start: number; end: number }): FileBodySource => ({
content: createReadStream(resolvedPath, range ? { start: range.start, end: range.end } : undefined),
});
@@ -221,8 +221,11 @@ function localFileSource(resolvedPath: string) {
* `open` supplies the bytes for the (optional) range, which is what lets a remote
* case reuse this instead of re-implementing the 206/416 contract: same headers,
* same status codes, whether the file sits on this host or behind an ssh pipe.
*
* Exported for the rendered-HTML route (html-view-routes.ts), so a page's own
* `<video>`/`<audio>` can seek as well.
*/
function sendFileBody(
export function sendFileBody(
reply: FastifyReply,
size: number,
rangeHeader: string | string[] | undefined,
+7 -3
View File
@@ -25,7 +25,7 @@
*/
import type { FastifyInstance } from 'fastify';
import { createReadStream, realpathSync } from 'node:fs';
import { realpathSync } from 'node:fs';
import fs from 'node:fs/promises';
import { homedir, tmpdir } from 'node:os';
import { basename, dirname, extname, isAbsolute, relative, resolve, sep } from 'node:path';
@@ -34,6 +34,7 @@ import { isBlockedAttachmentPath, isUnderTree, loadAttachmentGuardConfig } from
import { HTML_VIEW_PREFIX, htmlViewCapabilities } from '../../html-view-capabilities.js';
import { CASES_DIR, findSessionOrFail, ownerFor, parseBody, validateSessionFilePath } from '../route-helpers.js';
import { HtmlViewOpenSchema } from '../schemas.js';
import { localFileSource, sendFileBody } from './file-routes.js';
import { getUserSpacesDir } from '../../config/multiuser.js';
import { exceedsDownloadLimit } from '../../config/buffer-limits.js';
import type { SessionPort } from '../ports/index.js';
@@ -254,7 +255,6 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
reply.header('Content-Security-Policy', SANDBOX_CSP);
reply.header('Content-Type', contentType);
reply.header('Content-Length', String(stat.size));
reply.header('Cache-Control', 'no-store');
reply.header('Referrer-Policy', 'no-referrer');
// The sandboxed page is opaque-origin, so even its fetch() of a sibling
@@ -262,6 +262,10 @@ export function registerHtmlViewRoutes(app: FastifyInstance, ctx: SessionPort):
// enough, and widens nothing: these bytes are already readable by anyone
// holding the capability URL.
reply.header('Access-Control-Allow-Origin', '*');
return reply.send(createReadStream(resolvedPath));
// The file routes' body sender: Content-Length, and `Range` answered with 206
// (or 416), so a page's own <video>/<audio> can seek. It hijacks the reply, and
// carries every header set above across to it.
sendFileBody(reply, stat.size, req.headers.range, localFileSource(resolvedPath));
return reply;
});
}
+22
View File
@@ -94,6 +94,28 @@ describe('html-view routes', () => {
expect(res.body).toBe('<h1>hi</h1>');
});
it('answers Range requests, so a page can seek its own media', async () => {
writeFileSync(join(workspace, 'clip.mp4'), '0123456789');
const base = (await openUrl()).replace(/index\.html$/, '');
const full = await harness.app.inject({ method: 'GET', url: `${base}clip.mp4` });
expect(full.statusCode).toBe(200);
expect(full.headers['accept-ranges']).toBe('bytes');
expect(full.headers['content-length']).toBe('10');
const part = await harness.app.inject({ method: 'GET', url: `${base}clip.mp4`, headers: { range: 'bytes=2-5' } });
expect(part.statusCode).toBe(206);
expect(part.headers['content-range']).toBe('bytes 2-5/10');
expect(part.body).toBe('2345');
// Every header the route sets survives the hijacked body sender.
expect(part.headers['content-type']).toBe('video/mp4');
expect(String(part.headers['content-security-policy'])).toMatch(/^sandbox /);
expect(part.headers['access-control-allow-origin']).toBe('*');
expect(part.headers['cache-control']).toBe('no-store');
const beyond = await harness.app.inject({ method: 'GET', url: `${base}clip.mp4`, headers: { range: 'bytes=50-' } });
expect(beyond.statusCode).toBe(416);
});
it('serves sibling assets, including subdirectories', async () => {
const base = (await openUrl()).replace(/index\.html$/, '');
expect((await harness.app.inject({ method: 'GET', url: `${base}assets/app.css` })).statusCode).toBe(200);