fix(web): address the review of the workspace-file route
Isolation is restored on the premise the review corrected: a workspace file need not be agent-authored — a read row makes every file in a cloned repository openable — and a same-origin active document was measured driving /api/settings.describe to a 200 with full data. Script-capable documents go back into an opaque origin; the preview's lost localStorage is the known cost, and a separate serving origin is the way to retire it. - confine(): a workspace rooted at a filesystem root has a realpath already ending in the separator, and the doubled prefix 403'd every child. - turnDeliverables(): reset on the turn boundary, not only at a closing assistant, so an interrupted turn cannot spill into the next turn's row; and recognize a mutation by render intent (diff card, or generic with kind 'edit') so str_replace_editor's insert counts. - 405 answers name the methods it allows. - The e2e now cold-seeds a recorded WRITE turn, so the assembled application covers the Produced row, its chip's served URL, and the isolation header. - Agent Note matched to what shipped (the row is in this PR, not deferred); ui-conversation README documents the new destination and the row; the fixture lane's dead-tab quirk and the cold-path listing cost are recorded.
This commit is contained in:
@@ -34,11 +34,15 @@ function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session
|
||||
}
|
||||
|
||||
/** Response recorder compatible with both the fence's short-circuit and the bridge. */
|
||||
function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } {
|
||||
const state: { status?: number; body?: unknown } = {}
|
||||
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) { state.status = value; return this },
|
||||
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
|
||||
@@ -177,6 +181,7 @@ describe('connection node half: the /f workspace-file route', () => {
|
||||
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' })
|
||||
await dispose()
|
||||
})
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ 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 { join, sep } 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'
|
||||
@@ -37,7 +37,8 @@ beforeAll(async () => {
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
void handleWorkspaceFile(req, res, {
|
||||
cwdFor: async sessionId => sessionId === SESSION ? workspace : undefined,
|
||||
// 'rooted' names the filesystem root, the separator-terminated realpath case.
|
||||
cwdFor: async sessionId => sessionId === SESSION ? workspace : sessionId === 'rooted' ? sep : undefined,
|
||||
})
|
||||
})
|
||||
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve))
|
||||
@@ -58,25 +59,35 @@ function get(path: string, init?: RequestInit): Promise<Response> {
|
||||
}
|
||||
|
||||
describe('workspace file reads', () => {
|
||||
it('serves a produced document with its own capabilities intact', async () => {
|
||||
it('serves an active document into an opaque 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')
|
||||
// No isolation header: a preview keeps localStorage and cookies, because
|
||||
// the file's author already holds this user's shell (see the module doc).
|
||||
expect(response.headers.get('content-security-policy')).toBeNull()
|
||||
// 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')
|
||||
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('types SVG as a standalone document rather than sniffable bytes', async () => {
|
||||
it('sandboxes SVG too, and leaves inert types unrestricted', async () => {
|
||||
const svg = await get(`${FILES_PATH}/${SESSION}/chart.svg`)
|
||||
expect(svg.headers.get('content-type')).toBe('image/svg+xml')
|
||||
expect(svg.headers.get('x-content-type-options')).toBe('nosniff')
|
||||
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('serves a workspace rooted at a filesystem root, whose realpath already ends in a separator', async () => {
|
||||
// `realpath('/')` is '/', so a naive `root + sep` prefix is '//' and every
|
||||
// child of that workspace would 403.
|
||||
const rooted = await fetch(`${origin}${FILES_PATH}/rooted${new URL(`file://${workspace}/notes.txt`).pathname}`)
|
||||
expect(rooted.status).toBe(200)
|
||||
expect(await rooted.text()).toBe('plain')
|
||||
})
|
||||
|
||||
it('shows an unknown extension as text rather than downloading it', async () => {
|
||||
|
||||
Reference in New Issue
Block a user