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
@@ -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 .agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md
2026-07-31-web-workspace-file-links.md: 3cd7a075f091a50a980cde14fdfcd15e810ee1f4
2026-07-31-web-workspace-file-links.zh.md: 5702730938a76042879989ea961fdbe251830839
2026-07-31-web-workspace-file-links.md: a4bd8a2fecc2f1cb29d61a575b1ae0d31e5ebdd3
2026-07-31-web-workspace-file-links.zh.md: 1d001710b2548343cb811fd6cca282cf956e95d9
@@ -4,7 +4,7 @@ Status: implemented
English | [中文](2026-07-31-web-workspace-file-links.zh.md)
> Scope: the `/f` workspace-file route on the web transport, the `IWorkspaces.fileUrl` derivation behind it, and the conversation's file-open affordance switching to it. Not in scope: an artifact registry, versioning, live reload, or any model-facing declaration.
> Scope: the `/f` workspace-file route on the web transport, the `IWorkspaces.fileUrl` derivation behind it, the conversation's file-open affordance switching to it, and the produced-files row a finished turn ends with. Not in scope: an artifact registry, versioning, live reload, or any model-facing declaration.
## Problem
@@ -20,7 +20,7 @@ The parts were nearly all present, pointed at the wrong target. `ToolRow` alread
**The URL shape lives in `dsh-host-apiproxy/api`, with the other browser-importable contract surfaces.** Both ends must agree on one encoding, but a client bundle may not value-import another plugin's package: the purity gate in `packages/client/tsdown.client.ts` allows only platform modules and the `INLINE_SAFE` wire layers, of which apiproxy is one. Putting `api/files.ts` there is what lets the browser half build a URL and the serving half parse it from a single source, and it needed no new package edge — both sides already depend on apiproxy.
**A served document carries no isolation header.** The first cut sandboxed script-capable documents, reasoning that `/api/events.mux` is a readable same-origin `GET` stream one `window.open` away from a page the model wrote. Measuring it settled the question the other way: under `CSP: sandbox` the report's own artifact throws `SecurityError` on `localStorage` and its theme toggle goes dead, and the capability the sandbox denies is one the page's author — an agent already holding this user's shell — never needed the browser for. A sandbox there sits behind a trust boundary it has already crossed. The rejected middle option (`connect-src 'none'` plus a `Sec-Fetch-Dest: document` refusal on the two SSE `GET`s) restored the preview but was the only option that had to edit the RPC gateway, and its fence goes quiet over plain-HTTP LAN, where `Sec-Fetch-*` is not sent. 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.** `.html`/`.htm`/`.xhtml`/`.svg` carry `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`. The decision was briefly taken the other way, on the premise that these files are agent-authored and their author already holds this user's shell, so a browser sandbox would sit behind a trust boundary already crossed. Review falsified the premise: a workspace file need not be agent-authored at all — 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, so the loopback-pinned settings and credential plane was reachable from a page nobody in this session wrote. The cost is real and stays: a preview has no `localStorage` or cookies, so a generated page that remembers a theme will not. Restoring that without reopening the hole needs a separate origin, which is a different change; `host.openPath` remains the full-capability way to open the same file.
**The client decides by derivation, not by probing.** `IWorkspaces.fileUrl(sessionId, cwd, path)` expresses a tool-reported path as segments below the session cwd and returns the origin-relative URL, or `undefined` when the path leaves the workspace. `undefined` is exactly the signal to fall back to `openPath`, so a file outside the workspace behaves as it did before and no capability negotiation is needed.
@@ -30,9 +30,10 @@ The parts were nearly all present, pointed at the wrong target. `ToolRow` alread
- **A dedicated `dsh-client-workspace-files` package** — the honest seam shape if file serving were an independent capability. It is not: it needs the same fence and the same `trustedHosts` value as `/api`, and splitting would have duplicated both against the repository's own "don't split preemptively" rule.
- **Keeping the URL-shape module in `client-connection` and importing it from the runtime** — the first cut, and the build refused it: a cross-plugin value import into a client bundle either inlines a duplicate runtime instance or names a specifier the frozen module table cannot answer. The gate is the reason the shared module sits in the wire layer rather than in the package that happens to own the route.
- **`/f/<absolute path>`, so `openPath` could stay the single call site** — drops the sessionId from the URL, but then the served authority becomes the union of every workspace the host knows. The tight authority costs exactly one call-site edit, because `openFile` already has both the sessionId and the cwd in scope.
- **`connect-src 'none'` plus a navigation fence, to keep `localStorage` working under isolation** — measurably viable (Chrome sends `Sec-Fetch-Dest: document` for `window.open` and `empty` for `EventSource`, loopback included), and rejected anyway: it was the only option adding a rule to the RPC gateway, and the header it depends on is absent over plain-HTTP LAN. More mechanism than the threat it removes.
- **Linkifying paths in the assistant's closing message** — the shape a user asks for ("put the link at the end"), but it makes rendering depend on the model spelling a path recognizably. The tool calls already carry `locations` as structured fact; consuming that is the reliable source and is left as the follow-up this route unblocks.
- **`connect-src 'none'` plus a navigation fence, to keep `localStorage` working under isolation** — measurably viable against the SSE-read vector (Chrome sends `Sec-Fetch-Dest: document` for `window.open` and `empty` for `EventSource`, loopback included), but it never addressed the larger one: same-origin `fetch` to a POST method is what reaches the configuration plane, and blocking `connect-src` from the served document is exactly what a hostile document would not do to itself. Only an origin boundary contains it.
- **Serving `/f` from its own loopback port** — the one option that keeps both isolation and preview capabilities, and the shape a separate-origin answer would take. Deferred, not rejected: it needs a second listener with its own lifecycle plus the port plumbed to the client, which is a change of a different size than this one.
- **Linkifying paths in the assistant's closing message** — the shape a user asks for ("put the link at the end"), but it makes rendering depend on the model spelling a path recognizably. The tool calls already carry `locations` as structured fact, so the produced-files row consumes that instead; linkifying the prose stays unnecessary rather than merely deferred.
## Consequences
Every existing file affordance changed target at once: write, edit, read, and the generic single-file card all reach `openFile`, so one call-site edit made produced files openable in the browser, LAN clients included. Three tests asserting the old `openPath` destination were rewritten to the new one; the outside-workspace fallback keeps the old assertion. The route is covered against a real HTTP server and a real temporary workspace, because confinement, content typing, and the sandbox header are wire facts, and the assembled web lane (`apps/web/tests/workspace-file-open.e2e.ts`, keyless over a cold-seeded session) proves the product path: clicking a read row's path opens `/f/<sessionId>/a.txt` in a second tab serving that workspace file, while a traversal spelling answers 404. A preview keeps its own capabilities, so a generated page that persists a theme in `localStorage` works as its author intended. Still deferred: the end-of-turn deliverable row derived from `locations`, and any linkification inside assistant Markdown.
Every existing file affordance changed target at once: write, edit, read, and the generic single-file card all reach `openFile`, so one call-site edit made produced files openable in the browser, LAN clients included. Three tests asserting the old `openPath` destination were rewritten to the new one; the outside-workspace fallback keeps the old assertion. The route is covered against a real HTTP server and a real temporary workspace, because confinement, content typing, and the sandbox header are wire facts, and the assembled web lane (`apps/web/tests/workspace-file-open.e2e.ts`, keyless over a cold-seeded session) proves the product path: clicking a read row's path opens `/f/<sessionId>/a.txt` in a second tab serving that workspace file, while a traversal spelling answers 404. A preview runs without `localStorage` or cookies, visible on generated pages that persist a theme — the Host opener remains for those, and a separate serving origin is the way to retire the limitation. The produced-files row ships here too: `turnDeliverables` reads a turn's output off the mutation tools' render intent (a diff card, or a generic card whose `kind` is `edit`), resets on the turn boundary so an interrupted turn cannot spill into the next, and renders under the closing assistant. Still deferred: linkification inside assistant Markdown, and any cross-session view of past deliverables.
@@ -4,7 +4,7 @@ Status: implemented
[English](2026-07-31-web-workspace-file-links.md) | 中文
> 范围:web 传输层上的 `/f` 工作区文件路由、其背后的 `IWorkspaces.fileUrl` 推导,以及会话中打开文件的交互改指向它。不在范围内:产物注册表、版本、实时重载,或任何面向模型的声明。
> 范围:web 传输层上的 `/f` 工作区文件路由、其背后的 `IWorkspaces.fileUrl` 推导、会话中打开文件的交互改指向它,以及完成的一轮以其产出文件收尾的那一行。不在范围内:产物注册表、版本、实时重载,或任何面向模型的声明。
## 问题
@@ -20,7 +20,7 @@ Status: implemented
**URL 形状落在 `dsh-host-apiproxy/api`,与其余浏览器可导入的契约面同处一地。** 两端必须就同一套编码达成一致,但客户端 bundle 不允许值导入另一个插件的包:`packages/client/tsdown.client.ts` 里的纯度 gate 只放行平台模块与 `INLINE_SAFE` 协议层,而 apiproxy 正是其中之一。把 `api/files.ts` 放在那里,才使构造 URL 的浏览器半侧与解析它的服务半侧共用单一来源,而且没有新增任何包依赖边——两侧本来就依赖 apiproxy。
**所服务的文档不带任何隔离头。** 最初的做法是给能执行脚本的文档加 sandbox,理由是 `/api/events.mux` 是一条同源可读的 `GET` 流,离模型写的页面只有一次 `window.open` 之遥。实测把这个问题判向了另一边:在 `CSP: sandbox` 之下,报告中那份产物自己就会在 `localStorage` 上抛 `SecurityError`,主题切换当场变死;而 sandbox 所拒绝的那项能力,对这个页面的作者——一个已经握着本用户 shell 的 agent——而言从来就不需要经由浏览器取得。那道 sandbox 立在一条它早已越过的信任边界之后。被否掉的折中方案(`connect-src 'none'` 加上对两个 SSE `GET` 拒绝 `Sec-Fetch-Dest: document`)确实能救回预览,但它是唯一必须去改 RPC 网关的方案,而它依赖的那个头在明文 HTTP 的 LAN 上根本不发送。当工作区内容不再属于观看者本人时,隔离预览才成为一个真问题;那时的答案是一个独立的源,而不是一个头。
**能执行脚本的文档被送进不透明源。** `.html`/`.htm`/`.xhtml`/`.svg` 带上 `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`。这个决定曾短暂地被判向另一边,前提是这些文件由 agent 撰写、而其作者已经握着本用户的 shell,因此浏览器 sandbox 只会立在一条早已越过的信任边界之后。评审推翻了这个前提:工作区文件根本不必由 agent 撰写——一条 read 行就让 clone 下来的仓库里任何文件变得可打开——而同源的活动文档经实测能把 `/api/settings.describe` 打到 `200` 并拿到完整数据,也就是说被钉在回环的设置与凭据面,可以被一个本次会话中无人撰写的页面触达。代价真实且保留:预览没有 `localStorage` 与 cookie,因此会记住主题的生成页面在其中记不住。要在不重新打开这个洞的前提下取回它,需要一个独立的源,那是另一个尺寸的改动;`host.openPath` 仍是以完整能力打开同一文件的方式。
**客户端靠推导决定,而不是靠探测。** `IWorkspaces.fileUrl(sessionId, cwd, path)` 把工具报告的路径表达为 session cwd 之下的段落并返回相对于源的 URL,路径离开工作区时返回 `undefined`。`undefined` 恰好就是回退到 `openPath` 的信号,因此工作区外的文件行为与以往一致,也不需要任何能力协商。
@@ -30,9 +30,10 @@ Status: implemented
- **单开一个 `dsh-client-workspace-files` 包**——如果文件服务是一项独立能力,这才是诚实的 seam 形状。它不是:它需要与 `/api` 相同的 fence 和相同的 `trustedHosts` 值,拆分会把两者都复制一份,违背仓库自己的“不要预先拆分”。
- **把 URL 形状模块留在 `client-connection` 里、由 runtime 去导入**——最初就是这么写的,构建直接拒绝:向客户端 bundle 做跨插件值导入,要么内联出一份重复的运行时实例,要么落到冻结模块表答不出的说明符上。这道 gate 正是共享模块落在协议层、而非落在恰好持有该路由的那个包里的原因。
- **`/f/<绝对路径>`,好让 `openPath` 保持为唯一调用点**——这会把 sessionId 从 URL 里去掉,但所服务的权限边界随之变成 host 已知的全部工作区之并集。紧的权限边界只花掉一处调用点的改动,因为 `openFile` 本来就同时持有 sessionId 与 cwd。
- **用 `connect-src 'none'` 加一道导航栅栏,在保持隔离的同时保住 `localStorage`**——经实测确实可行(Chrome 对 `window.open` 发 `Sec-Fetch-Dest: document`、对 `EventSource` 发 `empty`,回环也在内),但仍被否:它是唯一要往 RPC 网关里加规则的方案,而它依赖的那个头在明文 HTTP 的 LAN 上并不发送。机制的分量超过了它移除的威胁。
- **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带;消费它才是可靠来源,作为这条路由解锁的后续留下。
- **用 `connect-src 'none'` 加一道导航栅栏,在保持隔离的同时保住 `localStorage`**——针对“读走 SSE 流”这条向量经实测可行(Chrome 对 `window.open` 发 `Sec-Fetch-Dest: document`、对 `EventSource` 发 `empty`,回环也在内),但它从未触及更大的那条:真正够到配置面的是向 POST 方法发起的同源 `fetch`,而“从所服务文档一侧封住 `connect-src`”恰恰是敌意文档不会对自己做的事。只有源边界能收住它。
- **让 `/f` 跑在自己的回环端口上**——唯一能同时保住隔离与预览能力的选项,也是“独立的源”这个答案该有的形状。是暂缓而非否决:它需要一个带自身生命周期的第二监听器,外加把端口铺到客户端,那是另一个尺寸的改动。
- **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带,产出文件行消费的正是它;因此把正文链接化是不必要,而不只是被推迟。
## 影响
现有的每一处文件交互都同时换了目标:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此一处调用点的改动就让产出的文件在浏览器里可打开,LAN 客户端也在内。三个断言旧 `openPath` 去向的测试被改写为新的去向;工作区外的回退保留了旧断言。这条路由对着真实 HTTP 服务器与真实临时工作区做覆盖,因为收敛、内容定型与 sandbox 头都是协议事实;而组装后的 web 通道(`apps/web/tests/workspace-file-open.e2e.ts`,在冷播种会话上无密钥运行)证明了产品路径:点击读取行的路径会在第二个标签页打开 `/f/<sessionId>/a.txt` 并提供那个工作区文件,而穿越写法应答 404。预览保有自身的能力,因此把主题持久化到 `localStorage` 的生成页面,按其作者的意图正常工作。仍然暂缓:由 `locations` 推导的回合末交付物行,以及助手 Markdown 内部的任何链接化。
现有的每一处文件交互都同时换了目标:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此一处调用点的改动就让产出的文件在浏览器里可打开,LAN 客户端也在内。三个断言旧 `openPath` 去向的测试被改写为新的去向;工作区外的回退保留了旧断言。这条路由对着真实 HTTP 服务器与真实临时工作区做覆盖,因为收敛、内容定型与 sandbox 头都是协议事实;而组装后的 web 通道(`apps/web/tests/workspace-file-open.e2e.ts`,在冷播种会话上无密钥运行)证明了产品路径:点击读取行的路径会在第二个标签页打开 `/f/<sessionId>/a.txt` 并提供那个工作区文件,而穿越写法应答 404。预览在没有 `localStorage` 与 cookie 的情况下运行,这在会持久化主题的生成页面上看得见——那些场景仍有 Host 打开器,而独立的服务源是退休这条限制的路。产出文件行也在本次一并落地:`turnDeliverables` 依据改写工具的渲染意图(diff 卡片,或 `kind` 为 `edit` 的 generic 卡片)读出一轮的产出,在 turn 边界重置以免中断的一轮溢进下一轮,并渲染在收尾 assistant 之下。仍然暂缓:助手 Markdown 内部的链接化,以及任何跨会话回看既往产物的视图。