/** 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 { 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, 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 } } { const state: { status?: number; body?: unknown; headers?: Record } = {} const response = Object.assign(new EventEmitter(), { writableEnded: false, writeHead(value: number, headers?: Record) { 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 = {}): ApiProxy { return { workspaceRootOf: async (id: string) => workspaces[id] } as unknown as ApiProxy } async function mounted( config?: { trustedHosts?: string[] }, workspaces: Record = {}, ): Promise<{ routes: WebRoute[]; taps: ((html: string) => string)[]; dispose: () => Promise }> { 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 { 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), '') 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 }> { const cwd = await mkdtemp(join(tmpdir(), 'dsh-node-half-')) await writeFile(join(cwd, 'index.html'), '

ok

') 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('

ok

') // 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 }> { const server = createServer((request, response) => { void routes[0]!.handler(request, response) }) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) const address = server.address() as AddressInfo return { port: address.port, close: () => new Promise((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 { 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() } }) })