cleanup(web): stop sandboxing served workspace documents

A preview lost localStorage and cookies under CSP sandbox — measurably, the
reported artifact throws SecurityError on load and its theme toggle goes
dead. The capability the sandbox denied is one the file's author, an agent
already holding this user's shell, never needed the browser for, so the
header sat behind a trust boundary it had already crossed.

Isolating a preview becomes a real question when workspace content stops
being the viewer's own; the answer then is a separate origin, not a header.
This commit is contained in:
ZiyaZhang
2026-07-31 23:20:36 -07:00
parent 35e9122a65
commit f5d53f04b7
8 changed files with 26 additions and 33 deletions

View File

@@ -58,26 +58,25 @@ function get(path: string, init?: RequestInit): Promise<Response> {
}
describe('workspace file reads', () => {
it('serves a produced document with the sandbox that keeps it off this origin', async () => {
it('serves a produced document with its own capabilities intact', async () => {
const response = await get(`${FILES_PATH}/${SESSION}/index.html`)
expect(response.status).toBe(200)
expect(await response.text()).toBe('<h1>产物</h1>')
expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8')
// The whole reason a model-authored page may be served from the RPC
// origin: an opaque origin cannot read /api/events.mux.
expect(response.headers.get('content-security-policy')).toContain('sandbox')
// No isolation header: a preview keeps localStorage and cookies, because
// the file's author already holds this user's shell (see the module doc).
expect(response.headers.get('content-security-policy')).toBeNull()
expect(response.headers.get('x-content-type-options')).toBe('nosniff')
expect(response.headers.get('cache-control')).toBe('no-store')
expect(response.headers.get('content-disposition')).toBe('inline')
})
it('sandboxes SVG too, and leaves non-scriptable types alone', async () => {
it('types SVG as a standalone document rather than sniffable bytes', async () => {
const svg = await get(`${FILES_PATH}/${SESSION}/chart.svg`)
expect(svg.headers.get('content-type')).toBe('image/svg+xml')
expect(svg.headers.get('content-security-policy')).toContain('sandbox')
expect(svg.headers.get('x-content-type-options')).toBe('nosniff')
const text = await get(`${FILES_PATH}/${SESSION}/notes.txt`)
expect(text.headers.get('content-type')).toBe('text/plain; charset=utf-8')
expect(text.headers.get('content-security-policy')).toBeNull()
})
it('shows an unknown extension as text rather than downloading it', async () => {