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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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