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:
ZiyaZhang
2026-08-01 01:08:16 -07:00
parent f5d53f04b7
commit dcf485ac5c
17 changed files with 205 additions and 81 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
README.md: ebc2dea2787268e1686eac24565c2433cb5f4b66
README.zh.md: f5653e9e0e0aed3cbf7a9e6124668942f4dde44a
README.md: cc7070500645f407b46f0326bf74479a81c579d8
README.zh.md: 600b4f19d3262cb2381ec9368bc92b93db183603

View File

@@ -12,10 +12,12 @@ The node half guards every request under `/api` before bridging (`src/api-reques
The node half also serves one file at a time out of a Session's workspace under `/f/<sessionId>/<segments…>`, so a produced deliverable is reachable from the page that reported it — an `http` page cannot follow a `file://` link, and a browser that is not on the Host machine has no such path anyway. The segments ride the URL rather than a query parameter so a served document's relative references resolve to its siblings. The request names a Session and the gateway names that Session's directory (`ApiProxy.workspaceRootOf`, which answers from a live agent's header or the persistence store and never resumes an agent to serve a file); this package reads the authority rather than the core services, because holding their host-side Context declarations would merge them over the browser runtime's own. The URL shape itself lives with the other browser-importable contract surfaces, in [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md), so the browser half that builds a URL and this half that parses one share a single encoding decision. Both the cwd and the resolved target go through `realpath` before comparison, so a symlink inside the workspace pointing out of it is refused by its target rather than its name; traversal spellings are refused earlier still, at parse time, before any filesystem call. Reads stream (no request buffers a file), answer `GET`/`HEAD` only, and carry `nosniff` with `no-store`. Extensions outside the served content-type table are typed `text/plain` rather than offered as a download, because a workspace read is a request to see a file.
A served document carries no isolation header and is same-origin with `/api`. That is a decision, not an omission: the only author of these files is the agent already holding this user's shell and filesystem, so a `Content-Security-Policy: sandbox` would sit behind a trust boundary it has already crossed while costing every preview its `localStorage` and cookies — a generated page that remembers a theme breaks under it. A deployment that serves `dsh web` beyond loopback should treat workspace content as trusted, which is already true of everything else its agent does. Isolating a preview becomes a real question when workspace content stops being the viewer's own; the answer then is a separate origin, not a header. The same trust fence gates this prefix, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads.
Documents that can execute script — `.html`, `.htm`, `.xhtml`, `.svg` — additionally carry `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`, which runs them in an opaque origin. A workspace file is not necessarily agent-authored: a read row makes every file in a cloned repository openable, so an active document served same-origin with `/api` would have its script pass the browser-trust fence into every method, the loopback-pinned settings and credential plane included. The cost is borne by the preview — `localStorage`, cookies, and same-origin `fetch` are unavailable inside it, so a generated page that remembers a theme will not — and `host.openPath` remains the full-capability way to open the same file on the Host machine. Restoring those capabilities without reopening the hole needs a separate origin, not a weaker header. The same trust fence gates this prefix, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads.
## Keyless fixture
The fixture carrier has no `/f` route, and `IWorkspaces.fileUrl` derives its URL in the browser regardless of carrier, so a file-path row clicked under `fixture=` opens a tab that 404s where the Host opener used to be a silent no-op. Fixture pages carry no file rows today; a fixture scenario that adds them should stub the derivation rather than teach the in-memory carrier to serve bytes.
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points.
## Model Experience

View File

@@ -12,10 +12,12 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust
node 半侧还会在 `/f/<sessionId>/<segments…>` 下逐个提供某个 Session 工作区里的文件,让产出的交付物能从报告它的那个页面直接抵达——`http` 页面无法跟随 `file://` 链接,而不在 Host 机器上的浏览器本来也没有那条路径。段落走 URL 而非查询参数,是为了让所服务文档的相对引用能解析到它的同级文件。请求指名一个 Session,由网关指名该 Session 的目录(`ApiProxy.workspaceRootOf`,它从活跃 agent 的 header 或持久化存储作答,绝不会为了提供一个文件而恢复 agent);本包读取这个权威来源而不去够核心服务,因为持有它们的 host 侧 Context 声明会把它们盖到浏览器运行时自己的声明之上。URL 形状本身与其余浏览器可导入的契约面放在一起,位于 [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md),因此构造 URL 的浏览器半侧与解析 URL 的这一半共享同一个编码决定。cwd 与解析出的目标在比较前都要过 `realpath`,因此工作区内指向工作区外的符号链接会因其目标而被拒绝,而不是因其名字;穿越写法拒得更早,在解析期、任何文件系统调用之前。读取是流式的(没有请求会把文件缓冲起来),只应答 `GET`/`HEAD`,并带上 `nosniff` 与 `no-store`。所服务的内容类型表之外的扩展名一律按 `text/plain` 定型而非作为下载给出,因为工作区读取本就是一个“让我看看这个文件”的请求。
所服务的文档不带任何隔离头,与 `/api` 同源。这是一个决定,不是遗漏:这些文件的唯一作者,正是那个已经握着本用户 shell 与文件系统的 agent,因此 `Content-Security-Policy: sandbox` 只会立在一条它早已越过的信任边界之后,代价却是每个预览都失去 `localStorage` 与 cookie——一个会记住主题的生成页面在它之下就是坏的。把 `dsh web` 服务到回环之外的部署,应当把工作区内容按可信处理,而这一点对其 agent 所做的其他一切本来就已成立。当工作区内容不再属于观看者本人时,隔离预览才成为一个真问题;那时的答案是一个独立的源,而不是一个头。这条前缀由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。
能执行脚本的文档——`.html`、`.htm`、`.xhtml`、`.svg`——还会额外带上 `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`,让它们运行在不透明源中。工作区文件未必由 agent 撰写:一条 read 行就能让 clone 下来的仓库里任何文件变得可打开,因此与 `/api` 同源提供的活动文档,其脚本会带着浏览器信任 fence 通行到每一个方法,包括那些正因会改动设置与凭据而被钉在回环的方法。代价由预览承担——其中无法使用 `localStorage`、cookie 与同源 `fetch`,因此一个会记住主题的生成页面在预览里记不住——而 `host.openPath` 仍是在 Host 机器上以完整能力打开同一文件的方式。要在不重新打开这个洞的前提下取回那些能力,需要的是一个独立的源,而不是一个更弱的头。这条前缀由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。
## 无密钥 fixture
fixture 载体没有 `/f` 路由,而 `IWorkspaces.fileUrl` 无论载体为何都在浏览器侧推导 URL,因此在 `fixture=` 下点击文件路径行会打开一个 404 的标签页,而此处从前是 Host 打开器的静默空操作。今天的 fixture 页面并不含文件行;若某个 fixture 场景要加上它们,应当把这段推导打桩,而不是教这个内存载体去提供字节。
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token/短语行为,并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。
## 模型体验

View File

@@ -118,7 +118,8 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
return
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
res.writeHead(405)
// RFC 9110 §15.5.6: a 405 names the methods the resource does support.
res.writeHead(405, { allow: 'GET, HEAD' })
res.end()
return
}

View File

@@ -10,13 +10,13 @@
* owns the browser-trust fence ([api-request-trust](./api-request-trust.ts)) —
* this module is reached only by requests that already passed it.
*
* A served document is same-origin with `/api`, and deliberately carries no
* isolation header. The only author of these files is the agent already
* holding this user's shell and filesystem, so a browser sandbox would not
* move the trust boundary — it would sit behind one already crossed, at the
* cost of `localStorage` and cookies in every preview. Isolating a preview
* becomes a real question when workspace content stops being the viewer's own;
* the answer then is a separate origin, not a header.
* Script-capable documents are served into an opaque origin. A workspace file
* is not necessarily agent-authored — a read row makes every file in a cloned
* repository openable — so an active document served same-origin with `/api`
* reaches the whole RPC surface, the loopback-pinned settings and credential
* methods included. The sandbox costs a preview its `localStorage` and
* cookies; restoring those without reopening that hole needs a separate
* origin, not a weaker header.
*/
import { createReadStream } from 'node:fs'
@@ -59,6 +59,17 @@ const MIME: Record<string, string> = {
const DEFAULT_MIME = 'text/plain; charset=utf-8'
/** Extensions whose top-level navigation can execute script, and so need the sandbox. */
const SCRIPTABLE = new Set(['.html', '.htm', '.xhtml', '.svg'])
/**
* The opaque origin an active workspace document runs in. Without it the
* document is same-origin with `/api` and its script passes the browser-trust
* fence, which admits every method — including the ones pinned to loopback
* precisely because they mutate settings and credentials.
*/
const SANDBOX_CSP = 'sandbox allow-scripts allow-popups allow-modals allow-forms'
/** How the route learns which directory a session may serve from. */
export interface WorkspaceFileDeps {
/**
@@ -85,8 +96,11 @@ function fail(res: ServerResponse, status: number): void {
*/
async function confine(cwd: string, segments: readonly string[]): Promise<string | undefined> {
const root = await realpath(cwd)
// A filesystem root already ends in the separator; appending a second one
// would make every child fail the prefix test and 403 the whole workspace.
const prefix = root.endsWith(sep) ? root : root + sep
const real = await realpath(resolve(root, ...segments))
return real.startsWith(root + sep) ? real : undefined
return real.startsWith(prefix) ? real : undefined
}
/**
@@ -146,6 +160,7 @@ export async function handleWorkspaceFile(
// Workspace files change under the agent's hands; a cached preview would
// show the previous turn's output after the next edit.
'cache-control': 'no-store',
...SCRIPTABLE.has(ext) ? { 'content-security-policy': SANDBOX_CSP } : {},
})
if (req.method === 'HEAD') {
res.end()

View File

@@ -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()
})

View File

@@ -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 () => {