feat(web): serve workspace files from their own origin

A sandbox header bought isolation by taking the document's origin away, and
measuring that cost decided against it: the reported artifact throws
SecurityError on load, and because an uncaught exception aborts the rest of
its <script>, every listener declared after that line — theme toggle, mobile
menu, model tabs — never binds. Two of the four artifacts in the reporting
user's workspace were dead pages under it, and they still looked right.

A second listener on the API's host, answering /f and nothing else, is the
same boundary without the amputation: cross-origin to /api (refused by the
Origin fence and by CORS), same-origin with itself (localStorage, cookies and
fetch all work). Its port is published into the index page; the browser half
reads it to address previews, and its absence — the keyless fixture lane — is
what makes a file row fall back to the Host opener instead of a dead tab.

fileUrl moves from IWorkspaces to ConnectionHandle: the transport owns both
the listener that serves the bytes and the port that addresses it.
This commit is contained in:
ZiyaZhang
2026-08-01 02:17:25 -07:00
parent 1082518520
commit 59bfe77fb8
37 changed files with 469 additions and 197 deletions
@@ -26,6 +26,7 @@ async function mount(): Promise<Bench> {
const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
const handle: ConnectionHandle = {
api,
fileUrl: () => undefined,
start: (sinks) => {
bench.sinks = sinks
return { stop: () => { bench.stopped += 1 } }
@@ -20,6 +20,7 @@ async function mount(): Promise<Bench> {
const bench: Bench = { ctx, sinks: undefined }
const handle: ConnectionHandle = {
api,
fileUrl: () => undefined,
start: (sinks) => {
bench.sinks = sinks
return { stop: () => {} }
@@ -276,21 +276,6 @@ describe('WorkspacesService', () => {
await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/)
})
it('addresses a workspace file by URL, and only inside the workspace', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
const session = 's-1' as SessionId
// The URL is derived, not fetched: no wire call answers a link.
expect(workspaces.fileUrl(session, '/w/alpha', '/w/alpha/out/a b.html')).toBe('/f/s-1/out/a%20b.html')
expect(workspaces.fileUrl(session, '/w/alpha', 'out/index.html')).toBe('/f/s-1/out/index.html')
// Outside the workspace there is nothing this transport may serve, which
// is the signal a caller falls back to openPath on.
expect(workspaces.fileUrl(session, '/w/alpha', '/etc/hosts')).toBeUndefined()
expect(api.calls).toHaveLength(0)
})
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
const ctx = new Context()
const api = new FakeApiClient()