cleanup(web): stop sandboxing served workspace documents

A preview lost localStorage and cookies under CSP sandbox — measurably, the
reported artifact throws SecurityError on load and its theme toggle goes
dead. The capability the sandbox denied is one the file's author, an agent
already holding this user's shell, never needed the browser for, so the
header sat behind a trust boundary it had already crossed.

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.
This commit is contained in:
ZiyaZhang
2026-07-31 23:20:36 -07:00
parent 35e9122a65
commit f5d53f04b7
8 changed files with 26 additions and 33 deletions
@@ -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。
**模型撰写的文档被送进不透明源。** `.html`/`.htm`/`.xhtml`/`.svg` 会带上 `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`。若把生成的标记与 `/api` 同源提供,`/api/events.mux`——一条可读的 `GET` 流——离模型写的页面就只有一次 `window.open` 之遥。sandbox 让预览失去 `localStorage`、cookie 与同源 `fetch`;`host.openPath` 作为在 Host 机器上以完整能力打开同一文件的方式保留下来,因此这个取舍是靠同时保留两个交互解决的,而不是靠削弱其中之一。
**所服务的文档不带任何隔离头。** 最初的做法是给能执行脚本的文档加 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 上根本不发送。当工作区内容不再属于观看者本人时,隔离预览才成为一个真问题;那时的答案是一个独立的源,而不是一个头。
**客户端靠推导决定,而不是靠探测。** `IWorkspaces.fileUrl(sessionId, cwd, path)` 把工具报告的路径表达为 session cwd 之下的段落并返回相对于源的 URL,路径离开工作区时返回 `undefined`。`undefined` 恰好就是回退到 `openPath` 的信号,因此工作区外的文件行为与以往一致,也不需要任何能力协商。
@@ -30,9 +30,9 @@ 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'` 代替 `sandbox`,以保住 `localStorage`**——它挡得住 `fetch`/`EventSource`,挡不住 `window.open('/api/events.mux')`,而后者是同源可读的。正是那两个 GET SSE 端点让 sandbox 成为必需而非可选。
- **用 `connect-src 'none'` 加一道导航栅栏,在保持隔离的同时保住 `localStorage`**——经实测确实可行(Chrome 对 `window.open` 发 `Sec-Fetch-Dest: document`、对 `EventSource` 发 `empty`,回环也在内),但仍被否:它是唯一要往 RPC 网关里加规则的方案,而它依赖的那个头在明文 HTTP 的 LAN 上并不发送。机制的分量超过了它移除的威胁。
- **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带;消费它才是可靠来源,作为这条路由解锁的后续留下。
## 影响
现有的每一处文件交互都同时换了目标:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此一处调用点的改动就让产出的文件在浏览器里可打开,LAN 客户端也在内。三个断言旧 `openPath` 去向的测试被改写为新的去向;工作区外的回退保留了旧断言。这条路由对着真实 HTTP 服务器与真实临时工作区做覆盖,因为收敛、内容定型与 sandbox 头都是协议事实;而组装后的 web 通道(`apps/web/tests/workspace-file-open.e2e.ts`,在冷播种会话上无密钥运行)证明了产品路径:点击读取行的路径会在第二个标签页打开 `/f/<sessionId>/a.txt` 并提供那个工作区文件,而穿越写法应答 404。预览中无法使用 `localStorage`,这在会持久化主题切换的生成页面上是看得见的——那些场景仍有 Host 打开器。仍然暂缓:由 `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` 的生成页面,按其作者的意图正常工作。仍然暂缓:由 `locations` 推导的回合末交付物行,以及助手 Markdown 内部的任何链接化。