feat(web): open a produced file from the conversation

Serve one file at a time out of a Session's workspace under /f on the web
transport, and point the conversation's existing file-open affordance at it.
Clicking a write/edit/read row's path now opens that file in a browser tab —
including from a LAN client, where the Host's system opener is fenced to
loopback and answered nothing.

- /f/<sessionId>/<segments> in client-connection, behind the same
  browser-trust fence as /api; realpath confinement, streamed reads,
  GET/HEAD only, nosniff + no-store.
- Script-capable documents carry CSP sandbox: model-authored markup must not
  be same-origin with /api, where events.mux is a readable GET stream.
- ApiProxy.workspaceRootOf answers where a Session's files live without
  resuming an agent; the client program cannot reach the core services.
- The /f URL shape lives in dsh-host-apiproxy/api so both ends share one
  encoding (client bundles may not value-import another plugin).
This commit is contained in:
ZiyaZhang
2026-07-31 12:07:43 -07:00
parent 992fdc0cee
commit 00390ae851
35 changed files with 946 additions and 30 deletions

View File

@@ -1,6 +1,9 @@
/** Node half: registers the /api prefix route bridging to the api gateway. */
/** 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'
@@ -8,6 +11,7 @@ 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(). */
@@ -45,31 +49,45 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b
return { response, state }
}
async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
/** 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[]; dispose: () => Promise<void> }> {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
ctx.provide('apiProxy', {} as unknown as ApiProxy)
ctx.provide('apiProxy', fakeApiProxy(workspaces))
const fiber = ctx.plugin({ inject: [...inject], apply }, config)
await fiber.await()
return { routes, 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
}
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', {} as unknown as ApiProxy)
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 prefix route and removes it with the fiber', async () => {
it('registers both transport prefix routes and removes them with the fiber', async () => {
const { routes, dispose } = await mounted()
expect(routes).toHaveLength(1)
expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH })
expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }, { kind: 'prefix', path: FILES_PATH }])
await dispose()
expect(routes).toHaveLength(0)
})
@@ -132,6 +150,52 @@ describe('connection node half', () => {
})
})
describe('connection node half: the /f workspace-file route', () => {
/** 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 }) }
}
/** 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)
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)
// 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)
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> }> {

View File

@@ -0,0 +1,134 @@
/**
* Workspace-file reads over a real HTTP server and a real temporary
* workspace: confinement, content typing, and the sandbox header are wire
* facts, so they are asserted against responses Node actually produced.
*/
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
import type { ServerResponse } from 'node:http'
import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { Writable } from 'node:stream'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api'
import { handleWorkspaceFile } from '../src/workspace-files.ts'
const SESSION = 's-1'
let workspace: string
let outside: string
let origin: string
let close: () => Promise<void>
beforeAll(async () => {
const root = await mkdtemp(join(tmpdir(), 'dsh-files-'))
workspace = join(root, 'workspace')
outside = join(root, 'outside')
await mkdir(join(workspace, 'out'), { recursive: true })
await mkdir(outside, { recursive: true })
await writeFile(join(workspace, 'index.html'), '<h1>产物</h1>')
await writeFile(join(workspace, 'notes.txt'), 'plain')
await writeFile(join(workspace, 'chart.svg'), '<svg xmlns="http://www.w3.org/2000/svg"/>')
await writeFile(join(workspace, 'model.safetensors'), 'unknown extension')
await writeFile(join(workspace, 'out', 'page.html'), '<p>nested</p>')
await writeFile(join(outside, 'secret.html'), 'SECRET')
await symlink(join(outside, 'secret.html'), join(workspace, 'escape.html'))
const server = createServer((req, res) => {
void handleWorkspaceFile(req, res, {
cwdFor: async sessionId => sessionId === SESSION ? workspace : undefined,
})
})
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
origin = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}`
close = () => new Promise<void>((resolve, reject) => {
server.close((error) => {
if (error === undefined || error === null) resolve()
else reject(error)
})
})
return async () => { await rm(root, { recursive: true, force: true }) }
})
afterAll(async () => { await close() })
function get(path: string, init?: RequestInit): Promise<Response> {
return fetch(`${origin}${path}`, init)
}
describe('workspace file reads', () => {
it('serves a produced document with the sandbox that keeps it off this origin', 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')
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 () => {
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')
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 () => {
const response = await get(`${FILES_PATH}/${SESSION}/model.safetensors`)
expect(response.status).toBe(200)
expect(response.headers.get('content-type')).toBe('text/plain; charset=utf-8')
})
it('serves a nested path, so a document reaches its own siblings', async () => {
const response = await get(`${FILES_PATH}/${SESSION}/out/page.html`)
expect(response.status).toBe(200)
expect(await response.text()).toBe('<p>nested</p>')
})
it('answers HEAD with the length and no body', async () => {
const response = await get(`${FILES_PATH}/${SESSION}/notes.txt`, { method: 'HEAD' })
expect(response.status).toBe(200)
expect(response.headers.get('content-length')).toBe('5')
expect(await response.text()).toBe('')
})
it('refuses a symlink whose target leaves the workspace', async () => {
const response = await get(`${FILES_PATH}/${SESSION}/escape.html`)
expect(response.status).toBe(403)
expect(await response.text()).not.toContain('SECRET')
})
it('reports missing files, directories, and unknown sessions as absent', async () => {
expect((await get(`${FILES_PATH}/${SESSION}/nope.html`)).status).toBe(404)
expect((await get(`${FILES_PATH}/${SESSION}/out`)).status).toBe(404)
// A path whose ancestor is a file, not a directory.
expect((await get(`${FILES_PATH}/${SESSION}/notes.txt/child`)).status).toBe(404)
expect((await get(`${FILES_PATH}/s-other/index.html`)).status).toBe(404)
expect((await get(`${FILES_PATH}/${SESSION}`)).status).toBe(404)
})
})
describe('workspace file streaming failures', () => {
it('tears the response down instead of rejecting when the body cannot be written', async () => {
// A client that goes away mid-stream must not surface as a handler
// rejection: the webserver's last-resort guard would log it and try to
// answer 400 on a response whose status line is already out.
const sink = new Writable({
write(_chunk, _encoding, callback) { callback(new Error('socket gone')) },
})
const response = Object.assign(sink, { writeHead: () => response }) as unknown as ServerResponse
await expect(handleWorkspaceFile(
{ url: `${FILES_PATH}/${SESSION}/index.html`, method: 'GET', headers: {} } as never,
response,
{ cwdFor: async () => workspace },
)).resolves.toBeUndefined()
expect(sink.destroyed).toBe(true)
})
})