Files
deepseek-harness/packages/client/connection/tests/node-half.spec.ts
ZiyaZhang 59bfe77fb8 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.
2026-08-01 02:17:25 -07:00

297 lines
13 KiB
TypeScript

/** Node half: registers the /api and /f prefix routes over the api gateway and the session workspaces. */
import { EventEmitter } from 'node:events'
import { createServer, request as httpRequest } from 'node:http'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Readable } from 'node:stream'
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import type { AddressInfo } from 'node:net'
import type { IncomingMessage, ServerResponse } from 'node:http'
import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver'
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[],
taps: ((html: string) => string)[] = [],
): Pick<HttpServerService, 'register' | 'tapIndex' | 'port' | 'host'> {
return {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex(transform) {
taps.push(transform)
return () => { taps.splice(taps.indexOf(transform), 1) }
},
port: 0,
host: '127.0.0.1',
}
}
/** Bodyless GET carrying the given headers (enough for the trust fence + bridge). */
function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session.list`): IncomingMessage {
const request = Readable.from([]) as unknown as IncomingMessage
Object.assign(request, { url, method: 'GET', headers })
return request
}
/** Response recorder compatible with both the fence's short-circuit and the bridge. */
function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown; headers?: Record<string, string> } } {
const state: { status?: number; body?: unknown; headers?: Record<string, string> } = {}
const response = Object.assign(new EventEmitter(), {
writableEnded: false,
writeHead(value: number, headers?: Record<string, string>) {
state.status = value
if (headers !== undefined) state.headers = headers
return this
},
write() { return true },
end(this: { writableEnded: boolean }, value?: unknown) {
if (value !== undefined) state.body = value
this.writableEnded = true
return this
},
}) as unknown as ServerResponse
return { response, state }
}
/** The gateway stub: only the session-directory authority the /f route reads. */
function fakeApiProxy(workspaces: Record<string, string> = {}): ApiProxy {
return { workspaceRootOf: async (id: string) => workspaces[id] } as unknown as ApiProxy
}
async function mounted(
config?: { trustedHosts?: string[] },
workspaces: Record<string, string> = {},
): Promise<{ routes: WebRoute[]; taps: ((html: string) => string)[]; dispose: () => Promise<void> }> {
const ctx = new Context()
const routes: WebRoute[] = []
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, taps, dispose: () => fiber.dispose() }
}
/** 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', () => {
it('fails the load on a trustedHosts entry that is not a bare authority', async () => {
const routes: WebRoute[] = []
const ctx = new Context()
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
ctx.provide('apiProxy', fakeApiProxy())
const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] })
await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/)
expect(routes).toHaveLength(0)
})
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 () => {
const { routes, dispose } = await mounted()
const { response, state } = fakeResponse()
await routes[0]!.handler(fakeRequest({
host: 'harness.example', origin: 'http://harness.example', 'sec-fetch-site': 'same-origin',
}), response)
expect(state.status).toBe(403)
expect(state.body).toBe('forbidden')
await dispose()
})
it('pins privileged methods to loopback even for a declared trusted authority', async () => {
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
// The privileged set: native dialogs plus the whole settings/credential
// configuration plane, reads included. The same declared authority reaches
// ordinary reads (carrier-level 404 from the empty proxy proves the fence
// passed), but each privileged method stays loopback-only and 403s.
for (const method of [
'host.pickDirectory', 'host.openPath',
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
]) {
const denied = fakeResponse()
await routes[0]!.handler(
fakeRequest({ host: 'harness.example' }, `${API_PATH}/${method}`),
denied.response,
)
expect(denied.state.status).toBe(403)
expect(denied.state.body).toBe('forbidden')
}
const read = fakeResponse()
await routes[0]!.handler(fakeRequest({ host: 'harness.example' }), read.response)
expect(read.state.status).not.toBe(403)
await dispose()
})
it('passes loopback and declared-authority requests through to the bridge', async () => {
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example:3080', '192.168.1.5'] })
// Loopback, no browser markers (curl shape): the fence passes; the carrier
// answers 404 for a GET unary path — proof the bridge ran.
const loopback = fakeResponse()
await routes[0]!.handler(fakeRequest({ host: '127.0.0.1:3080' }), loopback.response)
expect(loopback.state.status).toBe(404)
// LAN authority declared as a port-less IP literal — the shape the CLI
// derives for `--host 0.0.0.0` — passes markerless curl on any port.
const lan = fakeResponse()
await routes[0]!.handler(fakeRequest({ host: '192.168.1.5:3080' }), lan.response)
expect(lan.state.status).toBe(404)
// Declared public authority, same-origin browser shape.
const declared = fakeResponse()
await routes[0]!.handler(fakeRequest({
host: 'harness.example:3080', origin: 'http://harness.example:3080', 'sec-fetch-site': 'same-origin',
}), declared.response)
expect(declared.state.status).toBe(404)
await dispose()
})
})
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-'))
await writeFile(join(cwd, 'index.html'), '<h1>ok</h1>')
return { cwd, remove: () => rm(cwd, { recursive: true, force: true }) }
}
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 { 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.
expect((await fetch(`${origin}${FILES_PATH}/s-absent/index.html`)).status).toBe(404)
await dispose()
await remove()
})
})
describe('connection node half over a real HTTP server', () => {
/** Serve the registered prefix route from a real server and return its port. */
async function serve(routes: WebRoute[]): Promise<{ port: number; close: () => Promise<void> }> {
const server = createServer((request, response) => {
void routes[0]!.handler(request, response)
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
const address = server.address() as AddressInfo
return {
port: address.port,
close: () => new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error === undefined || error === null) resolve()
else reject(error)
})
}),
}
}
/** One real request; `host` spoofs the authority the way a LAN client's browser would send it. */
function call(port: number, method: string, host: string): Promise<number> {
return new Promise((resolve, reject) => {
const request = httpRequest(
{ host: '127.0.0.1', port, path: `${API_PATH}/${method}`, method: 'GET', headers: { host } },
(response) => {
response.resume()
response.on('end', () => { resolve(response.statusCode ?? 0) })
},
)
request.on('error', reject)
request.end()
})
}
it('answers a declared LAN authority with 403 on every configuration method, over real HTTP', async () => {
// The fence's input is a real IncomingMessage parsed by Node from the
// wire, not a hand-assembled object: the Host header a LAN browser sends
// is exactly what decides loopback-only here, so the boundary is asserted
// against the parse the server actually performs.
const { routes, dispose } = await mounted({ trustedHosts: ['harness.example'] })
const { port, close } = await serve(routes)
try {
// Reads are as privileged as writes: describe returns the exposed
// configuration, and credentials.describe probes arbitrary env-var names.
for (const method of [
'settings.describe', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
'host.pickDirectory', 'host.openPath',
]) {
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 403])
}
// The model catalog stays reachable for the same authority: a LAN
// client's model picker needs it, and it carries no key or endpoint
// state (404 is the empty proxy's carrier answer — the fence passed).
for (const method of ['llm.providers', 'llm.models']) {
expect([method, await call(port, method, 'harness.example')]).toEqual([method, 404])
}
// Loopback reaches everything, configuration included.
expect(await call(port, 'settings.describe', `127.0.0.1:${String(port)}`)).toBe(404)
} finally {
await close()
await dispose()
}
})
})