feat(web): serve workspace files from their own origin

A sandbox header bought isolation by taking the document's origin away, and
measuring that cost decided against it: the reported artifact throws
SecurityError on load, and because an uncaught exception aborts the rest of
its <script>, every listener declared after that line — theme toggle, mobile
menu, model tabs — never binds. Two of the four artifacts in the reporting
user's workspace were dead pages under it, and they still looked right.

A second listener on the API's host, answering /f and nothing else, is the
same boundary without the amputation: cross-origin to /api (refused by the
Origin fence and by CORS), same-origin with itself (localStorage, cookies and
fetch all work). Its port is published into the index page; the browser half
reads it to address previews, and its absence — the keyless fixture lane — is
what makes a file row fall back to the Host opener instead of a dead tab.

fileUrl moves from IWorkspaces to ConnectionHandle: the transport owns both
the listener that serves the bytes and the port that addresses it.
This commit is contained in:
ZiyaZhang
2026-08-01 02:17:25 -07:00
parent 1082518520
commit 59bfe77fb8
37 changed files with 469 additions and 197 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: a4bd8a2fecc2f1cb29d61a575b1ae0d31e5ebdd3
2026-07-31-web-workspace-file-links.zh.md: 1d001710b2548343cb811fd6cca282cf956e95d9
2026-07-31-web-workspace-file-links.md: 8eeb96517aa905e77e50ce36f0efb704353c0821
2026-07-31-web-workspace-file-links.zh.md: 63d746b0a9aabaf78ba5653e16705bd662a54126
@@ -20,9 +20,9 @@ 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.
**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.
**Workspace files get their own port, and therefore their own origin.** The isolation question was worked three ways before landing here. A sandbox header came first, on the reasoning that `/api/events.mux` is a readable same-origin `GET` stream. It was then dropped on the premise that these files are agent-authored, so a browser boundary would sit behind one already crossed — a premise review falsified: 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, reaching the loopback-pinned settings and credential plane from a page nobody in this session wrote. Restoring the sandbox closed that, and measuring what it cost decided the final shape: under `CSP: sandbox` the report's own artifact throws `SecurityError` on load, and because an uncaught exception aborts the rest of its `<script>`, every listener declared after that line — theme toggle, mobile menu, model tabs — never binds. Two of the four artifacts in the reporting user's workspace were dead pages under it, and they still *looked* right. A second port is the boundary without the amputation: cross-origin to `/api` (refused by the fence's Origin check and by CORS), same-origin with itself (so `localStorage`, cookies, and `fetch` all work). It binds the same host as the API so LAN previews keep working, answers `/f` and nothing else, and publishes its port into the index page for the browser half to address.
**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.
**The client decides by derivation, not by probing.** `ConnectionHandle.fileUrl(sessionId, cwd, path)` expresses a tool-reported path as segments below the session cwd and returns an absolute URL on the workspace-file origin — the page's own hostname, the published port — or `undefined` when the path leaves the workspace or no port was published. It lives on the connection handle because the transport owns both ends: the listener that serves the bytes and the port that addresses it. `undefined` is exactly the signal to fall back to `openPath`, which is also what makes the keyless fixture lane (served by no host) degrade to the old behavior instead of opening a dead tab.
## Alternatives considered
@@ -30,10 +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 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.
- **`connect-src 'none'` plus a navigation fence, to keep `localStorage` working under a sandbox** — 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.
- **Keeping the sandbox and accepting the limitation** — the honest reading of that trade only became visible once measured: it is not "a preview cannot remember a theme" but "a preview's entire script dies at its first storage access", on pages that still render perfectly. A limitation nobody can see is worse than one that costs a port.
- **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 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.
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 with its own origin's full capabilities, so a generated page behaves as its author intended. The residual the port does not close: two Sessions share one workspace-file origin, so a document from one may fetch another's served files. That is strictly narrower than the API surface it replaces, and narrowing it further would mean an origin per Session, which nothing today needs. 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.
@@ -20,9 +20,9 @@ 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`。这个决定曾短暂地被判向另一边,前提是这些文件由 agent 撰写、而其作者已经握着本用户的 shell,因此浏览器 sandbox 只会立在一条早已越过的信任边界之后评审推翻了这个前提:工作区文件根本不必由 agent 撰写——一条 read 行就让 clone 下来的仓库里任何文件变得可打开——而同源的活动文档经实测能把 `/api/settings.describe` 打到 `200` 并拿到完整数据,也就是说被钉在回环的设置与凭据面,可以被一个本次会话中无人撰写的页面触达。代价真实且保留:预览没有 `localStorage` 与 cookie,因此会记住主题的生成页面在其中记不住。要在不重新打开这个洞的前提下取回它,需要一个独立的源,那是另一个尺寸的改动;`host.openPath` 仍是以完整能力打开同一文件的方式
**工作区文件获得自己的端口,因而拥有自己的源。** 隔离这件事在落到此处之前走了三步。最初是加 sandbox 头,理由是 `/api/events.mux` 是一条同源可读的 `GET` 流。随后它被拿掉,前提是这些文件由 agent 撰写、浏览器边界只会立在一条早已越过的边界之后——而评审推翻了这个前提:一条 read 行就让 clone 下来的仓库里任何文件变得可打开而同源的活动文档经实测能把 `/api/settings.describe` 打到 `200` 并拿到完整数据,从一个本次会话中无人撰写的页面触达了被钉在回环的设置与凭据面。加回 sandbox 堵住了它,而“量清楚它的代价”决定了最终形状:在 `CSP: sandbox` 之下,报告中那份产物加载时就抛 `SecurityError`,又因为未捕获异常会中止其 `<script>` 的其余部分,该行之后声明的所有监听器——主题切换、移动端菜单、模型 tabs——统统不会绑定。报告者工作区里四份产物有两份在它之下是死页面,而且它们**看上去**仍然正常。第二个端口给出了这条边界而无需截肢:对 `/api` 是跨源(被 fence 的 Origin 校验与 CORS 双重拒绝),对自身是同源(因此 `localStorage`、cookie 与 `fetch` 都可用)。它绑定与 API 相同的 host,因此 LAN 预览继续可用;只应答 `/f`,别无其他;并把端口注入首页供浏览器半侧寻址
**客户端靠推导决定,而不是靠探测。** `IWorkspaces.fileUrl(sessionId, cwd, path)` 把工具报告的路径表达为 session cwd 之下的段落并返回相对于源的 URL,路径离开工作区时返回 `undefined``undefined` 恰好就是回退到 `openPath` 的信号,因此工作区外的文件行为与以往一致,也不需要任何能力协商
**客户端靠推导决定,而不是靠探测。** `ConnectionHandle.fileUrl(sessionId, cwd, path)` 把工具报告的路径表达为 session cwd 之下的段落并返回工作区文件源上的绝对 URL——页面自身的主机名,加上已发布的端口——路径离开工作区或没有端口发布时返回 `undefined`。它落在 connection 句柄上,是因为传输层同时持有两端:提供字节的监听器,和寻址它的端口。`undefined` 恰好就是回退到 `openPath` 的信号,这也让无密钥 fixture 通道(不由任何 host 提供)退化为旧行为,而不是打开一个空标签页
## 考虑过的替代方案
@@ -30,10 +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`**——针对“读走 SSE 流”这条向量经实测可行(Chrome 对 `window.open``Sec-Fetch-Dest: document`、对 `EventSource``empty`,回环也在内),但它从未触及更大的那条:真正够到配置面的是向 POST 方法发起的同源 `fetch`,而“从所服务文档一侧封住 `connect-src`”恰恰是敌意文档不会对自己做的事。只有源边界能收住它。
- **`/f` 跑在自己的回环端口上**——唯一能同时保住隔离与预览能力的选项,也是“独立的源”这个答案该有的形状。是暂缓而非否决:它需要一个带自身生命周期的第二监听器,外加把端口铺到客户端,那是另一个尺寸的改动
- **用 `connect-src 'none'` 加一道导航栅栏,在 sandbox 之下保住 `localStorage`**——针对“读走 SSE 流”这条向量经实测可行(Chrome 对 `window.open``Sec-Fetch-Dest: document`、对 `EventSource``empty`,回环也在内),但它从未触及更大的那条:真正够到配置面的是向 POST 方法发起的同源 `fetch`,而“从所服务文档一侧封住 `connect-src`”恰恰是敌意文档不会对自己做的事。
- **保留 sandbox 并接受这条限制**——这笔交易的真实读数要量过才看得见:它不是“预览记不住主题”,而是“预览的整段脚本在第一次访问存储时就死了”,而页面照样渲染得完美无缺。一条没人看得见的限制,比一条要花掉一个端口的限制更糟
- **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带,产出文件行消费的正是它;因此把正文链接化是不必要,而不只是被推迟。
## 影响
现有的每一处文件交互都同时换了目标: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 内部的链接化,以及任何跨会话回看既往产物的视图。
现有的每一处文件交互都同时换了目标:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此一处调用点的改动就让产出的文件在浏览器里可打开,LAN 客户端也在内。三个断言旧 `openPath` 去向的测试被改写为新的去向;工作区外的回退保留了旧断言。这条路由对着真实 HTTP 服务器与真实临时工作区做覆盖,因为收敛、内容定型与 sandbox 头都是协议事实;而组装后的 web 通道(`apps/web/tests/workspace-file-open.e2e.ts`,在冷播种会话上无密钥运行)证明了产品路径:点击读取行的路径会在第二个标签页打开 `/f/<sessionId>/a.txt` 并提供那个工作区文件,而穿越写法应答 404。预览以自身源的完整能力运行,因此生成页面按其作者的意图工作。端口没有堵住的残余:两个 Session 共用同一个工作区文件源,因此来自其一的文档可以 fetch 另一个已服务的文件。这比它所替代的 API 面严格更窄,而要再窄一层就意味着每个 Session 一个源,今天没有任何需求指向那里。产出文件行也在本次一并落地:`turnDeliverables` 依据改写工具的渲染意图(diff 卡片,或 `kind``edit` 的 generic 卡片)读出一轮的产出,在 turn 边界重置以免中断的一轮溢进下一轮,并渲染在收尾 assistant 之下。仍然暂缓:助手 Markdown 内部的链接化,以及任何跨会话回看既往产物的视图。
+33 -9
View File
@@ -73,25 +73,49 @@ describe('web e2e: opening a produced file from the conversation', () => {
chip.click(),
])
await opened.waitForLoadState('domcontentloaded')
expect(new URL(opened.url()).pathname).toBe(`/f/${SEED_ID}/${PRODUCED}`)
const url = new URL(opened.url())
expect(url.pathname).toBe(`/f/${SEED_ID}/${PRODUCED}`)
expect(await opened.locator('body').innerText()).toContain('neutral')
// The isolation: previews come from the app's hostname on a DIFFERENT
// port, so a served document is cross-origin to /api while keeping its own
// capabilities. A workspace file is not necessarily agent-authored.
const app = new URL(scaffold.baseUrl)
expect(url.hostname).toBe(app.hostname)
expect(url.port).not.toBe(app.port)
const filesOrigin = url.origin
const served = await page.request.get(opened.url())
expect(served.status()).toBe(200)
expect(served.headers()['x-content-type-options']).toBe('nosniff')
expect(served.headers()['cache-control']).toBe('no-store')
// No document is stripped of its origin: the port is the boundary.
expect(served.headers()['content-security-policy']).toBeUndefined()
// A workspace file is not necessarily agent-authored, so an active document
// is served into an opaque origin rather than same-origin with /api.
const active = await page.request.get(`${scaffold.baseUrl}/f/${SEED_ID}/${ACTIVE}`)
expect(active.status()).toBe(200)
expect(active.headers()['content-security-policy']).toContain('sandbox')
// An active document keeps its own storage — the capability a sandbox
// header would have taken, and the reason this route has its own port.
const active = opened
await active.goto(`${filesOrigin}/f/${SEED_ID}/${ACTIVE}`, { waitUntil: 'load' })
expect(await active.evaluate(() => {
try { window.localStorage.setItem('probe', '1'); return 'ok' } catch { return 'blocked' }
})).toBe('ok')
// …and cannot reach the API, which lives on the other origin.
expect(await active.evaluate(async (base) => {
try {
await fetch(`${base}/api/session.list`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ type: 'client-request', rpcId: 'x', method: 'session.list', payload: {} }),
})
return 'reached'
} catch { return 'blocked' }
}, scaffold.baseUrl)).toBe('blocked')
// The workspace-file origin serves that one prefix and nothing else.
expect((await page.request.get(`${filesOrigin}/`)).status()).toBe(404)
// Nothing outside the Session's workspace is reachable through the route.
const escape = await page.request.get(`${scaffold.baseUrl}/f/${SEED_ID}/..%2Fetc%2Fhosts`)
expect(escape.status()).toBe(404)
expect((await page.request.get(`${filesOrigin}/f/${SEED_ID}/..%2Fetc%2Fhosts`)).status()).toBe(404)
await opened.close()
await active.close()
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
}, 90_000)
+1 -1
View File
@@ -296,7 +296,7 @@ export interface ConnectionConfig {
}
```
Source: [`packages/client/connection/src/index.ts:26`](../packages/client/connection/src/index.ts)
Source: [`packages/client/connection/src/index.ts:25`](../packages/client/connection/src/index.ts)
## `@deepseek-ai/dsh-client-hmr`
+2 -2
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: cc7070500645f407b46f0326bf74479a81c579d8
README.zh.md: 600b4f19d3262cb2381ec9368bc92b93db183603
README.md: 5001da2458ea3470659f5983dffc8de832aadeac
README.zh.md: 47e745964e4087c6ccc59aae5bbfba69f96480e4
+2 -2
View File
@@ -12,11 +12,11 @@ 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.
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.
Workspace files are served from their own port, and therefore their own origin. That port is the isolation: a workspace file is not necessarily agent-authored a read row makes every file in a cloned repository openable so an active document served beside `/api` would have its script pass the browser-trust fence into every method, the loopback-pinned settings and credential plane included. A different origin closes that without touching the document: a preview keeps `localStorage`, cookies, and its own `fetch`, while a call to the API is cross-origin and refused twice over — by the fence's Origin check and by CORS. The alternative, `Content-Security-Policy: sandbox`, buys the same boundary by taking the document's origin away entirely, which measurably breaks the pages this route exists to show (a page that reads `localStorage` throws on load, and every listener declared after that line in the same script never binds). The listener binds the same host as the API, so a client that can reach the app can reach its previews; it answers the `/f` prefix and nothing else — no index, no SPA fallback, no API — and its port is published into the index page as `window.__DSH_FILES_PORT__`, which the browser half reads to address it. The same trust fence gates it, 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.
A fixture page is served by no host, so no workspace-file port is published into it and `ConnectionHandle.fileUrl` answers `undefined` a file-path row falls back to the Host opener rather than opening a dead tab.
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.
+2 -2
View File
@@ -12,11 +12,11 @@ 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` 定型而非作为下载给出,因为工作区读取本就是一个“让我看看这个文件”的请求。
能执行脚本的文档——`.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` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。
工作区文件由它自己的端口提供,因而拥有自己的源。那个端口就是隔离:工作区文件未必由 agent 撰写——一条 read 行就能让 clone 下来的仓库里任何文件变得可打开——因此与 `/api` 并排提供的活动文档,其脚本会带着浏览器信任 fence 通行到每一个方法,包括那些正因会改动设置与凭据而被钉在回环的方法。换一个源即可堵死这条,且不必动文档本身:预览保有 `localStorage`、cookie 与自己的 `fetch`而对 API 的调用属于跨源,会被两道独立的关卡拒绝——fence 的 Origin 校验,以及 CORS。另一种做法 `Content-Security-Policy: sandbox` 用"干脆剥夺文档的源"换来同一条边界,而这经实测会破坏本路由存在的意义所在的那类页面(读 `localStorage` 的页面在加载时抛异常,同一 script 块中该行之后声明的所有监听器都不会绑定)。该监听器绑定与 API 相同的 host,因此能访问应用的客户端也能访问它的预览;它只应答 `/f` 前缀,别无其他——没有首页、没有 SPA 兜底、没有 API——其端口以 `window.__DSH_FILES_PORT__` 注入首页,由浏览器半侧读取来寻址。它由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。
## 无密钥 fixture
fixture 载体没有 `/f` 路由,而 `IWorkspaces.fileUrl` 无论载体为何都在浏览器侧推导 URL,因此在 `fixture=` 下点击文件路径行会打开一个 404 的标签页,而此处从前是 Host 打开器的静默空操作。今天的 fixture 页面并不含文件行;若某个 fixture 场景要加上它们,应当把这段推导打桩,而不是教这个内存载体去提供字节
fixture 页面不由任何 host 提供,因此没有工作区文件端口注入其中,`ConnectionHandle.fileUrl` 应答 `undefined`——文件路径行会回退到 Host 打开器,而不是打开一个空标签页
任何 `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。
@@ -4,6 +4,9 @@
* controller with its sinks.
*/
import type { Context } from 'cordis'
import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { FILES_PORT_GLOBAL } from '../files-server.ts'
import type { IApiClient } from './api.ts'
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
import { FixtureApiClient } from './fixture.ts'
@@ -56,6 +59,19 @@ export interface ConnectionHandle {
* @returns stop handle for the loop.
*/
start(sinks: ConnectionSinks, config?: ConnectionConfig): { stop(): void }
/**
* Absolute URL serving one file out of a Session's workspace, on the
* transport's own workspace-file origin — the same hostname the page is
* reached by, a different port, so a served document is isolated from this
* API without being stripped of its own capabilities.
* @param sessionId - the Session whose cwd anchors the path.
* @param cwd - that Session's working directory, or `undefined` when unknown.
* @param path - the path a tool reported (absolute, or relative to `cwd`).
* @returns the URL, or `undefined` when the path lies outside the workspace
* (which this transport never serves) or when this page was not served by a
* host that published a workspace-file port (the fixture carrier).
*/
fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined
}
/**
@@ -68,6 +84,15 @@ export function apply(ctx: Context): void {
let started = false
const handle: ConnectionHandle = {
api,
fileUrl(sessionId, cwd, path) {
// Published by the node half's index tap; absent means no host is
// serving workspace files to this page (the keyless fixture lane).
const port = (globalThis as unknown as Record<string, unknown>)[FILES_PORT_GLOBAL]
if (typeof port !== 'number') return undefined
const segments = workspaceFileSegments(cwd, path)
if (segments === undefined) return undefined
return `${location.protocol}//${location.hostname}:${String(port)}${workspaceFileUrl(sessionId, segments)}`
},
start(sinks, config) {
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
started = true
@@ -0,0 +1,127 @@
/**
* The workspace-file listener: a second loopback/LAN socket on the same host
* as the API, serving nothing but `/f`.
*
* The port is the isolation. A workspace file is not necessarily
* agent-authored — a read row makes every file in a cloned repository
* openable — so an active document must not be same-origin with `/api`, where
* its script would pass the browser-trust fence into every method, the
* loopback-pinned settings and credential plane included. A different port is
* a different origin, which the browser enforces for free: the document keeps
* `localStorage`, cookies, and its own `fetch`, while a call to the API is
* cross-origin and refused twice over — by the fence's Origin check and by
* CORS. The alternative, `Content-Security-Policy: sandbox`, buys the same
* boundary by taking the document's origin away entirely, which measurably
* breaks the pages this route exists to show.
*/
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import type { AddressInfo } from 'node:net'
import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api'
import { isTrustedApiRequest } from './api-request-trust.ts'
import { handleWorkspaceFile, type WorkspaceFileDeps } from './workspace-files.ts'
/** A listening workspace-file server: its port, and the teardown that reaches quiescence. */
export interface FilesServer {
/** The bound port (OS-assigned), which the browser half needs to address this origin. */
port: number
/** Close the socket and destroy held connections; resolves once quiet. */
close: () => Promise<void>
}
/**
* Bind the workspace-file listener.
* @param host - the same bind host the API uses, so a client that can reach
* the app can reach its previews (a LAN deployment included).
* @param trustedHosts - the deployment's non-loopback serving authorities,
* applied through the same fence as `/api`.
* @param deps - the session-to-directory lookup reads are confined by.
* @param onSocketError - reports a post-listen socket error; without a
* listener node would raise it as an unhandled 'error' event.
* @returns the bound port and its disposer.
*/
export async function listenForWorkspaceFiles(
host: string,
trustedHosts: readonly string[],
deps: WorkspaceFileDeps,
onSocketError: (error: Error) => void,
): Promise<FilesServer> {
const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
if (!isTrustedApiRequest(req, trustedHosts)) {
res.writeHead(403)
res.end('forbidden')
return
}
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
// This origin serves one prefix and nothing else: no index, no SPA
// fallback, no API. Anything else is not here — answered before the method
// check, because a 405 would claim the resource exists.
if (pathname !== FILES_PATH && !pathname.startsWith(`${FILES_PATH}/`)) {
res.writeHead(404)
res.end()
return
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
// RFC 9110 §15.5.6: a 405 names the methods the resource does support.
res.writeHead(405, { allow: 'GET, HEAD' })
res.end()
return
}
await handleWorkspaceFile(req, res, deps)
}
const server: Server = createServer((req, res) => {
handle(req, res).catch((error: unknown) => {
// A malformed request must not become an unhandled rejection that takes
// the process down; the API carrier guards its own handler the same way.
if (res.headersSent) {
res.destroy()
return
}
onSocketError(error instanceof Error ? error : new Error(String(error)))
res.writeHead(400)
res.end()
})
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, host, () => {
server.off('error', reject)
server.on('error', onSocketError)
resolve()
})
})
return {
port: (server.address() as AddressInfo).port,
// close + closeAllConnections: a held-open response would otherwise keep
// teardown waiting forever.
close: () => new Promise<void>((resolve) => {
server.close(() => { resolve() })
server.closeAllConnections()
}),
}
}
/** The global the node half hands its port to the browser half through. */
export const FILES_PORT_GLOBAL = '__DSH_FILES_PORT__'
/**
* Inject the workspace-file port into index.html, ahead of the shell bundle
* that reads it. A boot-time fact of the serving host, delivered the way the
* module graph is: synchronously on the page, so the first click on a produced
* file does not race a round trip.
* @param html - the index.html source.
* @param port - the bound workspace-file port.
* @returns the html with the port script injected.
*/
export function injectFilesPort(html: string, port: number): string {
const script = `<script>window.${FILES_PORT_GLOBAL} = ${String(port)}</script>`
const head = html.indexOf('<head>')
if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
/* v8 ignore next -- headless fixture pages may lack <head>; prepending keeps read-before-shell ordering. */
return `${script}${html}`
}
+19 -22
View File
@@ -4,14 +4,13 @@ import z from 'schemastery'
// Activates the httpServer Context merge used below.
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api'
// The merge-free types subpath: pulling the session package's root into this
// client-registered program would merge the host `sessions` service over the
// browser runtime's own.
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { API_PATH } from './api-path.ts'
import { bridge } from './http-bridge.ts'
import { handleWorkspaceFile } from './workspace-files.ts'
import { injectFilesPort, listenForWorkspaceFiles } from './files-server.ts'
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
export { API_PATH } from './api-path.ts'
@@ -74,8 +73,10 @@ const PRIVILEGED_METHODS = new Set([
* additionally pass it with an empty trust list, which pins them to loopback.
* @param ctx - Host plugin context.
* @param config - resolved plugin config (schema defaults applied).
* @returns a promise settling once the workspace-file listener is bound and
* its port published — the page must never render before it can address one.
*/
export function apply(ctx: Context, config?: ConnectionConfig): void {
export async function apply(ctx: Context, config?: ConnectionConfig): Promise<void> {
// The Loader resolves schema defaults; hand-built test contexts may pass none.
const trustedHosts = config?.trustedHosts ?? []
// Config boundary: a malformed entry fails the load loudly here rather than
@@ -108,23 +109,19 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
// would merge their host-side Context declarations into the browser lane.
const cwdFor = (sessionId: string): Promise<string | undefined> =>
ctx.apiProxy.workspaceRootOf(sessionId as SessionId)
const filesRoute: WebRoute = {
kind: 'prefix',
path: FILES_PATH,
handler: async (req, res) => {
if (!isTrustedApiRequest(req, trustedHosts)) {
res.writeHead(403)
res.end('forbidden')
return
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
// RFC 9110 §15.5.6: a 405 names the methods the resource does support.
res.writeHead(405, { allow: 'GET, HEAD' })
res.end()
return
}
await handleWorkspaceFile(req, res, { cwdFor })
},
}
ctx.effect(() => ctx.httpServer.register(filesRoute), 'client-connection: /f route')
// Workspace files get their own port, and therefore their own origin: an
// active document served beside `/api` would reach every method through the
// fence below. The listen is awaited inside the effect so the port is known
// before the index tap that publishes it can run.
await ctx.effect(async () => {
const files = await listenForWorkspaceFiles(
ctx.httpServer.host, trustedHosts, { cwdFor },
(error) => { ctx.logger.error(error) },
)
const untap = ctx.httpServer.tapIndex(html => injectFilesPort(html, files.port))
return async () => {
untap()
await files.close()
}
}, 'client-connection: /f listener')
}
@@ -10,13 +10,11 @@
* owns the browser-trust fence ([api-request-trust](./api-request-trust.ts)) —
* this module is reached only by requests that already passed it.
*
* 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.
* Isolation is the listener's, not this module's: these responses carry no
* sandbox header because they are served from their own port, and therefore
* their own origin ([files-server](./files-server.ts)). A served document
* keeps `localStorage`, cookies, and its own `fetch`, while the API stays
* cross-origin to it.
*/
import { createReadStream } from 'node:fs'
@@ -59,17 +57,6 @@ 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 {
/**
@@ -160,7 +147,6 @@ 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()
@@ -8,10 +8,11 @@ import { apply, type ConnectionHandle } from '../src/client/index.ts'
import { FixtureApiClient } from '../src/client/fixture.ts'
import { WebApiClient } from '../src/client/web-api-client.ts'
type Win = { location?: { search: string } }
type Win = { location?: { search: string; protocol?: string; hostname?: string }; __DSH_FILES_PORT__?: number }
afterEach(() => {
delete (globalThis as Win).location
delete (globalThis as Win).__DSH_FILES_PORT__
})
async function mount(): Promise<ConnectionHandle> {
@@ -62,4 +63,28 @@ describe('connection client apply', () => {
}
expect(seen.some(u => u.includes('/api/'))).toBe(true)
})
it('addresses a workspace file on the port the host published, and only inside the workspace', async () => {
const win = globalThis as Win
win.location = { search: '', protocol: 'http:', hostname: '192.168.1.5' }
win.__DSH_FILES_PORT__ = 4321
const handle = await mount()
const session = 's-1' as never
// Same hostname the page was reached by — a LAN client must reach previews
// too — and the published port, which is what makes it another origin.
expect(handle.fileUrl(session, '/w/alpha', '/w/alpha/out/a b.html'))
.toBe('http://192.168.1.5:4321/f/s-1/out/a%20b.html')
// Outside the workspace there is nothing this transport may serve, which
// is the signal a caller falls back to openPath on.
expect(handle.fileUrl(session, '/w/alpha', '/etc/hosts')).toBeUndefined()
})
it('serves no file URL on a page no host published a port into', async () => {
const win = globalThis as Win
win.location = { search: '?fixture', protocol: 'http:', hostname: '127.0.0.1' }
const handle = await mount()
// The keyless fixture lane: no workspace-file origin exists, so the row
// falls back to the Host opener instead of opening a dead tab.
expect(handle.fileUrl('s-1' as never, '/w', 'a.txt')).toBeUndefined()
})
})
@@ -0,0 +1,44 @@
/** The workspace-file listener's own failure and publication paths. */
import { describe, expect, it } from 'vitest'
import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api'
import { injectFilesPort, listenForWorkspaceFiles } from '../src/files-server.ts'
describe('workspace-file listener', () => {
it('answers 400 and reports the failure when the directory lookup throws', async () => {
const seen: Error[] = []
const files = await listenForWorkspaceFiles(
'127.0.0.1', [],
{ cwdFor: () => Promise.reject(new Error('store unavailable')) },
(error) => { seen.push(error) },
)
try {
// A lookup failure is the host's problem, not a miss: it must not become
// an unhandled rejection, and it must not be reported as "not found".
const response = await fetch(`http://127.0.0.1:${String(files.port)}${FILES_PATH}/s-1/a.txt`)
expect(response.status).toBe(400)
expect(seen.map(error => error.message)).toEqual(['store unavailable'])
} finally {
await files.close()
}
})
it('closes idempotently and stops answering', async () => {
const files = await listenForWorkspaceFiles(
'127.0.0.1', [], { cwdFor: async () => undefined }, () => {},
)
const origin = `http://127.0.0.1:${String(files.port)}`
expect((await fetch(`${origin}${FILES_PATH}/s-1/a.txt`)).status).toBe(404)
await files.close()
await files.close()
await expect(fetch(`${origin}${FILES_PATH}/s-1/a.txt`)).rejects.toThrow()
})
})
describe('injectFilesPort', () => {
it('publishes the port as the first script in head', () => {
const html = injectFilesPort('<html><head><title>x</title></head></html>', 4321)
expect(html).toContain('<head><script>window.__DSH_FILES_PORT__ = 4321</script>')
// Ahead of anything the shell might read it from.
expect(html.indexOf('__DSH_FILES_PORT__')).toBeLessThan(html.indexOf('<title>'))
})
})
@@ -15,14 +15,21 @@ import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api'
import { API_PATH, apply, inject } from '../src/index.ts'
/** Structural httpServer fake: the plugin only touches register(). */
function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> {
function fakeHttpServer(
routes: WebRoute[],
taps: ((html: string) => string)[] = [],
): Pick<HttpServerService, 'register' | 'tapIndex' | 'port' | 'host'> {
return {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex: () => () => {},
tapIndex(transform) {
taps.push(transform)
return () => { taps.splice(taps.indexOf(transform), 1) }
},
port: 0,
host: '127.0.0.1',
}
}
@@ -61,21 +68,39 @@ function fakeApiProxy(workspaces: Record<string, string> = {}): ApiProxy {
async function mounted(
config?: { trustedHosts?: string[] },
workspaces: Record<string, string> = {},
): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
): Promise<{ routes: WebRoute[]; taps: ((html: string) => string)[]; dispose: () => Promise<void> }> {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
const taps: ((html: string) => string)[] = []
ctx.provide('httpServer', fakeHttpServer(routes, taps) as HttpServerService)
ctx.provide('apiProxy', fakeApiProxy(workspaces))
const fiber = ctx.plugin({ inject: [...inject], apply }, config)
await fiber.await()
return { routes, dispose: () => fiber.dispose() }
return { routes, taps, dispose: () => fiber.dispose() }
}
/** The /f route is registered after /api; both are prefix routes on the same server. */
function filesRoute(routes: WebRoute[]): WebRoute {
const route = routes.find(candidate => candidate.path === FILES_PATH)
if (route === undefined) throw new Error('the /f route was not registered')
return route
/** One raw GET whose Host header is spoofed (fetch forbids setting it). */
function statusWithHost(origin: string, path: string, host: string): Promise<number> {
const url = new URL(origin)
return new Promise((resolve, reject) => {
const request = httpRequest(
{ host: url.hostname, port: url.port, path, method: 'GET', headers: { host } },
(response) => {
response.resume()
response.on('end', () => { resolve(response.statusCode ?? 0) })
},
)
request.on('error', reject)
request.end()
})
}
/** The workspace-file origin the node half published into the index page. */
function filesOrigin(taps: ((html: string) => string)[]): string {
const html = taps.reduce((acc, tap) => tap(acc), '<head></head>')
const port = /__DSH_FILES_PORT__ = (\d+)/.exec(html)?.[1]
if (port === undefined) throw new Error(`no workspace-file port was published: ${html}`)
return `http://127.0.0.1:${port}`
}
describe('connection node half', () => {
@@ -89,11 +114,19 @@ describe('connection node half', () => {
expect(routes).toHaveLength(0)
})
it('registers both transport prefix routes and removes them with the fiber', async () => {
const { routes, dispose } = await mounted()
expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }, { kind: 'prefix', path: FILES_PATH }])
it('registers the /api route and publishes a separate workspace-file origin, both removed with the fiber', async () => {
const { routes, taps, dispose } = await mounted()
// The API keeps one prefix on the shared server; workspace files get a
// port of their own, which is the origin boundary between them.
expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }])
const origin = filesOrigin(taps)
expect(new URL(origin).port).not.toBe('')
expect((await fetch(`${origin}${FILES_PATH}/absent/x.txt`)).status).toBe(404)
await dispose()
expect(routes).toHaveLength(0)
expect(taps).toHaveLength(0)
// Disposal reaches quiescence: the socket is gone, not merely unrouted.
await expect(fetch(`${origin}${FILES_PATH}/absent/x.txt`)).rejects.toThrow()
})
it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
@@ -154,7 +187,7 @@ describe('connection node half', () => {
})
})
describe('connection node half: the /f workspace-file route', () => {
describe('connection node half: the workspace-file origin', () => {
/** A workspace holding one file, torn down with the returned disposer. */
async function workspace(): Promise<{ cwd: string; remove: () => Promise<void> }> {
const cwd = await mkdtemp(join(tmpdir(), 'dsh-node-half-'))
@@ -162,40 +195,35 @@ describe('connection node half: the /f workspace-file route', () => {
return { cwd, remove: () => rm(cwd, { recursive: true, force: true }) }
}
/** HEAD keeps the assertion on the route's decision, not on the byte stream. */
function head(url: string, headers: Record<string, string> = { host: '127.0.0.1:3080' }): IncomingMessage {
const request = fakeRequest(headers, url)
Object.assign(request, { method: 'HEAD' })
return request
}
it('applies the same browser-trust fence as /api, and refuses writes', async () => {
const { routes, dispose } = await mounted()
const untrusted = fakeResponse()
await filesRoute(routes).handler(head(`${FILES_PATH}/s-1/index.html`, { host: 'harness.example' }), untrusted.response)
expect(untrusted.state.status).toBe(403)
expect(untrusted.state.body).toBe('forbidden')
const written = fakeResponse()
const post = fakeRequest({ host: '127.0.0.1:3080' }, `${FILES_PATH}/s-1/index.html`)
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' })
it('applies the same browser-trust fence as /api, refuses writes, and serves nothing else', async () => {
const { taps, dispose } = await mounted()
const origin = filesOrigin(taps)
// Rebound Host: refused before any filesystem work, exactly as on /api.
// node's fetch refuses to set Host (a forbidden header), so the spoof goes
// through the raw client — the same parse the server really performs.
expect(await statusWithHost(origin, `${FILES_PATH}/s-1/index.html`, 'harness.example')).toBe(403)
const written = await fetch(`${origin}${FILES_PATH}/s-1/index.html`, { method: 'POST' })
expect(written.status).toBe(405)
expect(written.headers.get('allow')).toBe('GET, HEAD')
// This origin is one route wide: no index, no SPA fallback, no API.
expect((await fetch(`${origin}/`)).status).toBe(404)
expect((await fetch(`${origin}${API_PATH}/session.list`, { method: 'POST' })).status).toBe(404)
await dispose()
})
it('confines reads to the directory the gateway names for that session', async () => {
const { cwd, remove } = await workspace()
const { routes, dispose } = await mounted(undefined, { 's-1': cwd })
const served = fakeResponse()
await filesRoute(routes).handler(head(`${FILES_PATH}/s-1/index.html`), served.response)
expect(served.state.status).toBe(200)
const { taps, dispose } = await mounted(undefined, { 's-1': cwd })
const origin = filesOrigin(taps)
const served = await fetch(`${origin}${FILES_PATH}/s-1/index.html`)
expect(served.status).toBe(200)
expect(await served.text()).toBe('<h1>ok</h1>')
// A served document keeps its own capabilities: the port is the boundary,
// so nothing here strips the document of its origin.
expect(served.headers.get('content-security-policy')).toBeNull()
// A session the gateway names no directory for has no workspace to confine
// against, so there is nothing to serve.
const unknown = fakeResponse()
await filesRoute(routes).handler(head(`${FILES_PATH}/s-absent/index.html`), unknown.response)
expect(unknown.state.status).toBe(404)
expect((await fetch(`${origin}${FILES_PATH}/s-absent/index.html`)).status).toBe(404)
await dispose()
await remove()
})
@@ -59,27 +59,25 @@ function get(path: string, init?: RequestInit): Promise<Response> {
}
describe('workspace file reads', () => {
it('serves an active document into an opaque origin', async () => {
it('serves a produced document with its own capabilities intact', 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')
// 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')
// No isolation header: the listener's own port is the origin boundary, so
// a preview keeps localStorage and cookies (see files-server).
expect(response.headers.get('content-security-policy')).toBeNull()
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('sandboxes SVG too, and leaves inert types unrestricted', async () => {
it('types SVG as a standalone document rather than sniffable bytes', async () => {
const svg = await get(`${FILES_PATH}/${SESSION}/chart.svg`)
expect(svg.headers.get('content-type')).toBe('image/svg+xml')
expect(svg.headers.get('content-security-policy')).toContain('sandbox')
expect(svg.headers.get('x-content-type-options')).toBe('nosniff')
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 () => {
@@ -56,17 +56,6 @@ export interface IWorkspaces {
* @param path - absolute or host-resolvable path.
*/
openPath(path: string): Promise<void>
/**
* URL serving one file out of a session's workspace, for a UI that opens a
* produced file in the browser instead of on the Host machine.
* @param sessionId - the session whose cwd anchors the path.
* @param cwd - that session's working directory, or `undefined` when unknown.
* @param path - the path a tool reported (absolute, or relative to `cwd`).
* @returns the origin-relative URL, or `undefined` when the path lies
* outside the workspace — which this transport never serves, leaving
* {@link IWorkspaces.openPath} as the only way to reach it.
*/
fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined
/**
* Rename a Workspace.
* @param workspaceId - target workspace.
@@ -5,7 +5,6 @@ import type {
DirectoryListing, IApiClient, RpcError,
SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts'
@@ -240,18 +239,6 @@ export class WorkspacesService implements IWorkspaces {
}
}
/**
* URL serving one file out of a session's workspace.
* @param sessionId - the session whose cwd anchors the path.
* @param cwd - that session's working directory, or `undefined` when unknown.
* @param path - the path a tool reported (absolute, or relative to `cwd`).
* @returns the origin-relative URL, or `undefined` for a path outside the workspace.
*/
fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined {
const segments = workspaceFileSegments(cwd, path)
if (segments === undefined) return undefined
return workspaceFileUrl(sessionId, segments)
}
/**
* Rename a Workspace.
@@ -26,6 +26,7 @@ async function mount(): Promise<Bench> {
const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
const handle: ConnectionHandle = {
api,
fileUrl: () => undefined,
start: (sinks) => {
bench.sinks = sinks
return { stop: () => { bench.stopped += 1 } }
@@ -20,6 +20,7 @@ async function mount(): Promise<Bench> {
const bench: Bench = { ctx, sinks: undefined }
const handle: ConnectionHandle = {
api,
fileUrl: () => undefined,
start: (sinks) => {
bench.sinks = sinks
return { stop: () => {} }
@@ -276,21 +276,6 @@ describe('WorkspacesService', () => {
await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/)
})
it('addresses a workspace file by URL, and only inside the workspace', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
const session = 's-1' as SessionId
// The URL is derived, not fetched: no wire call answers a link.
expect(workspaces.fileUrl(session, '/w/alpha', '/w/alpha/out/a b.html')).toBe('/f/s-1/out/a%20b.html')
expect(workspaces.fileUrl(session, '/w/alpha', 'out/index.html')).toBe('/f/s-1/out/index.html')
// Outside the workspace there is nothing this transport may serve, which
// is the signal a caller falls back to openPath on.
expect(workspaces.fileUrl(session, '/w/alpha', '/etc/hosts')).toBeUndefined()
expect(api.calls).toHaveLength(0)
})
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
const ctx = new Context()
const api = new FakeApiClient()
@@ -25,6 +25,7 @@
"vitest": "^4.1.8"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
@@ -35,6 +36,7 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
@@ -0,0 +1,48 @@
/** Test-owned connection face: the transport members features read off `ctx.connection`. */
import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ConnectionHandle, IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
/**
* Connection test double. Implements the same `ConnectionHandle` face features
* receive as `ctx.connection`, so a production face change breaks this double
* at compile time. The wire client is not modelled — a feature that needs one
* composes its own connection over a fake api client; this double exists for
* the transport facts features read synchronously, above all the
* workspace-file URL.
*/
export class TestConnection implements ConnectionHandle {
/**
* The workspace-file port the host would have published into the page.
* Unset — the default, and the keyless fixture lane's real state — makes
* {@link TestConnection.fileUrl} answer `undefined`, which is the signal a
* caller falls back to the Host opener on.
*/
filesPort: number | undefined
/** The wire client; unused by this double's consumers and absent by construction. */
readonly api: IApiClient = undefined as unknown as IApiClient
/**
* Stream-loop starter (inert).
* @returns a stop handle that does nothing.
*/
start(): { stop(): void } {
return { stop: () => {} }
}
/**
* Workspace-file URL, deriving exactly as production does so a feature test
* sees the real inside/outside-workspace split.
* @param sessionId - the Session whose cwd anchors the path.
* @param cwd - that Session's working directory.
* @param path - the path a tool reported.
* @returns the absolute URL on the workspace-file origin, or undefined when
* the path leaves the workspace or no port is published.
*/
fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined {
if (this.filesPort === undefined) return undefined
const segments = workspaceFileSegments(cwd, path)
if (segments === undefined) return undefined
return `http://localhost:${String(this.filesPort)}${workspaceFileUrl(sessionId, segments)}`
}
}
@@ -29,11 +29,13 @@ import type {
} from '@deepseek-ai/dsh-client-ui-slots'
import { registerDomSnapshotSerializer } from './snapshot.ts'
import { TestSessions } from './sessions.ts'
import { TestConnection } from './connection.ts'
import { TestWorkspaces } from './workspaces.ts'
import type { Stabilizer } from './fixtures.ts'
export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts'
export { FixtureSession, TestSessions } from './sessions.ts'
export { TestConnection } from './connection.ts'
export { TestWorkspaces } from './workspaces.ts'
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
@@ -175,6 +177,8 @@ export class SlotTestRuntime {
readonly sessions: TestSessions
/** Workspaces double (list observable, recorded intent actions). */
readonly workspaces: TestWorkspaces
/** The transport double features read as `ctx.connection`. */
readonly connection: TestConnection
private readonly stabilizer: Stabilizer = async (fn) => {
await act(async () => { await fn() })
@@ -195,8 +199,10 @@ export class SlotTestRuntime {
this.root = new TestRoot(slots, this.stabilizer)
this.sessions = new TestSessions(this.stabilizer, ctx)
this.workspaces = new TestWorkspaces(this.stabilizer)
this.connection = new TestConnection()
ctx.provide('sessions', this.sessions)
ctx.provide('workspaces', this.workspaces)
ctx.provide('connection', this.connection)
// Capturing install: the production renderer does the rendering; the
// wrapper only takes the host face for storeOf (no machinery copied).
const renderer = createSlotRenderer()
@@ -1,6 +1,5 @@
/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -99,21 +98,6 @@ export class TestWorkspaces implements IWorkspaces {
await (this.stubs.get('openPath')?.(path) as Promise<void> | undefined)
}
/**
* Workspace-file URL (recorded). Runs the production path derivation so a
* feature test sees the real in/outside-workspace split; stub to force either.
* @param sessionId - the session whose cwd anchors the path.
* @param cwd - that session's working directory.
* @param path - the path a tool reported.
* @returns the origin-relative URL, or undefined outside the workspace.
*/
fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined {
this.calls.push({ method: 'fileUrl', args: [sessionId, cwd, path] })
const stub = this.stubs.get('fileUrl')
if (stub !== undefined) return stub(sessionId, cwd, path) as string | undefined
const segments = workspaceFileSegments(cwd, path)
return segments === undefined ? undefined : workspaceFileUrl(sessionId, segments)
}
/**
* Directory picker (recorded). The default cancels (null); stub to select.
@@ -549,10 +549,6 @@ describe('workspaces action face', () => {
expect(renamed.title).toBe('Renamed')
await ws.delete('w1' as WorkspaceId)
await ws.openPath('/proj/file.ts')
// fileUrl runs the production derivation, so a feature test sees the same
// inside/outside-workspace split the browser half decides on.
expect(ws.fileUrl('s1' as SessionId, '/proj', 'out/a.html')).toBe('/f/s1/out/a.html')
expect(ws.fileUrl('s1' as SessionId, '/proj', '/etc/hosts')).toBeUndefined()
const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId)
expect(moved.sessionIds).toEqual(['s1'])
// Default archive mirrors the production effect: the id joins the list
@@ -560,15 +556,13 @@ describe('workspaces action face', () => {
await ws.archiveSession('s1' as SessionId)
expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1'])
expect(ws.calls.map(c => c.method)).toEqual(
['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'fileUrl', 'fileUrl',
'insertSessionBefore', 'archiveSession'])
['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore', 'archiveSession'])
ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never))
ws.stub('pickDirectory', () => Promise.resolve('/picked'))
ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never))
ws.stub('delete', () => Promise.resolve())
ws.stub('openPath', () => Promise.resolve())
ws.stub('fileUrl', () => '/f/forced/a.html')
ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never))
ws.stub('archiveSession', () => Promise.resolve())
expect((await ws.create({ name: 'y' })).title).toBe('X')
@@ -576,7 +570,6 @@ describe('workspaces action face', () => {
expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S')
await ws.delete('w1' as WorkspaceId)
await ws.openPath('/other')
expect(ws.fileUrl('s1' as SessionId, '/proj', '/etc/hosts')).toBe('/f/forced/a.html')
expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([])
// The stub replaces the default set mutation: the set stays as-is.
await ws.archiveSession('s2' as SessionId)
@@ -17,6 +17,9 @@
{
"path": "../web-react"
},
{
"path": "../connection"
},
{
"path": "../runtime"
},
@@ -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: ba55f0704500034b7afb37258064fe0801aaee91
README.zh.md: 4212908b355a81dfd5af8645ce5d4284a4555622
README.md: 8c2075d615eccad1bbc7f5de1255ea4add69fab8
README.zh.md: 634721b4248da75cbd4e81528340936a31ece28d
+1 -1
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: 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.
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 on the transport's workspace-file origin (`ConnectionHandle.fileUrl`), 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)).
+1 -1
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 图标和路径摘要;该路径是悬停下划线链接,点击即打开文件:位于会话工作区之内的文件在新浏览器标签页打开,由 web 传输的 `/f` 路由提供,因此不在 Host 机器上的客户端也能看到;工作区之外的文件没有可服务的 URL,回退到宿主操作系统的默认应用(`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 图标和路径摘要;该路径是悬停下划线链接,点击即打开文件:位于会话工作区之内的文件在新浏览器标签页打开,位于传输层的工作区文件源上(`ConnectionHandle.fileUrl`,因此不在 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))。
@@ -39,6 +39,7 @@
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
@@ -50,6 +51,7 @@
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
@@ -2,6 +2,7 @@
import type { Context } from 'cordis'
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -42,7 +43,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
/** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection']
// Static no-session sources for the composer-bar hooks compartment: module
// constants so the render side's per-source hook cache (observableHook) keeps
@@ -275,11 +276,12 @@ export function apply(ctx: Context): void {
},
openFile: (path) => {
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
// A file inside the workspace opens in a new tab, so a browser that
// is not on the Host machine can still see what the agent produced.
// Anything outside it has no served URL and falls back to the Host's
// own opener, which is loopback-only by the /api trust fence.
const url = workspaces.fileUrl(sessionId, cwd, path)
// A file inside the workspace opens in a new tab on the transport's
// workspace-file origin, so a browser that is not on the Host machine
// can still see what the agent produced. Anything outside it has no
// served URL and falls back to the Host's own opener, which is
// loopback-only by the /api trust fence.
const url = (ctx.get('connection') as ConnectionHandle).fileUrl(sessionId, cwd, path)
if (url !== undefined) {
window.open(url, '_blank', 'noopener,noreferrer')
return
@@ -220,12 +220,15 @@ describe('conversation slot inject surface', () => {
it('openFile (chat view face) opens a workspace file in a tab and falls back to the host opener outside it', async () => {
const b = await bench()
// A host that publishes a workspace-file port: previews come from that
// origin, which is what keeps them off the API's.
b.runtime.connection.filesPort = 4321
const open = vi.spyOn(window, 'open').mockReturnValue(null)
const { injected } = b.chatViewSurface(ROOT)
// Inside the session cwd: served by this origin, so a browser anywhere on
// the network sees the file the agent produced.
// Inside the session cwd: served on the workspace-file origin, so a browser
// anywhere on the network sees the file the agent produced.
injected.openFile('src/a.ts')
expect(open).toHaveBeenCalledWith(`/f/${ROOT}/src/a.ts`, '_blank', 'noopener,noreferrer')
expect(open).toHaveBeenCalledWith(`http://localhost:4321/f/${ROOT}/src/a.ts`, '_blank', 'noopener,noreferrer')
expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false)
// Outside it there is no served URL, so the Host's own opener answers —
// resolved against the session cwd exactly as before.
@@ -134,9 +134,11 @@ async function bench(snapshot: ConversationSnapshot) {
startSession: vi.fn(),
sendSession: vi.fn(),
openPath: vi.fn(async () => {}),
fileUrl: vi.fn((_sessionId: unknown, _cwd: string | undefined, path: string) => `/f/s-1/${path}`),
}
ctx.provide('workspaces', workspaces)
// The transport face the chat view reads its workspace-file URLs from.
const connection = { fileUrl: vi.fn((_s: unknown, _cwd: string | undefined, path: string) => `http://localhost:4321/f/s-1/${path}`) }
ctx.provide('connection', connection)
ctx.provide('layout', layout)
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
@@ -249,7 +251,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
view.getByText('notes/demo.txt').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(open).toHaveBeenCalledWith('/f/s-1/notes/demo.txt', '_blank', 'noopener,noreferrer')
expect(open).toHaveBeenCalledWith('http://localhost:4321/f/s-1/notes/demo.txt', '_blank', 'noopener,noreferrer')
})
open.mockRestore()
view.getByText('List notes').click()
@@ -121,6 +121,7 @@ describe('keyed toolview hole through the real machinery', () => {
it('file-path clicks travel owner openFile → chat inject → the served workspace URL', async () => {
const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')])
b.runtime.connection.filesPort = 4321
const open = vi.spyOn(window, 'open').mockReturnValue(null)
const view = b.runtime.renderRoot()
view.getByText('src/a.ts').click()
@@ -20,6 +20,9 @@
{
"path": "../web-react"
},
{
"path": "../connection"
},
{
"path": "../runtime"
},
+6
View File
@@ -1183,6 +1183,9 @@ importers:
specifier: ^4.1.8
version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
devDependencies:
'@deepseek-ai/dsh-client-connection':
specifier: workspace:^
version: link:../connection
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
version: link:../runtime
@@ -1263,6 +1266,9 @@ importers:
specifier: ^2.0.0
version: 2.1.1
devDependencies:
'@deepseek-ai/dsh-client-connection':
specifier: workspace:^
version: link:../connection
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../locale