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

View File

@@ -8,10 +8,11 @@ import { apply, type ConnectionHandle } from '../src/client/index.ts'
import { FixtureApiClient } from '../src/client/fixture.ts'
import { WebApiClient } from '../src/client/web-api-client.ts'
type Win = { location?: { search: string } }
type Win = { location?: { search: string; protocol?: string; hostname?: string }; __DSH_FILES_PORT__?: number }
afterEach(() => {
delete (globalThis as Win).location
delete (globalThis as Win).__DSH_FILES_PORT__
})
async function mount(): Promise<ConnectionHandle> {
@@ -62,4 +63,28 @@ describe('connection client apply', () => {
}
expect(seen.some(u => u.includes('/api/'))).toBe(true)
})
it('addresses a workspace file on the port the host published, and only inside the workspace', async () => {
const win = globalThis as Win
win.location = { search: '', protocol: 'http:', hostname: '192.168.1.5' }
win.__DSH_FILES_PORT__ = 4321
const handle = await mount()
const session = 's-1' as never
// Same hostname the page was reached by — a LAN client must reach previews
// too — and the published port, which is what makes it another origin.
expect(handle.fileUrl(session, '/w/alpha', '/w/alpha/out/a b.html'))
.toBe('http://192.168.1.5:4321/f/s-1/out/a%20b.html')
// Outside the workspace there is nothing this transport may serve, which
// is the signal a caller falls back to openPath on.
expect(handle.fileUrl(session, '/w/alpha', '/etc/hosts')).toBeUndefined()
})
it('serves no file URL on a page no host published a port into', async () => {
const win = globalThis as Win
win.location = { search: '?fixture', protocol: 'http:', hostname: '127.0.0.1' }
const handle = await mount()
// The keyless fixture lane: no workspace-file origin exists, so the row
// falls back to the Host opener instead of opening a dead tab.
expect(handle.fileUrl('s-1' as never, '/w', 'a.txt')).toBeUndefined()
})
})

View File

@@ -0,0 +1,44 @@
/** The workspace-file listener's own failure and publication paths. */
import { describe, expect, it } from 'vitest'
import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api'
import { injectFilesPort, listenForWorkspaceFiles } from '../src/files-server.ts'
describe('workspace-file listener', () => {
it('answers 400 and reports the failure when the directory lookup throws', async () => {
const seen: Error[] = []
const files = await listenForWorkspaceFiles(
'127.0.0.1', [],
{ cwdFor: () => Promise.reject(new Error('store unavailable')) },
(error) => { seen.push(error) },
)
try {
// A lookup failure is the host's problem, not a miss: it must not become
// an unhandled rejection, and it must not be reported as "not found".
const response = await fetch(`http://127.0.0.1:${String(files.port)}${FILES_PATH}/s-1/a.txt`)
expect(response.status).toBe(400)
expect(seen.map(error => error.message)).toEqual(['store unavailable'])
} finally {
await files.close()
}
})
it('closes idempotently and stops answering', async () => {
const files = await listenForWorkspaceFiles(
'127.0.0.1', [], { cwdFor: async () => undefined }, () => {},
)
const origin = `http://127.0.0.1:${String(files.port)}`
expect((await fetch(`${origin}${FILES_PATH}/s-1/a.txt`)).status).toBe(404)
await files.close()
await files.close()
await expect(fetch(`${origin}${FILES_PATH}/s-1/a.txt`)).rejects.toThrow()
})
})
describe('injectFilesPort', () => {
it('publishes the port as the first script in head', () => {
const html = injectFilesPort('<html><head><title>x</title></head></html>', 4321)
expect(html).toContain('<head><script>window.__DSH_FILES_PORT__ = 4321</script>')
// Ahead of anything the shell might read it from.
expect(html.indexOf('__DSH_FILES_PORT__')).toBeLessThan(html.indexOf('<title>'))
})
})

View File

@@ -15,14 +15,21 @@ import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api'
import { API_PATH, apply, inject } from '../src/index.ts'
/** Structural httpServer fake: the plugin only touches register(). */
function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> {
function fakeHttpServer(
routes: WebRoute[],
taps: ((html: string) => string)[] = [],
): Pick<HttpServerService, 'register' | 'tapIndex' | 'port' | 'host'> {
return {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex: () => () => {},
tapIndex(transform) {
taps.push(transform)
return () => { taps.splice(taps.indexOf(transform), 1) }
},
port: 0,
host: '127.0.0.1',
}
}
@@ -61,21 +68,39 @@ function fakeApiProxy(workspaces: Record<string, string> = {}): ApiProxy {
async function mounted(
config?: { trustedHosts?: string[] },
workspaces: Record<string, string> = {},
): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
): Promise<{ routes: WebRoute[]; taps: ((html: string) => string)[]; dispose: () => Promise<void> }> {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
const taps: ((html: string) => string)[] = []
ctx.provide('httpServer', fakeHttpServer(routes, taps) as HttpServerService)
ctx.provide('apiProxy', fakeApiProxy(workspaces))
const fiber = ctx.plugin({ inject: [...inject], apply }, config)
await fiber.await()
return { routes, dispose: () => fiber.dispose() }
return { routes, taps, dispose: () => fiber.dispose() }
}
/** The /f route is registered after /api; both are prefix routes on the same server. */
function filesRoute(routes: WebRoute[]): WebRoute {
const route = routes.find(candidate => candidate.path === FILES_PATH)
if (route === undefined) throw new Error('the /f route was not registered')
return route
/** One raw GET whose Host header is spoofed (fetch forbids setting it). */
function statusWithHost(origin: string, path: string, host: string): Promise<number> {
const url = new URL(origin)
return new Promise((resolve, reject) => {
const request = httpRequest(
{ host: url.hostname, port: url.port, path, method: 'GET', headers: { host } },
(response) => {
response.resume()
response.on('end', () => { resolve(response.statusCode ?? 0) })
},
)
request.on('error', reject)
request.end()
})
}
/** The workspace-file origin the node half published into the index page. */
function filesOrigin(taps: ((html: string) => string)[]): string {
const html = taps.reduce((acc, tap) => tap(acc), '<head></head>')
const port = /__DSH_FILES_PORT__ = (\d+)/.exec(html)?.[1]
if (port === undefined) throw new Error(`no workspace-file port was published: ${html}`)
return `http://127.0.0.1:${port}`
}
describe('connection node half', () => {
@@ -89,11 +114,19 @@ describe('connection node half', () => {
expect(routes).toHaveLength(0)
})
it('registers both transport prefix routes and removes them with the fiber', async () => {
const { routes, dispose } = await mounted()
expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }, { kind: 'prefix', path: FILES_PATH }])
it('registers the /api route and publishes a separate workspace-file origin, both removed with the fiber', async () => {
const { routes, taps, dispose } = await mounted()
// The API keeps one prefix on the shared server; workspace files get a
// port of their own, which is the origin boundary between them.
expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }])
const origin = filesOrigin(taps)
expect(new URL(origin).port).not.toBe('')
expect((await fetch(`${origin}${FILES_PATH}/absent/x.txt`)).status).toBe(404)
await dispose()
expect(routes).toHaveLength(0)
expect(taps).toHaveLength(0)
// Disposal reaches quiescence: the socket is gone, not merely unrouted.
await expect(fetch(`${origin}${FILES_PATH}/absent/x.txt`)).rejects.toThrow()
})
it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
@@ -154,7 +187,7 @@ describe('connection node half', () => {
})
})
describe('connection node half: the /f workspace-file route', () => {
describe('connection node half: the workspace-file origin', () => {
/** A workspace holding one file, torn down with the returned disposer. */
async function workspace(): Promise<{ cwd: string; remove: () => Promise<void> }> {
const cwd = await mkdtemp(join(tmpdir(), 'dsh-node-half-'))
@@ -162,40 +195,35 @@ describe('connection node half: the /f workspace-file route', () => {
return { cwd, remove: () => rm(cwd, { recursive: true, force: true }) }
}
/** HEAD keeps the assertion on the route's decision, not on the byte stream. */
function head(url: string, headers: Record<string, string> = { host: '127.0.0.1:3080' }): IncomingMessage {
const request = fakeRequest(headers, url)
Object.assign(request, { method: 'HEAD' })
return request
}
it('applies the same browser-trust fence as /api, and refuses writes', async () => {
const { routes, dispose } = await mounted()
const untrusted = fakeResponse()
await filesRoute(routes).handler(head(`${FILES_PATH}/s-1/index.html`, { host: 'harness.example' }), untrusted.response)
expect(untrusted.state.status).toBe(403)
expect(untrusted.state.body).toBe('forbidden')
const written = fakeResponse()
const post = fakeRequest({ host: '127.0.0.1:3080' }, `${FILES_PATH}/s-1/index.html`)
Object.assign(post, { method: 'POST' })
await filesRoute(routes).handler(post, written.response)
expect(written.state.status).toBe(405)
expect(written.state.headers).toMatchObject({ allow: 'GET, HEAD' })
it('applies the same browser-trust fence as /api, refuses writes, and serves nothing else', async () => {
const { taps, dispose } = await mounted()
const origin = filesOrigin(taps)
// Rebound Host: refused before any filesystem work, exactly as on /api.
// node's fetch refuses to set Host (a forbidden header), so the spoof goes
// through the raw client — the same parse the server really performs.
expect(await statusWithHost(origin, `${FILES_PATH}/s-1/index.html`, 'harness.example')).toBe(403)
const written = await fetch(`${origin}${FILES_PATH}/s-1/index.html`, { method: 'POST' })
expect(written.status).toBe(405)
expect(written.headers.get('allow')).toBe('GET, HEAD')
// This origin is one route wide: no index, no SPA fallback, no API.
expect((await fetch(`${origin}/`)).status).toBe(404)
expect((await fetch(`${origin}${API_PATH}/session.list`, { method: 'POST' })).status).toBe(404)
await dispose()
})
it('confines reads to the directory the gateway names for that session', async () => {
const { cwd, remove } = await workspace()
const { routes, dispose } = await mounted(undefined, { 's-1': cwd })
const served = fakeResponse()
await filesRoute(routes).handler(head(`${FILES_PATH}/s-1/index.html`), served.response)
expect(served.state.status).toBe(200)
const { taps, dispose } = await mounted(undefined, { 's-1': cwd })
const origin = filesOrigin(taps)
const served = await fetch(`${origin}${FILES_PATH}/s-1/index.html`)
expect(served.status).toBe(200)
expect(await served.text()).toBe('<h1>ok</h1>')
// A served document keeps its own capabilities: the port is the boundary,
// so nothing here strips the document of its origin.
expect(served.headers.get('content-security-policy')).toBeNull()
// A session the gateway names no directory for has no workspace to confine
// against, so there is nothing to serve.
const unknown = fakeResponse()
await filesRoute(routes).handler(head(`${FILES_PATH}/s-absent/index.html`), unknown.response)
expect(unknown.state.status).toBe(404)
expect((await fetch(`${origin}${FILES_PATH}/s-absent/index.html`)).status).toBe(404)
await dispose()
await remove()
})

View File

@@ -59,27 +59,25 @@ function get(path: string, init?: RequestInit): Promise<Response> {
}
describe('workspace file reads', () => {
it('serves an active document into an opaque 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')
// A workspace file is not necessarily agent-authored, and same-origin
// script here would pass the browser-trust fence into every RPC method.
expect(response.headers.get('content-security-policy')).toContain('sandbox')
expect(response.headers.get('content-security-policy')).not.toContain('allow-same-origin')
// No isolation header: the listener's own port is the origin boundary, so
// a preview keeps localStorage and cookies (see files-server).
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 inert types unrestricted', 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('serves a workspace rooted at a filesystem root, whose realpath already ends in a separator', async () => {