Public: anyone can read this; the crew can change it.
An .html file in a repository previews as a page in a sandboxed frame, with Source one tap away #10927
Check · Review · Land · Live done — Landed and live in production.
An `.html` or `.htm` file now previews as a page in a sandboxed frame on its blob page, with Source one tap away. Relative images and stylesheets load through the media route, but a path that climbs out of the repository is not refused (details below). Gate, `tsc`, design-lint and the new and touched tests pass. I did not run the whole suite: `scripts/fast.ts` named 1,692 test files, so I ran the…
shipped production is serving it deploy befa93c
An .html or .htm file now previews as a page in a sandboxed frame on its blob page, with Source one tap away. Relative images and stylesheets load through the media route, but a path that climbs out of the repository is not refused (details below). Gate, tsc, design-lint and the new and touched tests pass. I did not run the whole suite: scripts/fast.ts named 1,692 test files, so I ran the files covering this change instead. render-kinds, render-pdf, render-media, blob-media-ceiling, blob-window, security-xss and render-font passed. security-floor-routes logged git-spawn errors in the sandbox and I could not read its verdict. I did not see the forge's check result.
What changed
- Kind and cap:
htmlis a kind inkinds.tswith a 2 MB cap and is drawn from the media route like a PDF. The extension decides, and a file with a NUL byte in its head is still binary. - Blob page: Rendered is the default, with Source, Raw and Download beside it.
render/html_preview.tsdraws a bare<iframe sandbox="">whosesrcis the media route, so the file's bytes never enter the forge page's markup. Over the cap the page shows Source and Download only. - Frame policy: only that page's CSP gains
frame-src 'self', through a newframeSelfoption plus a cachedframeflag. Every other page's policy is unchanged. - Media route: an html file goes out as
text/html; charset=utf-8only underHTML_POLICY:
sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline' 'self'; frame-ancestors 'self'.
It also carries nosniff and X-Frame-Options: SAMEORIGIN. ?download=1 and any file with NUL bytes stay application/octet-stream. I added frame-ancestors 'self' beyond the issue's string, so only the forge's own pages can frame it.
- Layout: the frame is 80vh tall (60vh at 390 px) and set to
color-scheme: light, so a page written for white stays white in either theme. I did not look at it in a browser. - Tests:
test/html_preview.test.tsis new. It covers the registry, the media response, the frame and switch on the blob page, no<script>in either view's markup,frame-srconly on that page, and no frame over the cap.
Two existing tests I changed. render-kinds and media asserted that HTML is never served as text/html. That invariant is now deliberately relaxed for .html and .htm only. media.test.ts used page.html as its "off the allowlist" example, so I renamed that fixture to page.txt.
What I left out
- Walk-outs: I did not apply the
gltf-uri.tsrule. It runs in a client-side loader, and a static HTML page has no hook to run it. The browser resolves../../xagainst the media URL before any request, so the server never sees the climb. Inside the sandbox the CSP still limits such a request to an image or stylesheet GET on this origin, and the media route refuses..and encoded traversal in its own path. Refusing walk-outs fully would need a rewrite of the file's links or a path-scoped CSP, which the specified policy string rules out. Say if you want either. - Plan tool: I could not post the plan on the issue because
keep_planwas not available here.
Try it: commit an index.html with a relative <img> and a <script>, open it under Code, and use the Rendered | Source switch. The script does not run, and opening /media/<ref>/index.html directly shows the same sandboxed page.
🤖 Generated with Claude Code
The frame, the sandbox policy and the tests are sound, but three things block this. Fix first: the walk-out rule (box 3). The builder says it is not applied. Then: (1) both doors: nothing in src/tui/ and no docs/features/html-preview.md say how an .html file shows in the terminal (source view, with the preview a web-only door, stated), and the how-to belongs in docs/features, not README. (2) Box 3: a relative path that climbs out of the repository is not refused. Resolve it in the media route: serve a path-scoped CSP or rewrite the file's relative src/href values through the gltf-uri.ts rule before serving, and add a test that a ../../ image or stylesheet reference does not load. (3) Design: see the line comments on principles 6 and the misplaced comment. I did not verify the 390px layout or both themes in the preview; add a test or note for box 4.
An .html file now previews in a sandboxed frame, and a relative path that climbs out of the repository no longer loads. The frame, sandbox="" and the media route's sandbox policy are unchanged. Type-check, gate and design-lint are clean. The html-preview, TUI workspace, media and render-kinds tests pass; I did not run the full suite.
What changed
- Walk-out rule (box 3). The media route now builds the HTML policy per request.
htmlPolicy(scope)replaces the constantHTML_POLICY, andscopeis the public origin plus/owner/name/media/<ref>/.
- img-src and style-src allow only that scope, so 'self' is gone. A ../../ reference to another repository or to a forge page resolves outside the scope and the browser refuses it.
- The test resolves relative references against the frame address. Inside the repository loads; out of it does not; the policy no longer falls back to 'self'. It checks the policy string only. No browser loaded a real ../../ image.
- A file can still load images and styles from elsewhere in the same repository at the same ref, not only from beside it.
- Both doors.
- A terminal already opens a text .html file as its source, so src/tui/ needed no behaviour change. I added a note in workspace.ts and a test in test/tui-workspace.test.ts that the terminal shows the markup.
- docs/features/html-preview.md is new. It covers the web preview and the terminal, and I did not touch the README.
- Design.
- The card and .html-view wrapper are gone. The frame has its own hairline (border: 1px solid var(--border)).
- The HTML rules now sit above the PDF comment, each block with its own one-line comment.
- I dropped a #fff background that design-lint rejected. color-scheme: light already gives the frame a white canvas.
- docs/design/areas/code.md is updated to match.
- Box 4. I could not check 390px or either theme in a browser. A test asserts the CSS instead: the fixed 80vh height, the
max-width: 600pxrule (60vh), the hairline,color-scheme: light, and no card in the markup. The feature doc states that the layout is the same in both themes and that no layout shifts at 390px.
Left out
- The public origin comes from
config.url, else the request URL. That depends on a correctconfig.urlbehind a proxy.
Earlier asks are met. (1) Walk-outs: htmlPolicy(scope) limits img-src and style-src to this repository's media path at the ref, and the test shows a ../../ reference resolves outside it. (2) Both doors: docs/features/html-preview.md exists, the README is untouched, and a terminal test shows the file opens as source. (3) Design: the card is gone, the frame has one hairline, and the area doc is updated. The preview root answers 200. The sandbox is two-layered: sandbox="" on the iframe and the CSP sandbox on the media route, with nosniff, and the file bytes never enter the blob page. I read the diff and the preview root, not a browser, so 390px and both themes rest on the CSS assertions.
Two things to do next:
- Non-blocking: the scope comes from config.url, so a wrong config.url behind a proxy would silently block every relative image. Add a test or a boot warning for that case.
- Non-blocking: a sandboxed frame has an opaque origin, so it probably sends no session cookie on subresource requests. Relative images in a private repository may then 404. Check this in the preview with a private repo, and say so in the feature doc if it is true.
The CSP differs from the issue string on purpose: it uses the repository scope instead of 'self', which is stricter, and it adds frame-ancestors.
An
.htmlor.htmfile now previews as a page in a sandboxed frame on its blob page, with Source one tap away. Relative images and stylesheets load through the media route, but a path that climbs out of the repository is not refused (details below). Gate,tsc, design-lint and the new and touched tests pass. I did not run the whole suite:scripts/fast.tsnamed 1,692 test files, so I ran the files covering this change instead.render-kinds,render-pdf,render-media,blob-media-ceiling,blob-window,security-xssandrender-fontpassed.security-floor-routeslogged git-spawn errors in the sandbox and I could not read its verdict. I did not see the forge's check result.What changed
htmlis a kind inkinds.tswith a 2 MB cap and is drawn from the media route like a PDF. The extension decides, and a file with a NUL byte in its head is still binary.render/html_preview.tsdraws a bare<iframe sandbox="">whosesrcis the media route, so the file's bytes never enter the forge page's markup. Over the cap the page shows Source and Download only.frame-src 'self', through a newframeSelfoption plus a cachedframeflag. Every other page's policy is unchanged.text/html; charset=utf-8only underHTML_POLICY:sandbox; default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline' 'self'; frame-ancestors 'self'. It also carriesnosniffandX-Frame-Options: SAMEORIGIN.?download=1and any file with NUL bytes stayapplication/octet-stream. I addedframe-ancestors 'self'beyond the issue's string, so only the forge's own pages can frame it.color-scheme: light, so a page written for white stays white in either theme. I did not look at it in a browser.test/html_preview.test.tsis new. It covers the registry, the media response, the frame and switch on the blob page, no<script>in either view's markup,frame-srconly on that page, and no frame over the cap.Two existing tests I changed.
render-kindsandmediaasserted that HTML is never served astext/html. That invariant is now deliberately relaxed for.htmland.htmonly.media.test.tsusedpage.htmlas its "off the allowlist" example, so I renamed that fixture topage.txt.What I left out
gltf-uri.tsrule. It runs in a client-side loader, and a static HTML page has no hook to run it. The browser resolves../../xagainst the media URL before any request, so the server never sees the climb. Inside the sandbox the CSP still limits such a request to an image or stylesheet GET on this origin, and the media route refuses..and encoded traversal in its own path. Refusing walk-outs fully would need a rewrite of the file's links or a path-scoped CSP, which the specified policy string rules out. Say if you want either.keep_planwas not available here.Try it: commit an
index.htmlwith a relative<img>and a<script>, open it under Code, and use the Rendered | Source switch. The script does not run, and opening/media/<ref>/index.htmldirectly shows the same sandboxed page.🤖 Generated with Claude Code
Closes #10897