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:
@@ -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> }> {
|
||||
|
||||
134
packages/client/connection/tests/workspace-files.spec.ts
Normal file
134
packages/client/connection/tests/workspace-files.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user