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

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/ui-conversation/README.md
README.md: b61a70fb079eb6a1bc2a67b682a337ffdf708b79
README.zh.md: 0bb1740b166cfacc2bc79fe2f49793796f66c365
README.md: ba55f0704500034b7afb37258064fe0801aaee91
README.zh.md: 4212908b355a81dfd5af8645ce5d4284a4555622

View File

@@ -14,7 +14,7 @@ Approvals take over the composer through the chain this package declares: `Appro
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file: one inside the session workspace opens in a new browser tab, served by the web transport's `/f` route, so a client that is not on the Host machine still sees it; one outside the workspace has no served URL and falls back to the Host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
@@ -42,6 +42,8 @@ The chat stats line takes its token accounting from two generic token-meter proj
`src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath).
A finished turn ends with the files it produced. `chat-flow.ts`'s `turnDeliverables` reads them off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. The row renders under the closing assistant's body and above its IconActions, keyed to the same seq `assistantActionsSeqs` elects. It shows six chips (basename, full path as the title) plus an explicit remainder count, and each chip opens through the same `openFile` the tool rows use.
## Model Experience
None, as the conversation UI renders session history and streams in the browser; nothing here reaches a model request.

View File

@@ -12,7 +12,7 @@
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px超出后滚动并以内联 JSON 展示 `content``source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击即打开文件:位于会话工作区之内的文件在新浏览器标签页打开,由 web 传输的 `/f` 路由提供,因此不在 Host 机器上的客户端也能看到;工作区之外的文件没有可服务的 URL回退到宿主操作系统的默认应用(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView``resultView` 对推导的唯一位置因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null落回通用路径。因此两个渲染点也都显示卡片的运行状态点它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`8面板为 16正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
@@ -42,6 +42,8 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插
`src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts``skeleton/``chat/``toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply``inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。
完成的一轮以它产出的文件收尾。`chat-flow.ts``turnDeliverables` 从改写工具自身的跟随文件 `locations` 中读出它们——diff 卡片,或 `kind``edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状——因此无论收尾消息是否点名这一轮的产出都会被列出新的改写工具靠声明自己做了什么加入而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目同一路径在一轮内按首见顺序只出现一次累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。该行渲染在收尾 assistant 正文之下、其 IconActions 之上,键控到 `assistantActionsSeqs` 选出的同一个 seq。它展示六枚 chip文本为文件名完整路径作为 title外加一个显式的剩余计数每枚 chip 都经由工具行所用的同一个 `openFile` 打开。
## 模型体验
无。会话 UI 在浏览器中渲染会话历史与流;这里没有任何内容进入模型请求。

View File

@@ -32,6 +32,21 @@ function rendersNothing(node: ConversationNode): boolean {
|| ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === ''))
}
/**
* Paths a call view reports having created or changed, by render intent rather
* than tool name: a diff card, or a generic card whose kind is `edit` (the
* shape `str_replace_editor`'s insert presents). Every other card produces
* nothing to open — a read looked, a delete removed, a terminal ran.
*/
function producedPaths(view: ToolResultNode['callView']): readonly string[] {
if (view === null) return []
if (view.card === 'diff') return (view.locations ?? []).map(location => location.path)
if (view.card === 'generic' && view.kind === 'edit') {
return (view.locations ?? []).map(location => location.path)
}
return []
}
/**
* Seq set of assistants that own IconActions: the last content-text assistant
* in each turn. Mid-turn narration (text before tools) stays chrome-free.
@@ -54,10 +69,19 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon
*
* The source is the mutation tools' own follow-along `locations`, not the
* closing prose: a produced file must be listed whether or not the model
* remembered to name it. Reads contribute nothing (looking at a file does not
* produce it) and a failed mutation contributes nothing (there is no file to
* open). Paths keep first-seen order and appear once, so a file written and
* then edited in the same turn is one entry.
* remembered to name it. A mutation is recognized by render intent, not by
* tool name — a diff card, or a generic card whose `kind` is `edit` (the shape
* `str_replace_editor`'s insert presents) — so a new mutation tool joins by
* declaring what it does. Reads contribute nothing (looking at a file does not
* produce it), and neither do deletes (there is nothing left to open) or
* failed calls. Paths keep first-seen order and appear once, so a file written
* and then edited in the same turn is one entry.
*
* Accumulation resets on the turn boundary, not merely at the closing
* assistant: a turn that mutates files and then ends without content text
* (interrupted mid-tool, or a turn whose last text precedes its last tool
* result) must not spill its paths into the next turn's row, nor leave `seen`
* suppressing a file the next turn legitimately rewrites.
* @param nodes - snapshot nodes (surface order).
* @returns Per-closing-seq produced paths; a turn that produced none is absent.
*/
@@ -65,21 +89,37 @@ export function turnDeliverables(nodes: readonly ConversationNode[]): ReadonlyMa
const closing = assistantActionsSeqs(nodes)
const byClosingSeq = new Map<number, readonly string[]>()
let pending: string[] = []
const seen = new Set<string>()
let seen = new Set<string>()
let turn: number | undefined
for (const node of nodes) {
if (node.kind === 'tool-result') {
if (node.isError || node.callView?.card !== 'diff') continue
for (const location of node.callView.locations ?? []) {
if (seen.has(location.path)) continue
seen.add(location.path)
pending.push(location.path)
if (node.isError) continue
for (const path of producedPaths(node.callView)) {
if (seen.has(path)) continue
seen.add(path)
pending.push(path)
}
continue
}
// Tool results carry no turn of their own, so the boundary is read off the
// nodes that do. A user message opens a turn without reporting a number,
// which is why the tracked turn goes back to undefined there: the next
// node to report one is stating the current turn, not entering a new one.
if (node.kind === 'user') {
turn = undefined
pending = []
seen = new Set()
} else if ('turn' in node) {
if (turn !== undefined && node.turn !== turn) {
pending = []
seen = new Set()
}
turn = node.turn
}
if (node.kind !== 'assistant' || !closing.has(node.seq)) continue
if (pending.length > 0) byClosingSeq.set(node.seq, pending)
pending = []
seen.clear()
seen = new Set()
}
return byClosingSeq
}

View File

@@ -241,6 +241,38 @@ describe('chat-flow derivation', () => {
expect(turnDeliverables([user(1, 'hi'), assistant(2, 'hello', 1)]).size).toBe(0)
})
it('turnDeliverables counts a generic edit and never spills across the turn boundary', () => {
const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({
...toolResult(seq, callId, 'str_replace_editor'),
// str_replace_editor's insert mutates behind a generic card, so the
// discriminant is the render intent, not the card shape alone.
callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] },
})
const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({
...toolResult(seq, callId, 'write'),
callView: {
card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }],
},
})
const produced = turnDeliverables([
user(1, 'insert a line'),
inserted(2, 'i', 'notes.md'),
assistant(3, 'inserted', 1),
// Turn 2 mutates and then ends with no content text (interrupted, or its
// last text preceded the tool): its paths must not ride into turn 3.
user(4, 'now rewrite it'),
wrote(5, 'w', 'leaked.txt'),
user(6, 'and again'),
wrote(7, 'w2', 'notes.md'),
assistant(8, 'done', 3),
])
expect(produced.get(3)).toEqual(['notes.md'])
// Turn 3 lists only its own file — and `seen` did not suppress the rewrite
// of a path an earlier turn already touched.
expect(produced.get(8)).toEqual(['notes.md'])
expect([...produced.values()].flat()).not.toContain('leaked.txt')
})
it('renders the produced files under the closing message and opens one on click', () => {
const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({
...toolResult(seq, callId, 'write'),