From 00390ae851b838c50e981049156d1a54b8176ce2 Mon Sep 17 00:00:00 2001 From: ZiyaZhang Date: Fri, 31 Jul 2026 12:07:43 -0700 Subject: [PATCH 01/12] feat(web): open a produced file from the conversation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Serve one file at a time out of a Session's workspace under /f on the web transport, and point the conversation's existing file-open affordance at it. Clicking a write/edit/read row's path now opens that file in a browser tab — including from a LAN client, where the Host's system opener is fenced to loopback and answered nothing. - /f// in client-connection, behind the same browser-trust fence as /api; realpath confinement, streamed reads, GET/HEAD only, nosniff + no-store. - Script-capable documents carry CSP sandbox: model-authored markup must not be same-origin with /api, where events.mux is a readable GET stream. - ApiProxy.workspaceRootOf answers where a Session's files live without resuming an agent; the client program cannot reach the core services. - The /f URL shape lives in dsh-host-apiproxy/api so both ends share one encoding (client bundles may not value-import another plugin). --- ...6-07-31-web-workspace-file-links.i18n.yaml | 6 + .../2026-07-31-web-workspace-file-links.md | 38 ++++ .../2026-07-31-web-workspace-file-links.zh.md | 38 ++++ apps/web/tests/workspace-file-open.e2e.ts | 92 ++++++++++ apps/web/tsconfig.json | 3 +- docs/config-catalog.md | 2 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 8 +- packages/client/connection/README.zh.md | 8 +- .../client/connection/src/client/fixture.ts | 5 + packages/client/connection/src/index.ts | 44 ++++- .../client/connection/src/workspace-files.ts | 169 ++++++++++++++++++ .../client/connection/tests/node-half.spec.ts | 78 +++++++- .../connection/tests/workspace-files.spec.ts | 134 ++++++++++++++ .../runtime/src/client/contract/workspaces.ts | 11 ++ .../runtime/src/client/workspaces/service.ts | 14 ++ .../runtime/tests/workspaces-service.spec.ts | 15 ++ .../client/test-runtime/src/workspaces.ts | 17 ++ .../test-runtime/tests/runtime.spec.tsx | 9 +- .../ui-conversation/src/client/apply.ts | 9 + .../tests/apply-inject.spec.tsx | 13 +- .../tests/chat-code-subcalls.spec.tsx | 5 +- .../tests/chat-toolview-slot.spec.tsx | 6 +- packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 + packages/host/apiproxy/README.zh.md | 2 + packages/host/apiproxy/src/api-proxy.ts | 11 ++ packages/host/apiproxy/src/api/files.ts | 98 ++++++++++ packages/host/apiproxy/src/api/index.ts | 18 ++ packages/host/apiproxy/src/index.ts | 2 + .../tests/api-proxy-workspace.spec.ts | 32 +++- .../apiproxy/tests/client-handler.spec.ts | 2 + .../host/apiproxy/tests/fetch-carrier.spec.ts | 2 + .../host/apiproxy/tests/files-path.spec.ts | 74 ++++++++ tsconfig.host.json | 1 + 35 files changed, 946 insertions(+), 30 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md create mode 100644 .agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md create mode 100644 apps/web/tests/workspace-file-open.e2e.ts create mode 100644 packages/client/connection/src/workspace-files.ts create mode 100644 packages/client/connection/tests/workspace-files.spec.ts create mode 100644 packages/host/apiproxy/src/api/files.ts create mode 100644 packages/host/apiproxy/tests/files-path.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml new file mode 100644 index 0000000000..2055af6cea --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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: b7fd5ca240db3ca885e89f4cf6dcc135e7c88de8 +2026-07-31-web-workspace-file-links.zh.md: 74949afe0260d2d9018691740573ff24a1bce820 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md new file mode 100644 index 0000000000..b7fd5ca240 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md @@ -0,0 +1,38 @@ +# Agent Note: opening a produced file from the web UI + +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. + +## Problem + +A web session that produced a file had no way to look at it. The agent wrote `deepseek-homepage.html`, said so, and the user's only recourse was to copy an absolute path like `/private/tmp/dsh-client-hotplug.ygPvsm/workspaces/plugin-hotplug/deepseek-homepage.html` into a terminal. + +The parts were nearly all present, pointed at the wrong target. `ToolRow` already renders a mutation or read row's path as a real button, `ui-conversation` already routes its click through `openFile`, and `workspaces.openPath` already carries it to the Host's system opener. But that opener runs on the Host machine, and `host.openPath` is loopback-pinned by the `/api` trust fence, so the affordance answered nothing for a browser reached over the LAN and was invisible even locally (the path styled as plain text, underlined only on hover). Meanwhile `MarkdownText` strips every non-`http(s)` URL, so a path the model wrote into its closing message could never become a link at all, and `ToolCallView.locations` — the follow-along vocabulary the file tools already populate — had no consumer in the client. + +## Decision + +**One prefix route on the transport that already exists, not a new capability.** `client-connection` owns both browser-facing prefixes: `/api` for RPC and `/f//` for workspace-file reads. It was already the package holding `httpServer`, the `trustedHosts` config, and the browser-trust fence; a separate package would have duplicated the fence and the config, and forced `AppCLIEntry` to patch two rows for one `--trusted-host` flag. The webserver's own contract — every feature surface is a route some other plugin registers — makes the route the whole mechanism. Segments ride the path rather than a query parameter so a served document's relative references resolve to its siblings. + +**The request names a Session; the gateway names the authority.** `ApiProxy.workspaceRootOf` answers where a Session's files live — a live agent's `session.header.cwd` first, then the persistence store, never a resume — as a second, non-envelope face of the `cwd` the session summaries already carry. The route reads that instead of `ctx.agents` directly, because `client-connection` is registered in the client program and importing the core service packages merges their host-side `sessions: SessionStore` declaration over the browser runtime's own `sessions: SessionsService` — the collision `tsconfig.host.json`/`tsconfig.client.json` exist to prevent. Both the cwd and the resolved target go through `realpath` before the prefix comparison, so a workspace-internal symlink pointing outward is refused by its target; traversal spellings are refused at parse time, before any filesystem call. Reads stream through `pipeline`, so a client that goes away destroys the descriptor and no request ever buffers a file. + +**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. + +**Model-authored documents are served into an opaque origin.** `.html`/`.htm`/`.xhtml`/`.svg` carry `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`. Serving generated markup same-origin with `/api` would put `/api/events.mux` — a readable `GET` stream — one `window.open` away from a page the model wrote. The sandbox costs the preview its `localStorage`, cookies, and same-origin `fetch`; `host.openPath` stays as the full-capability way to open the same file on the Host machine, so the trade is resolved by keeping both affordances rather than by weakening either. + +**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. + +## Alternatives considered + +- **The artifact capability family (RFC #268 / PR #272)** — a seam with ids, versions, snapshot storage, its own HTTP server, SSE live reload, and a browser auto-opener. Its review found seven critical issues, and every one of them came from that machinery: an unlistened opener spawn crashing the harness, the opener inheriting `DEEPSEEK_API_KEY`, in-flight publishes outliving disposal, `readFile` preceding the size cap, a snapshot TOCTOU, and retention leaking with undisposed agents. `dsh web` already runs an HTTP server and the user is already in a browser, so none of that machinery buys anything here. The RFC and its tests stay as the input for the day a real cross-session or versioned-artifact need appears; this route is that seam's natural mount point when it does. +- **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/`, 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'` instead of `sandbox`, to keep `localStorage` working** — blocks `fetch`/`EventSource` but not `window.open('/api/events.mux')`, which is readable same-origin. The two GET SSE endpoints are what make the sandbox necessary rather than optional. +- **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. + +## 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//a.txt` in a second tab serving that workspace file, while a traversal spelling answers 404. `localStorage` is unavailable inside a preview, which is visible on generated pages that persist a theme toggle — the Host opener remains for those. Still deferred: the end-of-turn deliverable row derived from `locations`, and any linkification inside assistant Markdown. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md new file mode 100644 index 0000000000..74949afe02 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md @@ -0,0 +1,38 @@ +# Agent Note:从 web UI 打开产出的文件 + +Status: implemented + +[English](2026-07-31-web-workspace-file-links.md) | 中文 + +> 范围:web 传输层上的 `/f` 工作区文件路由、其背后的 `IWorkspaces.fileUrl` 推导,以及会话中打开文件的交互改指向它。不在范围内:产物注册表、版本、实时重载,或任何面向模型的声明。 + +## 问题 + +一个产出了文件的 web 会话,没有办法看到那个文件。agent 写出了 `deepseek-homepage.html` 并如实告知,而用户唯一的办法是把 `/private/tmp/dsh-client-hotplug.ygPvsm/workspaces/plugin-hotplug/deepseek-homepage.html` 这样的绝对路径复制进终端。 + +零件几乎都在,只是指错了目标。`ToolRow` 早已把改写行或读取行的路径渲染成一个真正的按钮,`ui-conversation` 早已把它的点击经由 `openFile` 转发,`workspaces.openPath` 也早已把它送到 Host 的系统打开器。但那个打开器运行在 Host 机器上,而 `host.openPath` 被 `/api` 信任 fence 钉在回环,所以这个交互对经 LAN 访问的浏览器什么都答不了,即便在本机也是隐形的(路径的样式就是普通文本,只有 hover 时才有下划线)。与此同时 `MarkdownText` 会剥掉每一个非 `http(s)` 的 URL,因此模型写进收尾消息里的路径根本不可能成为链接;而 `ToolCallView.locations`——文件工具早已填好的跟随文件词汇——在客户端没有任何消费方。 + +## 决定 + +**在已有的传输层上加一条前缀路由,而不是加一项能力。** `client-connection` 持有两条面向浏览器的前缀:`/api` 承载 RPC,`/f//` 承载工作区文件读取。它本来就是持有 `httpServer`、`trustedHosts` 配置和浏览器信任 fence 的那个包;单开一个包会把 fence 和配置各复制一份,并逼着 `AppCLIEntry` 为一个 `--trusted-host` 标志去 patch 两行。webserver 自己的契约——每个特性面都是别的插件注册的一条路由——让这条路由本身就是全部机制。段落走路径而非查询参数,是为了让所服务文档的相对引用能解析到它的同级文件。 + +**请求指名 Session,由网关指名权限边界。** `ApiProxy.workspaceRootOf` 回答某个 Session 的文件位于何处——先看活跃 agent 的 `session.header.cwd`,再看持久化存储,绝不恢复会话——它是会话摘要早已携带的那个 `cwd` 的第二副面孔,只是不带信封。路由读取它而不是直接够 `ctx.agents`,因为 `client-connection` 注册在 client 程序里,而引入核心服务包会把它们 host 侧的 `sessions: SessionStore` 声明盖到浏览器运行时自己的 `sessions: SessionsService` 之上——这正是 `tsconfig.host.json`/`tsconfig.client.json` 分立所要防的那种冲突。cwd 与解析出的目标在前缀比较前都要过 `realpath`,因此工作区内指向工作区外的符号链接会因其目标而被拒绝;穿越写法在解析期就被拒,早于任何文件系统调用。读取经 `pipeline` 流出,因此客户端离开即销毁描述符,任何请求都不会把文件缓冲起来。 + +**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 机器上以完整能力打开同一文件的方式保留下来,因此这个取舍是靠同时保留两个交互解决的,而不是靠削弱其中之一。 + +**客户端靠推导决定,而不是靠探测。** `IWorkspaces.fileUrl(sessionId, cwd, path)` 把工具报告的路径表达为 session cwd 之下的段落并返回相对于源的 URL,路径离开工作区时返回 `undefined`。`undefined` 恰好就是回退到 `openPath` 的信号,因此工作区外的文件行为与以往一致,也不需要任何能力协商。 + +## 考虑过的替代方案 + +- **产物能力族(RFC #268 / PR #272)**——一条带 id、版本、快照存储、自有 HTTP 服务器、SSE 实时重载与浏览器自动打开器的 seam。它的评审给出了七个 critical,而每一个都来自那套机械结构:未监听的打开器 spawn 会让 harness 崩溃、打开器继承 `DEEPSEEK_API_KEY`、进行中的 publish 活过 dispose、`readFile` 先于大小上限、快照的 TOCTOU,以及未 dispose 的 agent 导致保留期泄漏。`dsh web` 本来就跑着一个 HTTP 服务器,用户本来就在浏览器里,那套机械结构在这里买不到任何东西。RFC 与其测试保留下来,作为真正出现跨会话或版本化产物需求那天的输入;届时这条路由就是那条 seam 的天然挂载点。 +- **单开一个 `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 成为必需而非可选。 +- **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带;消费它才是可靠来源,作为这条路由解锁的后续留下。 + +## 影响 + +现有的每一处文件交互都同时换了目标:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此一处调用点的改动就让产出的文件在浏览器里可打开,LAN 客户端也在内。三个断言旧 `openPath` 去向的测试被改写为新的去向;工作区外的回退保留了旧断言。这条路由对着真实 HTTP 服务器与真实临时工作区做覆盖,因为收敛、内容定型与 sandbox 头都是协议事实;而组装后的 web 通道(`apps/web/tests/workspace-file-open.e2e.ts`,在冷播种会话上无密钥运行)证明了产品路径:点击读取行的路径会在第二个标签页打开 `/f//a.txt` 并提供那个工作区文件,而穿越写法应答 404。预览中无法使用 `localStorage`,这在会持久化主题切换的生成页面上是看得见的——那些场景仍有 Host 打开器。仍然暂缓:由 `locations` 推导的回合末交付物行,以及助手 Markdown 内部的任何链接化。 diff --git a/apps/web/tests/workspace-file-open.e2e.ts b/apps/web/tests/workspace-file-open.e2e.ts new file mode 100644 index 0000000000..63d4266cf4 --- /dev/null +++ b/apps/web/tests/workspace-file-open.e2e.ts @@ -0,0 +1,92 @@ +// Web e2e scenario: clicking a tool row's file path opens that file in a new +// browser tab, served by the web transport's own /f route. Cold-seeds the +// seeded-history fixture (zero model calls). The surface package tests can +// assert which opener the click reaches, but only the assembled application +// proves the opened URL actually serves the workspace file — the whole point +// of the route (docs/testing.md snapshot rule). +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +// Borrowed read-only: this scenario needs any settled turn whose tool rows +// carry a workspace file path, not a new recording (message-actions pattern). +const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'workspace-file-open-web-e2e' + +const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' + +describe('web e2e: opening a workspace file from a tool row', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + // The seeded Session's cwd is the scaffold workspace itself; the recording's + // own nested directory is written too, so the seed's paths stay resolvable. + await mkdir(join(scaffold.workspaceCwd, 'workspace'), { recursive: true }) + for (const dir of [scaffold.workspaceCwd, join(scaffold.workspaceCwd, 'workspace')]) { + await writeFile(join(dir, 'a.txt'), 'alpha\n') + await writeFile(join(dir, 'b.txt'), 'beta\n') + } + const raw = await readFile(SEED, 'utf8') + expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT]) + await seedSession(scaffold, raw, SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('opens the read row’s file in a new tab, served from the session workspace', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-workspace-file-open')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) + + // The row summary IS the link: a button whose label is the tool's path. + const fileLink = page.getByRole('button', { name: 'a.txt', exact: true }).first() + await fileLink.waitFor({ timeout: 10_000 }) + const [opened] = await Promise.all([ + page.context().waitForEvent('page', { timeout: 15_000 }), + fileLink.click(), + ]) + await opened.waitForLoadState('domcontentloaded') + expect(new URL(opened.url()).pathname).toBe(`/f/${SEED_ID}/a.txt`) + expect(await opened.locator('body').innerText()).toContain('alpha') + + // The served response is a workspace read, not a download, and never cached + // past the turn that produced it. + 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') + + // 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) + + await opened.close() + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) +}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index c795dc7aef..2c65f1e510 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -50,7 +50,8 @@ "tests/permission-policy-context.e2e.ts", "tests/access-confirmation.e2e.ts", "tests/shipped-composition.e2e.ts", - "tests/startup-auto-selection.e2e.ts" + "tests/startup-auto-selection.e2e.ts", + "tests/workspace-file-open.e2e.ts" ], "references": [ { diff --git a/docs/config-catalog.md b/docs/config-catalog.md index a356a151e3..114e2bdb35 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -296,7 +296,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:20`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:26`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 974e3014d6..101d8fd61b 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -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: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d -README.zh.md: 693420183ffa4fb20e1fecbff523a12261a45d45 +README.md: 9a08cb4de5531b044bd411ea08595c22f88e5f8a +README.zh.md: cfc427945f42b61288f57f5ca1db9af74dbcfb31 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index c8b7c4787c..9a08cb4de5 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,12 +2,18 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half owns both browser-facing prefixes — `/api` for RPC and `/f` for workspace-file reads — behind one trust fence. The `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## /api browser-trust fence The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). +## /f workspace-file reads + +The node half also serves one file at a time out of a Session's workspace under `/f//`, 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`. Model-authored markup is served from the same origin as `/api`, where `/api/events.mux` is a readable `GET` stream, so an opaque origin is what keeps a generated page from reading the session event stream one `window.open` away. The cost is borne by the preview: `localStorage`, cookies, and same-origin `fetch` are unavailable inside it, and `host.openPath` remains the full-capability way to open the same file on the Host machine. The same trust fence gates this prefix, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads. + ## Keyless fixture 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. diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 693420183f..cfc427945f 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,12 +2,18 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧持有两条面向浏览器的前缀——`/api` 承载 RPC,`/f` 承载工作区文件读取——共用同一道信任 fence。`/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 +## /f 工作区文件读取 + +node 半侧还会在 `/f//` 下逐个提供某个 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`。模型撰写的标记与 `/api` 同源提供,而 `/api/events.mux` 是一条可读的 `GET` 流,因此正是不透明源阻止了一个生成页面通过一次 `window.open` 读走会话事件流。代价由预览承担:其中无法使用 `localStorage`、cookie 与同源 `fetch`,而 `host.openPath` 仍是在 Host 机器上以完整能力打开同一文件的方式。这条前缀由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。 + ## 无密钥 fixture 任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token/短语行为,并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 9f091b26c5..f5fa3e34ea 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2362,6 +2362,11 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }) return Promise.resolve({ accepted: true }) }, + + // The fixture has no filesystem behind its Sessions, so it names no + // directory for any of them; the /f route belongs to the node half, which + // a fixture page never reaches. + workspaceRootOf: () => Promise.resolve(undefined), } } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index ed4af2d21f..4f9ce7d51b 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -1,11 +1,17 @@ -/** Host HTTP bridge for browser-client RPC. */ +/** Host HTTP bridge for browser-client RPC and workspace-file reads. */ import type { Context } from 'cordis' 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 { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' export { API_PATH } from './api-path.ts' @@ -13,7 +19,7 @@ export { API_PATH } from './api-path.ts' /** Stable Cordis plugin name. */ export const name = 'client-connection' -/** Services required before mounting the route. */ +/** Services required before mounting the routes. */ export const inject = ['httpServer', 'apiProxy'] /** Plugin config: the deployment's non-loopback serving authorities. */ @@ -61,11 +67,11 @@ const PRIVILEGED_METHODS = new Set([ ]) /** - * Mounts the API gateway under the browser transport prefix. Every request on - * the prefix passes the browser-trust fence first (DNS-rebinding and - * cross-site defense — [api-request-trust](./api-request-trust.ts)); - * privileged methods additionally pass it with an empty trust list, which - * pins them to loopback. + * Mounts the API gateway and the workspace-file reads under the browser + * transport prefixes. Every request on either prefix passes the browser-trust + * fence first (DNS-rebinding and cross-site defense — + * [api-request-trust](./api-request-trust.ts)); privileged methods + * 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). */ @@ -96,4 +102,28 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { }, } ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') + + // The gateway is the host's session authority: it answers where a Session's + // files live without this package reaching into the core services, which + // would merge their host-side Context declarations into the browser lane. + const cwdFor = (sessionId: string): Promise => + 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') { + res.writeHead(405) + res.end() + return + } + await handleWorkspaceFile(req, res, { cwdFor }) + }, + } + ctx.effect(() => ctx.httpServer.register(filesRoute), 'client-connection: /f route') } diff --git a/packages/client/connection/src/workspace-files.ts b/packages/client/connection/src/workspace-files.ts new file mode 100644 index 0000000000..9ad1b830a3 --- /dev/null +++ b/packages/client/connection/src/workspace-files.ts @@ -0,0 +1,169 @@ +/** + * The read half of the web transport: streams one file out of a session's + * workspace so the browser can open what the agent just produced. The RPC + * gateway carries structured session state; this route carries bytes, which a + * JSON-RPC envelope cannot stream and a `file://` link cannot reach from an + * http page. + * + * Confinement is the whole contract: a request names a session, the session + * names its cwd, and nothing outside that realpath is ever served. The caller + * owns the browser-trust fence ([api-request-trust](./api-request-trust.ts)) — + * this module is reached only by requests that already passed it. + */ + +import { createReadStream } from 'node:fs' +import { realpath, stat } from 'node:fs/promises' +import type { IncomingMessage, ServerResponse } from 'node:http' +import { extname, resolve, sep } from 'node:path' +import { pipeline } from 'node:stream/promises' +import { parseWorkspaceFilePath } from '@deepseek-ai/dsh-host-apiproxy/api' + +/** + * Content types served verbatim. Everything absent is `text/plain`, not + * `application/octet-stream`: a workspace read is a "show me what you made" + * gesture, and an unknown extension is far more often a source file to read + * than a binary to download. `nosniff` keeps that choice binding, so a + * mislabelled document can never be re-interpreted as HTML. + */ +const MIME: Record = { + '.html': 'text/html; charset=utf-8', + '.htm': 'text/html; charset=utf-8', + '.xhtml': 'application/xhtml+xml', + '.svg': 'image/svg+xml', + '.css': 'text/css; charset=utf-8', + '.js': 'text/javascript; charset=utf-8', + '.mjs': 'text/javascript; charset=utf-8', + '.json': 'application/json', + '.pdf': 'application/pdf', + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.gif': 'image/gif', + '.webp': 'image/webp', + '.avif': 'image/avif', + '.ico': 'image/x-icon', + '.mp4': 'video/mp4', + '.webm': 'video/webm', + '.mp3': 'audio/mpeg', + '.wav': 'audio/wav', + '.wasm': 'application/wasm', +} + +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']) + +/** + * Model-authored documents run in an opaque origin. Without it a generated page + * is same-origin with the RPC gateway, where `/api/events.mux` is a readable + * GET stream — one `window.open` away from every session's events. The cost is + * that `localStorage`, cookies, and same-origin `fetch` are unavailable inside + * a preview; the native-open path (`host.openPath`) remains the full-capability + * way to view a file. + */ +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 { + /** + * The session's absolute working directory. + * @param sessionId - the session named by the request path. + * @returns its cwd, or `undefined` when the id names no session this host serves. + */ + cwdFor: (sessionId: string) => Promise +} + +function fail(res: ServerResponse, status: number): void { + res.writeHead(status) + res.end() +} + +/** + * Resolve one request's segments against a session cwd, refusing anything that + * leaves it. Both sides go through `realpath`, so a symlink inside the + * workspace pointing out of it is refused by its resolved target rather than + * its name. A component swapped between this resolution and the open below + * would still be followed; closing that window needs privileges that already + * imply workspace write access, which is strictly stronger than reading a + * workspace file, so the check stops here. + */ +async function confine(cwd: string, segments: readonly string[]): Promise { + const root = await realpath(cwd) + const real = await realpath(resolve(root, ...segments)) + return real.startsWith(root + sep) ? real : undefined +} + +/** + * Serve one workspace-file request. The caller has already applied the + * browser-trust fence and rejected non-read methods. + * @param req - the request, read for its url and method only (no body). + * @param res - the response this function owns to completion. + * @param deps - the session-to-cwd lookup this host answers with. + */ +export async function handleWorkspaceFile( + req: IncomingMessage, + res: ServerResponse, + deps: WorkspaceFileDeps, +): Promise { + /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */ + const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname + const target = parseWorkspaceFilePath(pathname) + if (target === undefined) { + fail(res, 404) + return + } + const cwd = await deps.cwdFor(target.sessionId) + if (cwd === undefined) { + fail(res, 404) + return + } + + let file: string | undefined + let size: number + try { + file = await confine(cwd, target.segments) + if (file === undefined) { + fail(res, 403) + return + } + const info = await stat(file) + // A directory read has no answer here: the route serves files, and listing + // is the directory-picker capability's job, behind its own fence. + if (!info.isFile()) { + fail(res, 404) + return + } + size = info.size + } catch { + // Missing, unreadable, or a path whose ancestor is not a directory: all + // report as absent, so a probe cannot distinguish them. + fail(res, 404) + return + } + + const ext = extname(file).toLowerCase() + res.writeHead(200, { + 'content-type': MIME[ext] ?? DEFAULT_MIME, + 'content-length': String(size), + 'content-disposition': 'inline', + 'x-content-type-options': 'nosniff', + // 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() + return + } + try { + // pipeline (not pipe) so a client disconnect destroys the read stream: + // an abandoned preview must not leave a descriptor open. + await pipeline(createReadStream(file), res) + } catch { + // The status line is already out, so a mid-stream read failure or client + // disconnect can only end the response abruptly. + res.destroy() + } +} diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 08c65de2ba..0b2c58ab37 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -1,6 +1,9 @@ -/** Node half: registers the /api prefix route bridging to the api gateway. */ +/** Node half: registers the /api and /f prefix routes over the api gateway and the session workspaces. */ import { EventEmitter } from 'node:events' import { createServer, request as httpRequest } from 'node:http' +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { Readable } from 'node:stream' import { Context } from 'cordis' import { describe, expect, it } from 'vitest' @@ -8,6 +11,7 @@ import type { AddressInfo } from 'node:net' import type { IncomingMessage, ServerResponse } from 'node:http' import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' +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(). */ @@ -45,31 +49,45 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b return { response, state } } -async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise }> { +/** The gateway stub: only the session-directory authority the /f route reads. */ +function fakeApiProxy(workspaces: Record = {}): ApiProxy { + return { workspaceRootOf: async (id: string) => workspaces[id] } as unknown as ApiProxy +} + +async function mounted( + config?: { trustedHosts?: string[] }, + workspaces: Record = {}, +): Promise<{ routes: WebRoute[]; dispose: () => Promise }> { const ctx = new Context() const routes: WebRoute[] = [] ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) - ctx.provide('apiProxy', {} as unknown as ApiProxy) + ctx.provide('apiProxy', fakeApiProxy(workspaces)) const fiber = ctx.plugin({ inject: [...inject], apply }, config) await fiber.await() return { routes, 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 +} + describe('connection node half', () => { it('fails the load on a trustedHosts entry that is not a bare authority', async () => { const routes: WebRoute[] = [] const ctx = new Context() ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) - ctx.provide('apiProxy', {} as unknown as ApiProxy) + ctx.provide('apiProxy', fakeApiProxy()) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] }) await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/) expect(routes).toHaveLength(0) }) - it('registers the /api prefix route and removes it with the fiber', async () => { + it('registers both transport prefix routes and removes them with the fiber', async () => { const { routes, dispose } = await mounted() - expect(routes).toHaveLength(1) - expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) + expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }, { kind: 'prefix', path: FILES_PATH }]) await dispose() expect(routes).toHaveLength(0) }) @@ -132,6 +150,52 @@ describe('connection node half', () => { }) }) +describe('connection node half: the /f workspace-file route', () => { + /** A workspace holding one file, torn down with the returned disposer. */ + async function workspace(): Promise<{ cwd: string; remove: () => Promise }> { + const cwd = await mkdtemp(join(tmpdir(), 'dsh-node-half-')) + await writeFile(join(cwd, 'index.html'), '

ok

') + 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 = { 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) + 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) + // 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) + await dispose() + await remove() + }) +}) + describe('connection node half over a real HTTP server', () => { /** Serve the registered prefix route from a real server and return its port. */ async function serve(routes: WebRoute[]): Promise<{ port: number; close: () => Promise }> { diff --git a/packages/client/connection/tests/workspace-files.spec.ts b/packages/client/connection/tests/workspace-files.spec.ts new file mode 100644 index 0000000000..fb4288a8b2 --- /dev/null +++ b/packages/client/connection/tests/workspace-files.spec.ts @@ -0,0 +1,134 @@ +/** + * Workspace-file reads over a real HTTP server and a real temporary + * workspace: confinement, content typing, and the sandbox header are wire + * facts, so they are asserted against responses Node actually produced. + */ +import { createServer } from 'node:http' +import type { AddressInfo } from 'node:net' +import type { ServerResponse } from 'node:http' +import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Writable } from 'node:stream' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' +import { handleWorkspaceFile } from '../src/workspace-files.ts' + +const SESSION = 's-1' + +let workspace: string +let outside: string +let origin: string +let close: () => Promise + +beforeAll(async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-files-')) + workspace = join(root, 'workspace') + outside = join(root, 'outside') + await mkdir(join(workspace, 'out'), { recursive: true }) + await mkdir(outside, { recursive: true }) + await writeFile(join(workspace, 'index.html'), '

产物

') + await writeFile(join(workspace, 'notes.txt'), 'plain') + await writeFile(join(workspace, 'chart.svg'), '') + await writeFile(join(workspace, 'model.safetensors'), 'unknown extension') + await writeFile(join(workspace, 'out', 'page.html'), '

nested

') + await writeFile(join(outside, 'secret.html'), 'SECRET') + await symlink(join(outside, 'secret.html'), join(workspace, 'escape.html')) + + const server = createServer((req, res) => { + void handleWorkspaceFile(req, res, { + cwdFor: async sessionId => sessionId === SESSION ? workspace : undefined, + }) + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + origin = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}` + close = () => new Promise((resolve, reject) => { + server.close((error) => { + if (error === undefined || error === null) resolve() + else reject(error) + }) + }) + return async () => { await rm(root, { recursive: true, force: true }) } +}) + +afterAll(async () => { await close() }) + +function get(path: string, init?: RequestInit): Promise { + return fetch(`${origin}${path}`, init) +} + +describe('workspace file reads', () => { + it('serves a produced document with the sandbox that keeps it off this origin', async () => { + const response = await get(`${FILES_PATH}/${SESSION}/index.html`) + expect(response.status).toBe(200) + expect(await response.text()).toBe('

产物

') + expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8') + // The whole reason a model-authored page may be served from the RPC + // origin: an opaque origin cannot read /api/events.mux. + expect(response.headers.get('content-security-policy')).toContain('sandbox') + 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 non-scriptable types alone', 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') + 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('shows an unknown extension as text rather than downloading it', async () => { + const response = await get(`${FILES_PATH}/${SESSION}/model.safetensors`) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('text/plain; charset=utf-8') + }) + + it('serves a nested path, so a document reaches its own siblings', async () => { + const response = await get(`${FILES_PATH}/${SESSION}/out/page.html`) + expect(response.status).toBe(200) + expect(await response.text()).toBe('

nested

') + }) + + it('answers HEAD with the length and no body', async () => { + const response = await get(`${FILES_PATH}/${SESSION}/notes.txt`, { method: 'HEAD' }) + expect(response.status).toBe(200) + expect(response.headers.get('content-length')).toBe('5') + expect(await response.text()).toBe('') + }) + + it('refuses a symlink whose target leaves the workspace', async () => { + const response = await get(`${FILES_PATH}/${SESSION}/escape.html`) + expect(response.status).toBe(403) + expect(await response.text()).not.toContain('SECRET') + }) + + it('reports missing files, directories, and unknown sessions as absent', async () => { + expect((await get(`${FILES_PATH}/${SESSION}/nope.html`)).status).toBe(404) + expect((await get(`${FILES_PATH}/${SESSION}/out`)).status).toBe(404) + // A path whose ancestor is a file, not a directory. + expect((await get(`${FILES_PATH}/${SESSION}/notes.txt/child`)).status).toBe(404) + expect((await get(`${FILES_PATH}/s-other/index.html`)).status).toBe(404) + expect((await get(`${FILES_PATH}/${SESSION}`)).status).toBe(404) + }) +}) + +describe('workspace file streaming failures', () => { + it('tears the response down instead of rejecting when the body cannot be written', async () => { + // A client that goes away mid-stream must not surface as a handler + // rejection: the webserver's last-resort guard would log it and try to + // answer 400 on a response whose status line is already out. + const sink = new Writable({ + write(_chunk, _encoding, callback) { callback(new Error('socket gone')) }, + }) + const response = Object.assign(sink, { writeHead: () => response }) as unknown as ServerResponse + await expect(handleWorkspaceFile( + { url: `${FILES_PATH}/${SESSION}/index.html`, method: 'GET', headers: {} } as never, + response, + { cwdFor: async () => workspace }, + )).resolves.toBeUndefined() + expect(sink.destroyed).toBe(true) + }) +}) diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index 3e64ef3717..dbc0f3b30f 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -56,6 +56,17 @@ export interface IWorkspaces { * @param path - absolute or host-resolvable path. */ openPath(path: string): Promise + /** + * 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. diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index a0a76670f2..837a7daa03 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -5,6 +5,7 @@ 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' @@ -239,6 +240,19 @@ 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. * @param workspaceId - target workspace. diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 4323d7ffce..3d9cef547f 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -276,6 +276,21 @@ 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() diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 7e626a3660..01e7db4c3d 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -1,5 +1,6 @@ /** 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' @@ -98,6 +99,22 @@ export class TestWorkspaces implements IWorkspaces { await (this.stubs.get('openPath')?.(path) as Promise | 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. * @returns the picked path, or null. diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index 3675671f26..a9c4b0c9ca 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -549,6 +549,10 @@ 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 @@ -556,13 +560,15 @@ 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', 'insertSessionBefore', 'archiveSession']) + ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'fileUrl', 'fileUrl', + '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') @@ -570,6 +576,7 @@ 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) diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index c67431e409..71f05267b3 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -275,6 +275,15 @@ 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) + if (url !== undefined) { + window.open(url, '_blank', 'noopener,noreferrer') + return + } void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => { // Host/OS open failures stay silent in the chat row; the native // app surfaces its own error dialog when the path is unusable. diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index cfbb0fdcbe..6427f6750c 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -218,13 +218,22 @@ describe('conversation slot inject surface', () => { await b.runtime.dispose() }) - it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => { + 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() + 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. injected.openFile('src/a.ts') + expect(open).toHaveBeenCalledWith(`/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. + injected.openFile('/etc/hosts') await vi.waitFor(() => { - expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] }) + expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/etc/hosts'] }) }) + open.mockRestore() await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index bafc6fe709..51e31c9750 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -134,6 +134,7 @@ 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) ctx.provide('layout', layout) @@ -243,12 +244,14 @@ describe('run_code sub-calls through the real chat machinery', () => { subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'), ]]]) const b = await bench(snapshotWith([codeResult(10, parent)], dispatches)) + const open = vi.spyOn(window, 'open').mockReturnValue(null) const view = mountApp(b.slots) view.getByText('notes/demo.txt').click() expect(b.layout.openDetails).not.toHaveBeenCalled() await vi.waitFor(() => { - expect(b.workspaces.openPath).toHaveBeenCalledWith('notes/demo.txt') + expect(open).toHaveBeenCalledWith('/f/s-1/notes/demo.txt', '_blank', 'noopener,noreferrer') }) + open.mockRestore() view.getByText('List notes').click() expect(b.layout.openDetails).not.toHaveBeenCalled() }) diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index eb48677d4f..6cb46e7ea0 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -119,14 +119,16 @@ describe('keyed toolview hole through the real machinery', () => { await b.runtime.dispose() }) - it('file-path clicks travel owner openFile → chat inject → workspaces.openPath', async () => { + 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"}')]) + const open = vi.spyOn(window, 'open').mockReturnValue(null) const view = b.runtime.renderRoot() view.getByText('src/a.ts').click() expect(b.layout.openDetails).not.toHaveBeenCalled() await vi.waitFor(() => { - expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['src/a.ts'] }) + expect(open).toHaveBeenCalledWith(expect.stringContaining('/src/a.ts'), '_blank', 'noopener,noreferrer') }) + open.mockRestore() await b.runtime.dispose() }) diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 27a1434e60..b96bf528a7 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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/host/apiproxy/README.md -README.md: 3c5a83a468b0cf9e596b8b13fafe40c409576fc5 -README.zh.md: f8533564575bf6b716f3fa7241ce47b8d4dd435f +README.md: ee8e758a68f6efa3e363a36fcc9e8444e589ea40 +README.zh.md: 4ec3817e65543d6e248be9d902d0b74674f56e5a diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 3c5a83a468..ee8e758a68 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -36,6 +36,8 @@ The `command.*` and `skill.*` domains expose the host command registry and skill The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. +Two members of `ApiProxy` are deliberately not wire methods. `respond` is the client-response entry (four-quadrant model), and `workspaceRootOf` answers where a Session's files live for an in-process reader — a live agent's header first, then the persistence store, never a resume. It has no wire face: a browser learns a Session's cwd from `sessions.view`, and reaches a file through the web transport's own `/f` route, never by asking for a host path. That route's URL shape (`api/files.ts`: `FILES_PATH`, `workspaceFileSegments`, `workspaceFileUrl`, `parseWorkspaceFilePath`) lives here with the other browser-importable contract surfaces, so the browser half that builds a `/f` URL and the serving half that parses one cannot drift apart; the route itself belongs to [`dsh-client-connection`](../../client/connection/README.md). + ## Carrier layer (`/client` + root) `AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index f853356457..4ec3817e65 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -36,6 +36,8 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 +`ApiProxy` 上有两个成员刻意不是协议方法。`respond` 是客户端响应入口(四象限模型),`workspaceRootOf` 则为进程内读取方回答某个 Session 的文件位于何处——先看活跃 agent 的 header,再看持久化存储,绝不恢复会话。它没有协议面:浏览器从 `sessions.view` 得知 Session 的 cwd,并经由 web 传输自己的 `/f` 路由抵达文件,而不是靠索要一条宿主路径。该路由的 URL 形状(`api/files.ts`:`FILES_PATH`、`workspaceFileSegments`、`workspaceFileUrl`、`parseWorkspaceFilePath`)与其余浏览器可导入的契约面一同放在这里,因此构造 `/f` URL 的浏览器半侧与解析它的服务半侧不会彼此漂移;路由本身则属于 [`dsh-client-connection`](../../client/connection/README.md)。 + ## 载体层(`/client` + 根路径) `AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 4e506ed262..3c20785657 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2290,5 +2290,16 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro pending.resolve(payload.answer) return Promise.resolve({ accepted: true }) }, + + async workspaceRootOf(sessionId: SessionId): Promise { + // A live agent answers from its own header; otherwise the store answers, + // deliberately without resuming — reading a session's directory must not + // pull an agent up the way the cold RPC path does. + const live = ctx.agents.get(sessionId) + if (live !== undefined) return live.session.header.cwd + const persistence = ctx.get('sessionPersistence') + if (persistence === undefined) return undefined + return (await persistence.list()).find(meta => meta.id === sessionId)?.cwd + }, } } diff --git a/packages/host/apiproxy/src/api/files.ts b/packages/host/apiproxy/src/api/files.ts new file mode 100644 index 0000000000..b4ba01f29b --- /dev/null +++ b/packages/host/apiproxy/src/api/files.ts @@ -0,0 +1,98 @@ +/** + * The `/f` workspace-file URL shape: the contract half of the web transport + * that carries bytes rather than RPC. The browser turns a tool's file path + * into a URL, the serving side turns that URL back into the segments below a + * session's cwd, and both read this one encoding decision so neither can drift + * into serving a path the other never meant. Pure string work with no Node and + * no DOM, like the rest of `api/` — the browser bundle inlines it. + * @module @deepseek-ai/dsh-host-apiproxy/api/files + */ + +/** + * Route prefix owning every workspace-file read (`/f//`). + * The path carries the segments verbatim rather than a query parameter so a + * served document's relative references (`./logo.png`) resolve to their + * siblings in the same workspace directory. + */ +export const FILES_PATH = '/f' + +/** One parsed workspace-file request: whose workspace, and where inside it. */ +export interface WorkspaceFileTarget { + /** The owning session, still an opaque string — the caller resolves it to a cwd. */ + sessionId: string + /** Decoded path segments below that session's cwd; never empty, never `.` or `..`. */ + segments: string[] +} + +/** A segment that survived decoding but would re-enter path resolution as more than one name. */ +function isPlainSegment(segment: string): boolean { + return segment !== '' && segment !== '.' && segment !== '..' + && !segment.includes('/') && !segment.includes('\\') && !segment.includes('\0') +} + +function decode(raw: string): string | undefined { + try { + return decodeURIComponent(raw) + } catch { + // A malformed %-escape is a request we cannot interpret, not a miss. + return undefined + } +} + +/** + * Express one tool-reported file path as segments below the session cwd. + * @param cwd - the session's working directory, or `undefined` when unknown. + * @param path - the path the tool reported (absolute, or relative to `cwd`). + * @returns the segments below `cwd`, or `undefined` when the path names + * something outside the workspace (which this route never serves) or resolves + * to the workspace directory itself. + */ +export function workspaceFileSegments(cwd: string | undefined, path: string): string[] | undefined { + const slashed = path.replace(/\\/g, '/') + const absolute = /^\/|^[A-Za-z]:\//.test(slashed) + let relative: string + if (absolute) { + if (cwd === undefined || cwd === '') return undefined + const root = cwd.replace(/\\/g, '/').replace(/\/+$/, '') + if (!slashed.startsWith(`${root}/`)) return undefined + relative = slashed.slice(root.length + 1) + } else { + relative = slashed + } + const segments = relative.split('/').filter(segment => segment !== '' && segment !== '.') + if (segments.length === 0 || segments.some(segment => !isPlainSegment(segment))) return undefined + return segments +} + +/** + * Build the origin-relative URL serving one workspace file. + * @param sessionId - the session whose cwd anchors the path. + * @param segments - segments below that cwd, as {@link workspaceFileSegments} returns them. + * @returns the `/f/…` URL, resolved by the browser against the serving origin. + */ +export function workspaceFileUrl(sessionId: string, segments: readonly string[]): string { + const encoded = segments.map(segment => encodeURIComponent(segment)).join('/') + return `${FILES_PATH}/${encodeURIComponent(sessionId)}/${encoded}` +} + +/** + * Parse a request pathname back into the session and segments it names. + * @param pathname - the request's raw (still percent-encoded) pathname. + * @returns the target, or `undefined` when the pathname is not a well-formed + * workspace-file read — including every traversal shape, which is refused here + * before any filesystem call rather than being resolved and then judged. + */ +export function parseWorkspaceFilePath(pathname: string): WorkspaceFileTarget | undefined { + if (!pathname.startsWith(`${FILES_PATH}/`)) return undefined + const [rawSession, ...rawSegments] = pathname.slice(FILES_PATH.length + 1).split('/') + if (rawSession === undefined || rawSegments.length === 0) return undefined + const sessionId = decode(rawSession) + if (sessionId === undefined || sessionId === '') return undefined + const segments: string[] = [] + for (const raw of rawSegments) { + const segment = decode(raw) + if (segment === undefined || !isPlainSegment(segment)) return undefined + segments.push(segment) + } + return { sessionId, segments } +} diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 87aa1036bf..227e26264e 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -15,6 +15,9 @@ import type { SettingsApi } from './settings.ts' import type { CredentialsApi } from './credentials.ts' import type { LlmApi } from './llm.ts' import type { ClientResponse, RpcReceipt } from './rpc.ts' +// The merge-free types subpath: api/ is imported from the browser lane, where +// the host session service must not merge over the client runtime's own. +import type { SessionId } from '@deepseek-ai/dsh-session/types' /** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */ export interface ApiProxy { @@ -30,6 +33,17 @@ export interface ApiProxy { llm: LlmApi /** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */ respond(message: ClientResponse): Promise + /** + * The directory a Session's files may be read from — the same `cwd` the + * session summaries carry, in non-envelope form for an in-process reader. + * Not a domain method: it has no wire face, because a browser learns a + * Session's cwd from `sessions.view` and a file it may read from the web + * transport's own `/f` route, never by asking for a host path. + * @param sessionId - the Session to locate. + * @returns its absolute working directory, or `undefined` when this host + * serves no such Session. Resolving one never resumes an agent. + */ + workspaceRootOf(sessionId: SessionId): Promise } // ---- Domain interfaces and payload entities ---- @@ -48,6 +62,10 @@ export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSe export type { CredentialsApi, CredentialView } from './credentials.ts' export type { ConfigurableProviderView, LlmApi } from './llm.ts' export type { ApprovalResponsePayload } from './approvals.ts' + +// ---- Workspace-file URL shape (the transport's byte-carrying half) ---- +export { FILES_PATH, workspaceFileSegments, workspaceFileUrl, parseWorkspaceFilePath } from './files.ts' +export type { WorkspaceFileTarget } from './files.ts' export type { QuestionResponsePayload } from './questions.ts' // ---- Message layer: narrow forms (domain-signature view) ---- diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 339b1e777d..f6dec19420 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -64,6 +64,7 @@ export class ApiProxyService extends Service implements ApiProxy { readonly llm: ApiProxy['llm'] readonly events: ApiProxy['events'] readonly respond: ApiProxy['respond'] + readonly workspaceRootOf: ApiProxy['workspaceRootOf'] constructor(ctx: Context, config: Config) { super(ctx, 'apiProxy') @@ -87,6 +88,7 @@ export class ApiProxyService extends Service implements ApiProxy { // createApiProxy returns closures (no `this` capture); bind only satisfies // the unbound-method lint without changing behavior. this.respond = api.respond.bind(api) + this.workspaceRootOf = api.workspaceRootOf.bind(api) } } diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index da05a4cd9b..cc30e5dee2 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -62,7 +62,11 @@ function stubAgent(session: Session): Agent { async function harness( workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, - extras: { openPath?: (path: string, signal: AbortSignal) => Promise } = {}, + extras: { + openPath?: (path: string, signal: AbortSignal) => Promise + /** Store contents behind the gateway, or 'absent' for a composition with no persistence at all. */ + persisted?: { id: SessionId; cwd?: string }[] | 'absent' + } = {}, ) { const ctx = new Context() await ctx.plugin(SessionStore) @@ -73,7 +77,10 @@ async function harness( const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} }) ctx.storage.mount('domain', storageDomain) ctx.provide('storageDomain', storageDomain) - ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never) + if (extras.persisted !== 'absent') { + const persisted = extras.persisted ?? [] + ctx.provide('sessionPersistence', { list: () => Promise.resolve(persisted) } as never) + } await ctx.plugin(WorkspaceRegistry) const factory: AgentFactory = { @@ -244,6 +251,27 @@ describe('host.openPath', () => { }) }) +describe('workspaceRootOf', () => { + it('answers from the live agent, then the store, and names nothing for an unknown session', async () => { + const { api, workspaceRoot } = await harness(undefined, undefined, { + persisted: [{ id: 's-cold' as SessionId, cwd: '/w/cold' }], + }) + const created = await api.sessions.create(request({ cwd: workspaceRoot })) + const sessionId = (created.result as { ok: true; value: { sessionId: SessionId } }).value.sessionId + // Live: the agent's own header, no store read involved. + await expect(api.workspaceRootOf(sessionId)).resolves.toBe(workspaceRoot) + // Not live: the store answers, and the lookup never resumes an agent — + // this harness's factory throws on resume, so a resuming lookup would fail. + await expect(api.workspaceRootOf('s-cold' as SessionId)).resolves.toBe('/w/cold') + await expect(api.workspaceRootOf('s-absent' as SessionId)).resolves.toBeUndefined() + }) + + it('names nothing at all when the host keeps no session store', async () => { + const { api } = await harness(undefined, undefined, { persisted: 'absent' }) + await expect(api.workspaceRootOf('s-any' as SessionId)).resolves.toBeUndefined() + }) +}) + describe('workspace.create', () => { it('serializes concurrent names and rejects the duplicate', async () => { const { api, workspaceRoot } = await harness() diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 6307dfe8f9..2299949890 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -108,6 +108,8 @@ function scriptedApi(overrides: { }, events: { mux: () => empty(), host: () => empty(), ...overrides.events }, respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })), + // No wire face, so the handler map never reaches it. + workspaceRootOf: () => Promise.resolve(undefined), } } diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index ef111afe12..dac49a1234 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -233,6 +233,8 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async respond(message: ClientResponse): Promise { return message.rpcId === 'known' ? { accepted: true } : { accepted: false, reason: 'not-pending' } }, + // No wire face, so the carrier never reaches it. + workspaceRootOf: () => Promise.resolve(undefined), } } diff --git a/packages/host/apiproxy/tests/files-path.spec.ts b/packages/host/apiproxy/tests/files-path.spec.ts new file mode 100644 index 0000000000..df309a4783 --- /dev/null +++ b/packages/host/apiproxy/tests/files-path.spec.ts @@ -0,0 +1,74 @@ +/** The /f URL shape: one encoding decision, asserted from both ends. */ +import { describe, expect, it } from 'vitest' +import { + FILES_PATH, parseWorkspaceFilePath, workspaceFileSegments, workspaceFileUrl, +} from '../src/api/files.ts' + +describe('workspaceFileSegments', () => { + it('keeps a relative path as its own segments', () => { + expect(workspaceFileSegments('/w', 'out/index.html')).toEqual(['out', 'index.html']) + expect(workspaceFileSegments(undefined, 'index.html')).toEqual(['index.html']) + expect(workspaceFileSegments('/w', './a/./b.txt')).toEqual(['a', 'b.txt']) + }) + + it('strips the cwd prefix from an absolute path inside the workspace', () => { + expect(workspaceFileSegments('/w', '/w/a/b.html')).toEqual(['a', 'b.html']) + // A trailing separator on the cwd must not shift the split. + expect(workspaceFileSegments('/w/', '/w/a.html')).toEqual(['a.html']) + }) + + it('reads Windows paths on either separator', () => { + expect(workspaceFileSegments('C:\\w', 'C:\\w\\a\\b.html')).toEqual(['a', 'b.html']) + expect(workspaceFileSegments('C:/w', 'C:\\w\\a.html')).toEqual(['a.html']) + }) + + it('refuses everything the route would not serve', () => { + // Absolute, but not under this workspace. + expect(workspaceFileSegments('/w', '/etc/hosts')).toBeUndefined() + // A sibling directory sharing the cwd's name prefix is not inside it. + expect(workspaceFileSegments('/w', '/workspace-other/a')).toBeUndefined() + // Absolute with no cwd to anchor against. + expect(workspaceFileSegments(undefined, '/w/a.html')).toBeUndefined() + expect(workspaceFileSegments('', '/w/a.html')).toBeUndefined() + // Traversal, in either spelling. + expect(workspaceFileSegments('/w', '../secret')).toBeUndefined() + expect(workspaceFileSegments('/w', 'a/../../secret')).toBeUndefined() + // The workspace directory itself is not a file. + expect(workspaceFileSegments('/w', '/w')).toBeUndefined() + expect(workspaceFileSegments('/w', '.')).toBeUndefined() + }) +}) + +describe('workspaceFileUrl', () => { + it('percent-encodes each segment but keeps the separators structural', () => { + expect(workspaceFileUrl('s-1', ['out', 'a b.html'])).toBe(`${FILES_PATH}/s-1/out/a%20b.html`) + expect(workspaceFileUrl('s/1', ['a#b.html'])).toBe(`${FILES_PATH}/s%2F1/a%23b.html`) + }) +}) + +describe('parseWorkspaceFilePath', () => { + it('round-trips what the browser half builds', () => { + const url = workspaceFileUrl('s-1', ['out', 'a b.html']) + expect(parseWorkspaceFilePath(url)).toEqual({ sessionId: 's-1', segments: ['out', 'a b.html'] }) + }) + + it('refuses malformed, prefix-foreign, and traversal pathnames', () => { + expect(parseWorkspaceFilePath('/api/session.list')).toBeUndefined() + expect(parseWorkspaceFilePath(FILES_PATH)).toBeUndefined() + // Session named but no file below it. + expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1`)).toBeUndefined() + expect(parseWorkspaceFilePath(`${FILES_PATH}//a.html`)).toBeUndefined() + // Traversal is refused at parse time, before any filesystem call. + expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/../etc/hosts`)).toBeUndefined() + expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a/./b`)).toBeUndefined() + expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a//b`)).toBeUndefined() + // A separator smuggled through percent-encoding stays one segment's problem. + expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%2F..%2Fb`)).toBeUndefined() + expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%5Cb`)).toBeUndefined() + expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%00b`)).toBeUndefined() + // Malformed percent-escapes are uninterpretable, not a miss to resolve. + expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%zz`)).toBeUndefined() + expect(parseWorkspaceFilePath(`${FILES_PATH}/%zz/a.html`)).toBeUndefined() + expect(parseWorkspaceFilePath(`${FILES_PATH}//`)).toBeUndefined() + }) +}) diff --git a/tsconfig.host.json b/tsconfig.host.json index bae800f3ff..9de5b51da0 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -38,6 +38,7 @@ "apps/web/tests/access-confirmation.e2e.ts", "apps/web/tests/shipped-composition.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", + "apps/web/tests/workspace-file-open.e2e.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts", From 35e9122a658659154691bddbe1d5aa150716ddcb Mon Sep 17 00:00:00 2001 From: ZiyaZhang Date: Fri, 31 Jul 2026 22:13:34 -0700 Subject: [PATCH 02/12] feat(web): list a turn's produced files under its closing message The paths come from the mutation tools' follow-along locations, not from the closing prose, so a turn's output is listed whether or not the model named it. Each chip opens through the same openFile the tool rows use. Reads contribute nothing (looking at a file does not produce it), a failed mutation contributes nothing, a file touched twice is one entry, and the row shows six with an explicit remainder rather than burying the answer. --- .../src/client/chat/AssistantMarkdown.tsx | 11 +++- .../src/client/chat/ChatView.tsx | 7 ++- .../src/client/chat/Deliverables.module.css | 44 ++++++++++++++ .../src/client/chat/Deliverables.tsx | 54 ++++++++++++++++++ .../src/client/chat/chat-flow.ts | 37 ++++++++++++ .../ui-conversation/src/client/locales.ts | 6 ++ .../ui-conversation/tests/chat-view.spec.tsx | 57 ++++++++++++++++++- 7 files changed, 213 insertions(+), 3 deletions(-) create mode 100644 packages/client/ui-conversation/src/client/chat/Deliverables.module.css create mode 100644 packages/client/ui-conversation/src/client/chat/Deliverables.tsx diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 387a7fd82a..024a67a843 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -14,6 +14,7 @@ import { IconThinkOutline14, JsonBlock, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' +import { Deliverables } from './Deliverables.tsx' import { MessageIconActions } from './MessageIconActions.tsx' import { ToolRow } from './ToolRow.tsx' import css from './AssistantMarkdown.module.css' @@ -30,6 +31,11 @@ export interface AssistantMarkdownProps { seq?: number | undefined /** Fork the session through the turn containing this finalized message. */ onFork?: ((seq: number) => void) | undefined + /** Files the closing turn produced, listed under the body; omitted for a + * mid-turn assistant and for a turn that wrote nothing. */ + produced?: readonly string[] | undefined + /** Opens one produced file; omitted wherever `produced` is. */ + openFile?: ((path: string) => void) | undefined /** The owning view's locale seat, passed down as a plain prop. */ t: ChatViewSlotProps['t'] } @@ -69,7 +75,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, seq, onFork, t, + blocks, streaming, interrupted, time, seq, onFork, produced, openFile, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -107,6 +113,9 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ })} {interrupted && {t('message.stopped')}} + {showActions && produced !== undefined && openFile !== undefined && ( + + )} {showActions && ( assistantActionsSeqs(nodes), [nodes]) + // Produced files per closing assistant: derived from the mutation tools' + // locations, so a turn's output is listed whether or not the model named it. + const produced = useMemo(() => turnDeliverables(nodes), [nodes]) const listRef = useRef(null) const atBottomRef = useRef(true) @@ -402,6 +405,8 @@ export function ChatView({ time={actionSeqs.has(node.seq) ? node.time : undefined} seq={node.seq} onFork={forkAt} + produced={produced.get(node.seq)} + openFile={openFile} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/chat/Deliverables.module.css b/packages/client/ui-conversation/src/client/chat/Deliverables.module.css new file mode 100644 index 0000000000..2077de48ac --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/Deliverables.module.css @@ -0,0 +1,44 @@ +/* Turn-tail produced-files row: a quiet label followed by wrapping file chips. + Sits between the assistant body and its IconActions footer, so it reads as + part of the answer rather than as another tool row. */ + +.root { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin-top: 16px; + font-size: 13px; + line-height: 22px; +} + +.label { + color: var(--dsw-alias-label-tertiary); +} + +/* One produced file. A link by behavior (it opens the file), a chip by shape: + full paths are long and several may wrap onto one row. */ +.file { + max-width: 320px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + margin: 0; + padding: 0 8px; + border: none; + border-radius: 6px; + background: var(--dsw-alias-interactive-bg-hover); + color: var(--dsw-alias-label-secondary); + font: inherit; + cursor: pointer; +} + +.file:hover { + color: var(--dsw-alias-label-primary); + text-decoration: underline; +} + +/* Overflow count: the row never silently drops files it did not show. */ +.more { + color: var(--dsw-alias-label-tertiary); +} diff --git a/packages/client/ui-conversation/src/client/chat/Deliverables.tsx b/packages/client/ui-conversation/src/client/chat/Deliverables.tsx new file mode 100644 index 0000000000..0a0160b486 --- /dev/null +++ b/packages/client/ui-conversation/src/client/chat/Deliverables.tsx @@ -0,0 +1,54 @@ +// Deliverables: the produced-file row a finished turn ends with. The paths come +// from the mutation tools' follow-along locations (see turnDeliverables), never +// from the closing prose, so the answer carries its own output whether or not +// the model remembered to name it. Clicking one goes through the same openFile +// the tool rows use — in the browser that is a new tab served from the session +// workspace, and outside it the Host's own opener. + +import type { ChatViewSlotProps } from '../contract/slots.ts' +import css from './Deliverables.module.css' + +/** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */ +const SHOWN = 6 + +/** Trailing path segment, the part that identifies the file at a glance. */ +function basename(path: string): string { + const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + return at === -1 ? path : path.slice(at + 1) +} + +/** + * Render one turn's produced files as openable chips. + * @param props - the turn's paths (tool order, already deduped), the chat + * view's file opener, and the owning view's locale seat. + * @returns The row, or `null` when the turn produced nothing. + */ +export function Deliverables({ paths, openFile, t }: { + paths: readonly string[] + openFile: (path: string) => void + t: ChatViewSlotProps['t'] +}) { + if (paths.length === 0) return null + const shown = paths.slice(0, SHOWN) + const hidden = paths.length - shown.length + return ( +
+ {t('produced.label')} + {shown.map(path => ( + + ))} + {hidden > 0 && {t('produced.more', { count: String(hidden) })}} +
+ ) +} diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 4958894154..83ba5c463c 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -47,6 +47,43 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon return new Set(lastByTurn.values()) } +/** + * Files each turn produced, keyed by the assistant seq that closes it — the + * same anchor {@link assistantActionsSeqs} elects, so the row lands under the + * message that reports the work rather than after some mid-turn narration. + * + * The source is the mutation tools' own follow-along `locations`, not the + * closing prose: a produced file must be listed whether or not the model + * remembered to name it. Reads contribute nothing (looking at a file does not + * produce it) and a failed mutation contributes nothing (there is no file to + * open). Paths keep first-seen order and appear once, so a file written and + * then edited in the same turn is one entry. + * @param nodes - snapshot nodes (surface order). + * @returns Per-closing-seq produced paths; a turn that produced none is absent. + */ +export function turnDeliverables(nodes: readonly ConversationNode[]): ReadonlyMap { + const closing = assistantActionsSeqs(nodes) + const byClosingSeq = new Map() + let pending: string[] = [] + const seen = new Set() + for (const node of nodes) { + if (node.kind === 'tool-result') { + if (node.isError || node.callView?.card !== 'diff') continue + for (const location of node.callView.locations ?? []) { + if (seen.has(location.path)) continue + seen.add(location.path) + pending.push(location.path) + } + continue + } + if (node.kind !== 'assistant' || !closing.has(node.seq)) continue + if (pending.length > 0) byClosingSeq.set(node.seq, pending) + pending = [] + seen.clear() + } + return byClosingSeq +} + /** * Group finalized nodes into the step-summary flow. * @param nodes - snapshot nodes in human-transcript and durable-notice order. diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index 1bda57660d..78114fa2a8 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -53,6 +53,9 @@ export const zh = { 'message.unknownSurface': '未知 surface 事件:{type}', 'message.unknownBlock': '未知内容块', 'message.stopped': '已停止', + 'produced.label': '产物', + 'produced.more': '还有 {count} 个', + 'produced.open': '打开 {name}', 'message.branch': '在新对话中分支', 'message.retry.active': '正在重试模型请求', 'message.retry.cancelled': '模型请求重试已取消', @@ -152,6 +155,9 @@ export const en = { 'message.unknownSurface': 'Unknown surface event: {type}', 'message.unknownBlock': 'Unknown content block', 'message.stopped': 'Stopped', + 'produced.label': 'Produced', + 'produced.more': '{count} more', + 'produced.open': 'Open {name}', 'message.branch': 'Branch into a new conversation', 'message.retry.active': 'Retrying model request', 'message.retry.cancelled': 'Model request retry cancelled', diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 110ab6a991..6f855231ac 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -20,7 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' import { zh } from '../src/client/locales.ts' -import { assistantActionsSeqs, deriveChatFlow, flowKeys } from '../src/client/chat/chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, flowKeys, turnDeliverables } from '../src/client/chat/chat-flow.ts' afterEach(cleanup) // Keyless create() persists under the bare declared key; clear between cases @@ -211,6 +211,61 @@ describe('chat-flow derivation', () => { ]) expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7]) }) + + it('turnDeliverables attributes each turn’s written files to the assistant that closes it', () => { + const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ + ...toolResult(seq, callId, 'write'), + callView: { + card: 'diff', title: `Write ${paths[0] ?? ''}`, + diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), + locations: paths.map(path => ({ path })), + }, + }) + const produced = turnDeliverables([ + user(1, 'build it'), + assistant(2, 'writing', 1), + wrote(3, 'a', 'out/index.html'), + // Same file touched twice in one turn is one deliverable, in first-seen order. + wrote(4, 'b', 'out/app.css', 'out/index.html'), + // A read is not a deliverable; a failed write has no file to open. + { ...toolResult(5, 'c', 'read'), callView: { card: 'generic', title: 'Read x', locations: [{ path: 'x.ts' }] } }, + { ...wrote(6, 'd', 'out/broken.html'), isError: true }, + assistant(7, 'done', 1), + user(8, 'again'), + assistant(9, 'second turn', 2), + ]) + expect(produced.get(7)).toEqual(['out/index.html', 'out/app.css']) + // A turn that produced nothing is absent, not an empty row. + expect(produced.has(9)).toBe(false) + // Nothing at all written: no entries. + expect(turnDeliverables([user(1, 'hi'), assistant(2, 'hello', 1)]).size).toBe(0) + }) + + it('renders the produced files under the closing message and opens one on click', () => { + const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ + ...toolResult(seq, callId, 'write'), + callView: { + card: 'diff', title: 'Write', + diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), + locations: paths.map(path => ({ path })), + }, + }) + // Seven files: six chips plus an explicit remainder — the row bounds what + // it shows and says so rather than dropping the rest silently. + const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts'] + const h = makeHarness({ + nodes: [user(1, 'build it'), wrote(2, 'w', ...paths), assistant(3, 'done', 1)], + }) + const view = render() + expect(view.getByText('产物')).toBeTruthy() + // Chips carry the basename; the full path stays reachable as the title. + const chip = view.getByRole('button', { name: '打开 deep/a.html' }) + expect(chip.textContent).toBe('a.html') + expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull() + expect(view.getByText('还有 1 个')).toBeTruthy() + fireEvent.click(chip) + expect(h.openFile).toHaveBeenCalledWith('deep/a.html') + }) }) describe('ChatView', () => { From f5d53f04b7f02f7ad69c0dd135b61ae4f16a7330 Mon Sep 17 00:00:00 2001 From: ZiyaZhang Date: Fri, 31 Jul 2026 23:20:36 -0700 Subject: [PATCH 03/12] cleanup(web): stop sandboxing served workspace documents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...6-07-31-web-workspace-file-links.i18n.yaml | 4 ++-- .../2026-07-31-web-workspace-file-links.md | 6 ++--- .../2026-07-31-web-workspace-file-links.zh.md | 6 ++--- packages/client/connection/README.i18n.yaml | 4 ++-- packages/client/connection/README.md | 2 +- packages/client/connection/README.zh.md | 2 +- .../client/connection/src/workspace-files.ts | 22 +++++++------------ .../connection/tests/workspace-files.spec.ts | 13 +++++------ 8 files changed, 26 insertions(+), 33 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml index 2055af6cea..78f99d03c9 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml @@ -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: b7fd5ca240db3ca885e89f4cf6dcc135e7c88de8 -2026-07-31-web-workspace-file-links.zh.md: 74949afe0260d2d9018691740573ff24a1bce820 +2026-07-31-web-workspace-file-links.md: 3cd7a075f091a50a980cde14fdfcd15e810ee1f4 +2026-07-31-web-workspace-file-links.zh.md: 5702730938a76042879989ea961fdbe251830839 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md index b7fd5ca240..3cd7a075f0 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md @@ -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. -**Model-authored documents are served into an opaque origin.** `.html`/`.htm`/`.xhtml`/`.svg` carry `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`. Serving generated markup same-origin with `/api` would put `/api/events.mux` — a readable `GET` stream — one `window.open` away from a page the model wrote. The sandbox costs the preview its `localStorage`, cookies, and same-origin `fetch`; `host.openPath` stays as the full-capability way to open the same file on the Host machine, so the trade is resolved by keeping both affordances rather than by weakening either. +**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. **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,9 @@ 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/`, 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'` instead of `sandbox`, to keep `localStorage` working** — blocks `fetch`/`EventSource` but not `window.open('/api/events.mux')`, which is readable same-origin. The two GET SSE endpoints are what make the sandbox necessary rather than optional. +- **`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. ## 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//a.txt` in a second tab serving that workspace file, while a traversal spelling answers 404. `localStorage` is unavailable inside a preview, which is visible on generated pages that persist a theme toggle — the Host opener remains for those. 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//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. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md index 74949afe02..5702730938 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md @@ -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//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//a.txt` 并提供那个工作区文件,而穿越写法应答 404。预览保有自身的能力,因此把主题持久化到 `localStorage` 的生成页面,按其作者的意图正常工作。仍然暂缓:由 `locations` 推导的回合末交付物行,以及助手 Markdown 内部的任何链接化。 diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 101d8fd61b..452ffe81c8 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -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: 9a08cb4de5531b044bd411ea08595c22f88e5f8a -README.zh.md: cfc427945f42b61288f57f5ca1db9af74dbcfb31 +README.md: ebc2dea2787268e1686eac24565c2433cb5f4b66 +README.zh.md: f5653e9e0e0aed3cbf7a9e6124668942f4dde44a diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 9a08cb4de5..ebc2dea278 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -12,7 +12,7 @@ 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//`, 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`. Model-authored markup is served from the same origin as `/api`, where `/api/events.mux` is a readable `GET` stream, so an opaque origin is what keeps a generated page from reading the session event stream one `window.open` away. The cost is borne by the preview: `localStorage`, cookies, and same-origin `fetch` are unavailable inside it, and `host.openPath` remains the full-capability way to open the same file on the Host machine. The same trust fence gates this prefix, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads. +A served document carries no isolation header and is same-origin with `/api`. That is a decision, not an omission: the only author of these files is the agent already holding this user's shell and filesystem, so a `Content-Security-Policy: sandbox` would sit behind a trust boundary it has already crossed while costing every preview its `localStorage` and cookies — a generated page that remembers a theme breaks under it. A deployment that serves `dsh web` beyond loopback should treat workspace content as trusted, which is already true of everything else its agent does. Isolating a preview becomes a real question when workspace content stops being the viewer's own; the answer then is a separate origin, not a header. The same trust fence gates this prefix, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads. ## Keyless fixture diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index cfc427945f..f5653e9e0e 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -12,7 +12,7 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust node 半侧还会在 `/f//` 下逐个提供某个 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`。模型撰写的标记与 `/api` 同源提供,而 `/api/events.mux` 是一条可读的 `GET` 流,因此正是不透明源阻止了一个生成页面通过一次 `window.open` 读走会话事件流。代价由预览承担:其中无法使用 `localStorage`、cookie 与同源 `fetch`,而 `host.openPath` 仍是在 Host 机器上以完整能力打开同一文件的方式。这条前缀由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。 +所服务的文档不带任何隔离头,与 `/api` 同源。这是一个决定,不是遗漏:这些文件的唯一作者,正是那个已经握着本用户 shell 与文件系统的 agent,因此 `Content-Security-Policy: sandbox` 只会立在一条它早已越过的信任边界之后,代价却是每个预览都失去 `localStorage` 与 cookie——一个会记住主题的生成页面在它之下就是坏的。把 `dsh web` 服务到回环之外的部署,应当把工作区内容按可信处理,而这一点对其 agent 所做的其他一切本来就已成立。当工作区内容不再属于观看者本人时,隔离预览才成为一个真问题;那时的答案是一个独立的源,而不是一个头。这条前缀由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。 ## 无密钥 fixture diff --git a/packages/client/connection/src/workspace-files.ts b/packages/client/connection/src/workspace-files.ts index 9ad1b830a3..354b7b2225 100644 --- a/packages/client/connection/src/workspace-files.ts +++ b/packages/client/connection/src/workspace-files.ts @@ -9,6 +9,14 @@ * names its cwd, and nothing outside that realpath is ever served. The caller * owns the browser-trust fence ([api-request-trust](./api-request-trust.ts)) — * this module is reached only by requests that already passed it. + * + * A served document is same-origin with `/api`, and deliberately carries no + * isolation header. The only author of these files is the agent already + * holding this user's shell and filesystem, so a browser sandbox would not + * move the trust boundary — it would sit behind one already crossed, at the + * cost of `localStorage` and cookies in every preview. Isolating a preview + * becomes a real question when workspace content stops being the viewer's own; + * the answer then is a separate origin, not a header. */ import { createReadStream } from 'node:fs' @@ -51,19 +59,6 @@ const MIME: Record = { 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']) - -/** - * Model-authored documents run in an opaque origin. Without it a generated page - * is same-origin with the RPC gateway, where `/api/events.mux` is a readable - * GET stream — one `window.open` away from every session's events. The cost is - * that `localStorage`, cookies, and same-origin `fetch` are unavailable inside - * a preview; the native-open path (`host.openPath`) remains the full-capability - * way to view a file. - */ -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 { /** @@ -151,7 +146,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() diff --git a/packages/client/connection/tests/workspace-files.spec.ts b/packages/client/connection/tests/workspace-files.spec.ts index fb4288a8b2..eb36fdd6ea 100644 --- a/packages/client/connection/tests/workspace-files.spec.ts +++ b/packages/client/connection/tests/workspace-files.spec.ts @@ -58,26 +58,25 @@ function get(path: string, init?: RequestInit): Promise { } describe('workspace file reads', () => { - it('serves a produced document with the sandbox that keeps it off this 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('

产物

') expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8') - // The whole reason a model-authored page may be served from the RPC - // origin: an opaque origin cannot read /api/events.mux. - expect(response.headers.get('content-security-policy')).toContain('sandbox') + // No isolation header: a preview keeps localStorage and cookies, because + // the file's author already holds this user's shell (see the module doc). + expect(response.headers.get('content-security-policy')).toBeNull() 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 non-scriptable types alone', 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('shows an unknown extension as text rather than downloading it', async () => { From dcf485ac5c2ee7373b0de683871cc63eb4a83152 Mon Sep 17 00:00:00 2001 From: ZiyaZhang Date: Sat, 1 Aug 2026 01:08:16 -0700 Subject: [PATCH 04/12] fix(web): address the review of the workspace-file route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- ...6-07-31-web-workspace-file-links.i18n.yaml | 4 +- .../2026-07-31-web-workspace-file-links.md | 11 ++-- .../2026-07-31-web-workspace-file-links.zh.md | 11 ++-- apps/web/tests/workspace-file-open.e2e.ts | 66 ++++++++++--------- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 4 +- packages/client/connection/README.zh.md | 4 +- packages/client/connection/src/index.ts | 3 +- .../client/connection/src/workspace-files.ts | 31 ++++++--- .../client/connection/tests/node-half.spec.ts | 11 +++- .../connection/tests/workspace-files.spec.ts | 27 +++++--- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 4 +- packages/client/ui-conversation/README.zh.md | 4 +- .../src/client/chat/chat-flow.ts | 62 +++++++++++++---- .../ui-conversation/tests/chat-view.spec.tsx | 32 +++++++++ packages/host/apiproxy/src/api-proxy.ts | 4 ++ 17 files changed, 205 insertions(+), 81 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml index 78f99d03c9..584f3c899d 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml @@ -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 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md index 3cd7a075f0..a4bd8a2fec 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md @@ -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/`, 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//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//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. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md index 5702730938..1d001710b2 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md @@ -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//a.txt` 并提供那个工作区文件,而穿越写法应答 404。预览保有自身的能力,因此把主题持久化到 `localStorage` 的生成页面,按其作者的意图正常工作。仍然暂缓:由 `locations` 推导的回合末交付物行,以及助手 Markdown 内部的任何链接化。 +现有的每一处文件交互都同时换了目标:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此一处调用点的改动就让产出的文件在浏览器里可打开,LAN 客户端也在内。三个断言旧 `openPath` 去向的测试被改写为新的去向;工作区外的回退保留了旧断言。这条路由对着真实 HTTP 服务器与真实临时工作区做覆盖,因为收敛、内容定型与 sandbox 头都是协议事实;而组装后的 web 通道(`apps/web/tests/workspace-file-open.e2e.ts`,在冷播种会话上无密钥运行)证明了产品路径:点击读取行的路径会在第二个标签页打开 `/f//a.txt` 并提供那个工作区文件,而穿越写法应答 404。预览在没有 `localStorage` 与 cookie 的情况下运行,这在会持久化主题的生成页面上看得见——那些场景仍有 Host 打开器,而独立的服务源是退休这条限制的路。产出文件行也在本次一并落地:`turnDeliverables` 依据改写工具的渲染意图(diff 卡片,或 `kind` 为 `edit` 的 generic 卡片)读出一轮的产出,在 turn 边界重置以免中断的一轮溢进下一轮,并渲染在收尾 assistant 之下。仍然暂缓:助手 Markdown 内部的链接化,以及任何跨会话回看既往产物的视图。 diff --git a/apps/web/tests/workspace-file-open.e2e.ts b/apps/web/tests/workspace-file-open.e2e.ts index 63d4266cf4..98d239beb1 100644 --- a/apps/web/tests/workspace-file-open.e2e.ts +++ b/apps/web/tests/workspace-file-open.e2e.ts @@ -1,29 +1,31 @@ -// Web e2e scenario: clicking a tool row's file path opens that file in a new -// browser tab, served by the web transport's own /f route. Cold-seeds the -// seeded-history fixture (zero model calls). The surface package tests can -// assert which opener the click reaches, but only the assembled application -// proves the opened URL actually serves the workspace file — the whole point -// of the route (docs/testing.md snapshot rule). -import { mkdir, readFile, writeFile } from 'node:fs/promises' +// Web e2e scenario: a produced file, from the row that lists it to the bytes +// the browser gets. Cold-seeds a recorded write turn (zero model calls). +// Package tests cover the derivation and the route in isolation, but only the +// assembled application shows that the turn's Produced row, the URL it opens, +// and the file on disk are the same thing (docs/testing.md snapshot rule). +import { readFile, writeFile, mkdir } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { - fixtureUserPrompts, launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, } from './scaffold.ts' import { newEnglishPage, saveFailureShot } from './support.ts' -// Borrowed read-only: this scenario needs any settled turn whose tool rows -// carry a workspace file path, not a new recording (message-actions pattern). -const SEED = fileURLToPath(new URL('./snapshots/seeded-history/seed.jsonl', import.meta.url)) +// Borrowed read-only: this scenario needs any settled turn whose tools WROTE a +// file, not a new recording (the message-actions borrowing pattern). +const SEED = fileURLToPath(new URL('./snapshots/permission-policy-context/session.jsonl', import.meta.url)) const MODE = webSnapshotMode() const SEED_ID = 'workspace-file-open-web-e2e' -const PROMPT = 'Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop.' +/** The file the borrowed recording's write tool produces. */ +const PRODUCED = 'policy-neutral.txt' +/** An active document placed alongside it, for the isolation header the route puts on those. */ +const ACTIVE = 'preview.html' -describe('web e2e: opening a workspace file from a tool row', () => { +describe('web e2e: opening a produced file from the conversation', () => { let scaffold: WebScaffold let browser: Browser let page: Page @@ -31,15 +33,13 @@ describe('web e2e: opening a workspace file from a tool row', () => { beforeAll(async () => { scaffold = await launchWebScaffold({}) - // The seeded Session's cwd is the scaffold workspace itself; the recording's - // own nested directory is written too, so the seed's paths stay resolvable. + // The seeded Session's cwd is the scaffold workspace; the recording's own + // nested directory is created too, so its paths stay resolvable. await mkdir(join(scaffold.workspaceCwd, 'workspace'), { recursive: true }) - for (const dir of [scaffold.workspaceCwd, join(scaffold.workspaceCwd, 'workspace')]) { - await writeFile(join(dir, 'a.txt'), 'alpha\n') - await writeFile(join(dir, 'b.txt'), 'beta\n') - } + await writeFile(join(scaffold.workspaceCwd, PRODUCED), 'neutral\n') + await writeFile(join(scaffold.workspaceCwd, ACTIVE), '

produced

\n') const raw = await readFile(SEED, 'utf8') - expect(fixtureUserPrompts(raw), 'borrowed seed must carry the drive prompt').toEqual([PROMPT]) + expect(raw, 'borrowed recording must carry the write this scenario reads').toContain(PRODUCED) await seedSession(scaffold, raw, SEED_ID) browser = await chromium.launch() page = await newEnglishPage(browser) @@ -53,7 +53,7 @@ describe('web e2e: opening a workspace file from a tool row', () => { await scaffold?.close() }) - it.skipIf(MODE === 'record')('opens the read row’s file in a new tab, served from the session workspace', async () => { + it.skipIf(MODE === 'record')('ends the turn with its produced file, which opens as the workspace file itself', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-workspace-file-open')) const groupRow = page.locator('[role="treeitem"]').first() await groupRow.waitFor({ timeout: 15_000 }) @@ -61,26 +61,32 @@ describe('web e2e: opening a workspace file from a tool row', () => { const sessionRow = page.locator('[role="treeitem"]').nth(1) await sessionRow.waitFor({ timeout: 10_000 }) await sessionRow.click() - await expect.poll(() => page.getByText('DONE', { exact: true }).count(), { timeout: 15_000 }).toBe(1) - // The row summary IS the link: a button whose label is the tool's path. - const fileLink = page.getByRole('button', { name: 'a.txt', exact: true }).first() - await fileLink.waitFor({ timeout: 10_000 }) + // The row the turn ends with — derived from the write call's locations, + // not from whatever the closing message happened to say. + const chip = page.getByRole('button', { name: `Open ${PRODUCED}`, exact: true }).first() + await chip.waitFor({ timeout: 15_000 }) + expect(await chip.innerText()).toBe(PRODUCED) + const [opened] = await Promise.all([ page.context().waitForEvent('page', { timeout: 15_000 }), - fileLink.click(), + chip.click(), ]) await opened.waitForLoadState('domcontentloaded') - expect(new URL(opened.url()).pathname).toBe(`/f/${SEED_ID}/a.txt`) - expect(await opened.locator('body').innerText()).toContain('alpha') + expect(new URL(opened.url()).pathname).toBe(`/f/${SEED_ID}/${PRODUCED}`) + expect(await opened.locator('body').innerText()).toContain('neutral') - // The served response is a workspace read, not a download, and never cached - // past the turn that produced it. 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') + // 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') + // 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) diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index 452ffe81c8..68e7f42e21 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/connection/README.md -README.md: ebc2dea2787268e1686eac24565c2433cb5f4b66 -README.zh.md: f5653e9e0e0aed3cbf7a9e6124668942f4dde44a +README.md: cc7070500645f407b46f0326bf74479a81c579d8 +README.zh.md: 600b4f19d3262cb2381ec9368bc92b93db183603 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index ebc2dea278..cc70705006 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -12,10 +12,12 @@ The node half guards every request under `/api` before bridging (`src/api-reques The node half also serves one file at a time out of a Session's workspace under `/f//`, so a produced deliverable is reachable from the page that reported it — an `http` page cannot follow a `file://` link, and a browser that is not on the Host machine has no such path anyway. The segments ride the URL rather than a query parameter so a served document's relative references resolve to its siblings. The request names a Session and the gateway names that Session's directory (`ApiProxy.workspaceRootOf`, which answers from a live agent's header or the persistence store and never resumes an agent to serve a file); this package reads the authority rather than the core services, because holding their host-side Context declarations would merge them over the browser runtime's own. The URL shape itself lives with the other browser-importable contract surfaces, in [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md), so the browser half that builds a URL and this half that parses one share a single encoding decision. Both the cwd and the resolved target go through `realpath` before comparison, so a symlink inside the workspace pointing out of it is refused by its target rather than its name; traversal spellings are refused earlier still, at parse time, before any filesystem call. Reads stream (no request buffers a file), answer `GET`/`HEAD` only, and carry `nosniff` with `no-store`. Extensions outside the served content-type table are typed `text/plain` rather than offered as a download, because a workspace read is a request to see a file. -A served document carries no isolation header and is same-origin with `/api`. That is a decision, not an omission: the only author of these files is the agent already holding this user's shell and filesystem, so a `Content-Security-Policy: sandbox` would sit behind a trust boundary it has already crossed while costing every preview its `localStorage` and cookies — a generated page that remembers a theme breaks under it. A deployment that serves `dsh web` beyond loopback should treat workspace content as trusted, which is already true of everything else its agent does. Isolating a preview becomes a real question when workspace content stops being the viewer's own; the answer then is a separate origin, not a header. The same trust fence gates this prefix, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads. +Documents that can execute script — `.html`, `.htm`, `.xhtml`, `.svg` — additionally carry `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`, which runs them in an opaque origin. A workspace file is not necessarily agent-authored: a read row makes every file in a cloned repository openable, so an active document served same-origin with `/api` would have its script pass the browser-trust fence into every method, the loopback-pinned settings and credential plane included. The cost is borne by the preview — `localStorage`, cookies, and same-origin `fetch` are unavailable inside it, so a generated page that remembers a theme will not — and `host.openPath` remains the full-capability way to open the same file on the Host machine. Restoring those capabilities without reopening the hole needs a separate origin, not a weaker header. The same trust fence gates this prefix, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads. ## Keyless fixture +The fixture carrier has no `/f` route, and `IWorkspaces.fileUrl` derives its URL in the browser regardless of carrier, so a file-path row clicked under `fixture=` opens a tab that 404s where the Host opener used to be a silent no-op. Fixture pages carry no file rows today; a fixture scenario that adds them should stub the derivation rather than teach the in-memory carrier to serve bytes. + Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points. ## Model Experience diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index f5653e9e0e..600b4f19d3 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -12,10 +12,12 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust node 半侧还会在 `/f//` 下逐个提供某个 Session 工作区里的文件,让产出的交付物能从报告它的那个页面直接抵达——`http` 页面无法跟随 `file://` 链接,而不在 Host 机器上的浏览器本来也没有那条路径。段落走 URL 而非查询参数,是为了让所服务文档的相对引用能解析到它的同级文件。请求指名一个 Session,由网关指名该 Session 的目录(`ApiProxy.workspaceRootOf`,它从活跃 agent 的 header 或持久化存储作答,绝不会为了提供一个文件而恢复 agent);本包读取这个权威来源而不去够核心服务,因为持有它们的 host 侧 Context 声明会把它们盖到浏览器运行时自己的声明之上。URL 形状本身与其余浏览器可导入的契约面放在一起,位于 [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md),因此构造 URL 的浏览器半侧与解析 URL 的这一半共享同一个编码决定。cwd 与解析出的目标在比较前都要过 `realpath`,因此工作区内指向工作区外的符号链接会因其目标而被拒绝,而不是因其名字;穿越写法拒得更早,在解析期、任何文件系统调用之前。读取是流式的(没有请求会把文件缓冲起来),只应答 `GET`/`HEAD`,并带上 `nosniff` 与 `no-store`。所服务的内容类型表之外的扩展名一律按 `text/plain` 定型而非作为下载给出,因为工作区读取本就是一个“让我看看这个文件”的请求。 -所服务的文档不带任何隔离头,与 `/api` 同源。这是一个决定,不是遗漏:这些文件的唯一作者,正是那个已经握着本用户 shell 与文件系统的 agent,因此 `Content-Security-Policy: sandbox` 只会立在一条它早已越过的信任边界之后,代价却是每个预览都失去 `localStorage` 与 cookie——一个会记住主题的生成页面在它之下就是坏的。把 `dsh web` 服务到回环之外的部署,应当把工作区内容按可信处理,而这一点对其 agent 所做的其他一切本来就已成立。当工作区内容不再属于观看者本人时,隔离预览才成为一个真问题;那时的答案是一个独立的源,而不是一个头。这条前缀由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。 +能执行脚本的文档——`.html`、`.htm`、`.xhtml`、`.svg`——还会额外带上 `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`,让它们运行在不透明源中。工作区文件未必由 agent 撰写:一条 read 行就能让 clone 下来的仓库里任何文件变得可打开,因此与 `/api` 同源提供的活动文档,其脚本会带着浏览器信任 fence 通行到每一个方法,包括那些正因会改动设置与凭据而被钉在回环的方法。代价由预览承担——其中无法使用 `localStorage`、cookie 与同源 `fetch`,因此一个会记住主题的生成页面在预览里记不住——而 `host.openPath` 仍是在 Host 机器上以完整能力打开同一文件的方式。要在不重新打开这个洞的前提下取回那些能力,需要的是一个独立的源,而不是一个更弱的头。这条前缀由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。 ## 无密钥 fixture +fixture 载体没有 `/f` 路由,而 `IWorkspaces.fileUrl` 无论载体为何都在浏览器侧推导 URL,因此在 `fixture=` 下点击文件路径行会打开一个 404 的标签页,而此处从前是 Host 打开器的静默空操作。今天的 fixture 页面并不含文件行;若某个 fixture 场景要加上它们,应当把这段推导打桩,而不是教这个内存载体去提供字节。 + 任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session;`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace;`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId,均具有足够的确定性,组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token/短语行为,并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。 ## 模型体验 diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 4f9ce7d51b..59bab263ea 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -118,7 +118,8 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { return } if (req.method !== 'GET' && req.method !== 'HEAD') { - res.writeHead(405) + // RFC 9110 §15.5.6: a 405 names the methods the resource does support. + res.writeHead(405, { allow: 'GET, HEAD' }) res.end() return } diff --git a/packages/client/connection/src/workspace-files.ts b/packages/client/connection/src/workspace-files.ts index 354b7b2225..e173c33a40 100644 --- a/packages/client/connection/src/workspace-files.ts +++ b/packages/client/connection/src/workspace-files.ts @@ -10,13 +10,13 @@ * owns the browser-trust fence ([api-request-trust](./api-request-trust.ts)) — * this module is reached only by requests that already passed it. * - * A served document is same-origin with `/api`, and deliberately carries no - * isolation header. The only author of these files is the agent already - * holding this user's shell and filesystem, so a browser sandbox would not - * move the trust boundary — it would sit behind one already crossed, at the - * cost of `localStorage` and cookies in every preview. Isolating a preview - * becomes a real question when workspace content stops being the viewer's own; - * the answer then is a separate origin, not a header. + * Script-capable documents are served into an opaque origin. A workspace file + * is not necessarily agent-authored — a read row makes every file in a cloned + * repository openable — so an active document served same-origin with `/api` + * reaches the whole RPC surface, the loopback-pinned settings and credential + * methods included. The sandbox costs a preview its `localStorage` and + * cookies; restoring those without reopening that hole needs a separate + * origin, not a weaker header. */ import { createReadStream } from 'node:fs' @@ -59,6 +59,17 @@ const MIME: Record = { const DEFAULT_MIME = 'text/plain; charset=utf-8' +/** Extensions whose top-level navigation can execute script, and so need the sandbox. */ +const SCRIPTABLE = new Set(['.html', '.htm', '.xhtml', '.svg']) + +/** + * The opaque origin an active workspace document runs in. Without it the + * document is same-origin with `/api` and its script passes the browser-trust + * fence, which admits every method — including the ones pinned to loopback + * precisely because they mutate settings and credentials. + */ +const SANDBOX_CSP = 'sandbox allow-scripts allow-popups allow-modals allow-forms' + /** How the route learns which directory a session may serve from. */ export interface WorkspaceFileDeps { /** @@ -85,8 +96,11 @@ function fail(res: ServerResponse, status: number): void { */ async function confine(cwd: string, segments: readonly string[]): Promise { const root = await realpath(cwd) + // A filesystem root already ends in the separator; appending a second one + // would make every child fail the prefix test and 403 the whole workspace. + const prefix = root.endsWith(sep) ? root : root + sep const real = await realpath(resolve(root, ...segments)) - return real.startsWith(root + sep) ? real : undefined + return real.startsWith(prefix) ? real : undefined } /** @@ -146,6 +160,7 @@ export async function handleWorkspaceFile( // Workspace files change under the agent's hands; a cached preview would // show the previous turn's output after the next edit. 'cache-control': 'no-store', + ...SCRIPTABLE.has(ext) ? { 'content-security-policy': SANDBOX_CSP } : {}, }) if (req.method === 'HEAD') { res.end() diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 0b2c58ab37..8ab1fbce8e 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -34,11 +34,15 @@ function fakeRequest(headers: Record, url = `${API_PATH}/session } /** Response recorder compatible with both the fence's short-circuit and the bridge. */ -function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } { - const state: { status?: number; body?: unknown } = {} +function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown; headers?: Record } } { + const state: { status?: number; body?: unknown; headers?: Record } = {} const response = Object.assign(new EventEmitter(), { writableEnded: false, - writeHead(value: number) { state.status = value; return this }, + writeHead(value: number, headers?: Record) { + state.status = value + if (headers !== undefined) state.headers = headers + return this + }, write() { return true }, end(this: { writableEnded: boolean }, value?: unknown) { if (value !== undefined) state.body = value @@ -177,6 +181,7 @@ describe('connection node half: the /f workspace-file route', () => { Object.assign(post, { method: 'POST' }) await filesRoute(routes).handler(post, written.response) expect(written.state.status).toBe(405) + expect(written.state.headers).toMatchObject({ allow: 'GET, HEAD' }) await dispose() }) diff --git a/packages/client/connection/tests/workspace-files.spec.ts b/packages/client/connection/tests/workspace-files.spec.ts index eb36fdd6ea..6aee38e4d9 100644 --- a/packages/client/connection/tests/workspace-files.spec.ts +++ b/packages/client/connection/tests/workspace-files.spec.ts @@ -8,7 +8,7 @@ import type { AddressInfo } from 'node:net' import type { ServerResponse } from 'node:http' import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { join } from 'node:path' +import { join, sep } from 'node:path' import { Writable } from 'node:stream' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' @@ -37,7 +37,8 @@ beforeAll(async () => { const server = createServer((req, res) => { void handleWorkspaceFile(req, res, { - cwdFor: async sessionId => sessionId === SESSION ? workspace : undefined, + // 'rooted' names the filesystem root, the separator-terminated realpath case. + cwdFor: async sessionId => sessionId === SESSION ? workspace : sessionId === 'rooted' ? sep : undefined, }) }) await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) @@ -58,25 +59,35 @@ function get(path: string, init?: RequestInit): Promise { } describe('workspace file reads', () => { - it('serves a produced document with its own capabilities intact', async () => { + it('serves an active document into an opaque origin', async () => { const response = await get(`${FILES_PATH}/${SESSION}/index.html`) expect(response.status).toBe(200) expect(await response.text()).toBe('

产物

') expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8') - // No isolation header: a preview keeps localStorage and cookies, because - // the file's author already holds this user's shell (see the module doc). - expect(response.headers.get('content-security-policy')).toBeNull() + // A workspace file is not necessarily agent-authored, and same-origin + // script here would pass the browser-trust fence into every RPC method. + expect(response.headers.get('content-security-policy')).toContain('sandbox') + expect(response.headers.get('content-security-policy')).not.toContain('allow-same-origin') expect(response.headers.get('x-content-type-options')).toBe('nosniff') expect(response.headers.get('cache-control')).toBe('no-store') expect(response.headers.get('content-disposition')).toBe('inline') }) - it('types SVG as a standalone document rather than sniffable bytes', async () => { + it('sandboxes SVG too, and leaves inert types unrestricted', async () => { const svg = await get(`${FILES_PATH}/${SESSION}/chart.svg`) expect(svg.headers.get('content-type')).toBe('image/svg+xml') - expect(svg.headers.get('x-content-type-options')).toBe('nosniff') + expect(svg.headers.get('content-security-policy')).toContain('sandbox') const text = await get(`${FILES_PATH}/${SESSION}/notes.txt`) expect(text.headers.get('content-type')).toBe('text/plain; charset=utf-8') + expect(text.headers.get('content-security-policy')).toBeNull() + }) + + it('serves a workspace rooted at a filesystem root, whose realpath already ends in a separator', async () => { + // `realpath('/')` is '/', so a naive `root + sep` prefix is '//' and every + // child of that workspace would 403. + const rooted = await fetch(`${origin}${FILES_PATH}/rooted${new URL(`file://${workspace}/notes.txt`).pathname}`) + expect(rooted.status).toBe(200) + expect(await rooted.text()).toBe('plain') }) it('shows an unknown extension as text rather than downloading it', async () => { diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 505d693bb6..2a2857839d 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md -README.md: b61a70fb079eb6a1bc2a67b682a337ffdf708b79 -README.zh.md: 0bb1740b166cfacc2bc79fe2f49793796f66c365 +README.md: ba55f0704500034b7afb37258064fe0801aaee91 +README.zh.md: 4212908b355a81dfd5af8645ce5d4284a4555622 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index b61a70fb07..ba55f07045 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -14,7 +14,7 @@ Approvals take over the composer through the chain this package declares: `Appro Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)). -Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file with the host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. +Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file: one inside the session workspace opens in a new browser tab, served by the web transport's `/f` route, so a client that is not on the Host machine still sees it; one outside the workspace has no served URL and falls back to the Host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering. A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)). @@ -42,6 +42,8 @@ The chat stats line takes its token accounting from two generic token-meter proj `src/client/` is organized for the future package split: `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations + composed slot props including the tool-row contract, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` (sample registrants) domain directories import contract files and never each other; `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components (skeleton, chat rows) and the store factory stay internal and reach the page exclusively through apply's slot registrations (tests take them via the `./src/*` subpath). +A finished turn ends with the files it produced. `chat-flow.ts`'s `turnDeliverables` reads them off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. The row renders under the closing assistant's body and above its IconActions, keyed to the same seq `assistantActionsSeqs` elects. It shows six chips (basename, full path as the title) plus an explicit remainder count, and each chip opens through the same `openFile` the tool rows use. + ## Model Experience None, as the conversation UI renders session history and streams in the browser; nothing here reaches a model request. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 0bb1740b16..4212908b35 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -12,7 +12,7 @@ 已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。 -通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击后通过宿主操作系统的默认应用打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 +通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击即打开文件:位于会话工作区之内的文件在新浏览器标签页打开,由 web 传输的 `/f` 路由提供,因此不在 Host 机器上的客户端也能看到;工作区之外的文件没有可服务的 URL,回退到宿主操作系统的默认应用(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。 声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。 @@ -42,6 +42,8 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 `src/client/` 按未来的包拆分组织:`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明 + 组合后的 slot props,包括工具行契约、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/`(示例注册方)领域目录只导入 contract 文件,彼此绝不导入;`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件(骨架、聊天行)与 store factory 保持内部状态,只能通过 apply 的 slot 注册到达页面(测试通过 `./src/*` 子路径获取它们)。 +完成的一轮以它产出的文件收尾。`chat-flow.ts` 的 `turnDeliverables` 从改写工具自身的跟随文件 `locations` 中读出它们——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。该行渲染在收尾 assistant 正文之下、其 IconActions 之上,键控到 `assistantActionsSeqs` 选出的同一个 seq。它展示六枚 chip(文本为文件名,完整路径作为 title),外加一个显式的剩余计数,每枚 chip 都经由工具行所用的同一个 `openFile` 打开。 + ## 模型体验 无。会话 UI 在浏览器中渲染会话历史与流;这里没有任何内容进入模型请求。 diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 83ba5c463c..7b5c53d7ac 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -32,6 +32,21 @@ function rendersNothing(node: ConversationNode): boolean { || ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === '')) } +/** + * Paths a call view reports having created or changed, by render intent rather + * than tool name: a diff card, or a generic card whose kind is `edit` (the + * shape `str_replace_editor`'s insert presents). Every other card produces + * nothing to open — a read looked, a delete removed, a terminal ran. + */ +function producedPaths(view: ToolResultNode['callView']): readonly string[] { + if (view === null) return [] + if (view.card === 'diff') return (view.locations ?? []).map(location => location.path) + if (view.card === 'generic' && view.kind === 'edit') { + return (view.locations ?? []).map(location => location.path) + } + return [] +} + /** * Seq set of assistants that own IconActions: the last content-text assistant * in each turn. Mid-turn narration (text before tools) stays chrome-free. @@ -54,10 +69,19 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon * * The source is the mutation tools' own follow-along `locations`, not the * closing prose: a produced file must be listed whether or not the model - * remembered to name it. Reads contribute nothing (looking at a file does not - * produce it) and a failed mutation contributes nothing (there is no file to - * open). Paths keep first-seen order and appear once, so a file written and - * then edited in the same turn is one entry. + * remembered to name it. A mutation is recognized by render intent, not by + * tool name — a diff card, or a generic card whose `kind` is `edit` (the shape + * `str_replace_editor`'s insert presents) — so a new mutation tool joins by + * declaring what it does. Reads contribute nothing (looking at a file does not + * produce it), and neither do deletes (there is nothing left to open) or + * failed calls. Paths keep first-seen order and appear once, so a file written + * and then edited in the same turn is one entry. + * + * Accumulation resets on the turn boundary, not merely at the closing + * assistant: a turn that mutates files and then ends without content text + * (interrupted mid-tool, or a turn whose last text precedes its last tool + * result) must not spill its paths into the next turn's row, nor leave `seen` + * suppressing a file the next turn legitimately rewrites. * @param nodes - snapshot nodes (surface order). * @returns Per-closing-seq produced paths; a turn that produced none is absent. */ @@ -65,21 +89,37 @@ export function turnDeliverables(nodes: readonly ConversationNode[]): ReadonlyMa const closing = assistantActionsSeqs(nodes) const byClosingSeq = new Map() let pending: string[] = [] - const seen = new Set() + let seen = new Set() + let turn: number | undefined for (const node of nodes) { if (node.kind === 'tool-result') { - if (node.isError || node.callView?.card !== 'diff') continue - for (const location of node.callView.locations ?? []) { - if (seen.has(location.path)) continue - seen.add(location.path) - pending.push(location.path) + if (node.isError) continue + for (const path of producedPaths(node.callView)) { + if (seen.has(path)) continue + seen.add(path) + pending.push(path) } continue } + // Tool results carry no turn of their own, so the boundary is read off the + // nodes that do. A user message opens a turn without reporting a number, + // which is why the tracked turn goes back to undefined there: the next + // node to report one is stating the current turn, not entering a new one. + if (node.kind === 'user') { + turn = undefined + pending = [] + seen = new Set() + } else if ('turn' in node) { + if (turn !== undefined && node.turn !== turn) { + pending = [] + seen = new Set() + } + turn = node.turn + } if (node.kind !== 'assistant' || !closing.has(node.seq)) continue if (pending.length > 0) byClosingSeq.set(node.seq, pending) pending = [] - seen.clear() + seen = new Set() } return byClosingSeq } diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 6f855231ac..d95d1db9ff 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -241,6 +241,38 @@ describe('chat-flow derivation', () => { expect(turnDeliverables([user(1, 'hi'), assistant(2, 'hello', 1)]).size).toBe(0) }) + it('turnDeliverables counts a generic edit and never spills across the turn boundary', () => { + const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ + ...toolResult(seq, callId, 'str_replace_editor'), + // str_replace_editor's insert mutates behind a generic card, so the + // discriminant is the render intent, not the card shape alone. + callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }, + }) + const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({ + ...toolResult(seq, callId, 'write'), + callView: { + card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }], + }, + }) + const produced = turnDeliverables([ + user(1, 'insert a line'), + inserted(2, 'i', 'notes.md'), + assistant(3, 'inserted', 1), + // Turn 2 mutates and then ends with no content text (interrupted, or its + // last text preceded the tool): its paths must not ride into turn 3. + user(4, 'now rewrite it'), + wrote(5, 'w', 'leaked.txt'), + user(6, 'and again'), + wrote(7, 'w2', 'notes.md'), + assistant(8, 'done', 3), + ]) + expect(produced.get(3)).toEqual(['notes.md']) + // Turn 3 lists only its own file — and `seen` did not suppress the rewrite + // of a path an earlier turn already touched. + expect(produced.get(8)).toEqual(['notes.md']) + expect([...produced.values()].flat()).not.toContain('leaked.txt') + }) + it('renders the produced files under the closing message and opens one on click', () => { const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ ...toolResult(seq, callId, 'write'), diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 3c20785657..63e1f0007c 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2299,6 +2299,10 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro if (live !== undefined) return live.session.header.cwd const persistence = ctx.get('sessionPersistence') if (persistence === undefined) return undefined + // TODO(persistence/by-id): a full listing per lookup. Harmless while the + // caller is one preview open, but a served document with N relative + // sub-resources pays it N times; a by-id header read on the persistence + // seam would retire it. return (await persistence.list()).find(meta => meta.id === sessionId)?.cwd }, } From 59bfe77fb821eeadcea4b97cbb50981d04b556bd Mon Sep 17 00:00:00 2001 From: ZiyaZhang Date: Sat, 1 Aug 2026 02:17:25 -0700 Subject: [PATCH 05/12] feat(web): serve workspace files from their own origin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ` + const head = html.indexOf('') + if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}` + /* v8 ignore next -- headless fixture pages may lack ; prepending keeps read-before-shell ordering. */ + return `${script}${html}` +} diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 59bab263ea..f0a60bbfb9 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -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 { // 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 => 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') } diff --git a/packages/client/connection/src/workspace-files.ts b/packages/client/connection/src/workspace-files.ts index e173c33a40..c934b14516 100644 --- a/packages/client/connection/src/workspace-files.ts +++ b/packages/client/connection/src/workspace-files.ts @@ -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 = { 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() diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 6892dc7721..4b323182bb 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -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 { @@ -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() + }) }) diff --git a/packages/client/connection/tests/files-server.spec.ts b/packages/client/connection/tests/files-server.spec.ts new file mode 100644 index 0000000000..4a2618709b --- /dev/null +++ b/packages/client/connection/tests/files-server.spec.ts @@ -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('x', 4321) + expect(html).toContain('') + // Ahead of anything the shell might read it from. + expect(html.indexOf('__DSH_FILES_PORT__')).toBeLessThan(html.indexOf('')) + }) +}) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 8ab1fbce8e..2561a0846f 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -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() }) diff --git a/packages/client/connection/tests/workspace-files.spec.ts b/packages/client/connection/tests/workspace-files.spec.ts index 6aee38e4d9..8e33a6751b 100644 --- a/packages/client/connection/tests/workspace-files.spec.ts +++ b/packages/client/connection/tests/workspace-files.spec.ts @@ -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 () => { diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts index dbc0f3b30f..3e64ef3717 100644 --- a/packages/client/runtime/src/client/contract/workspaces.ts +++ b/packages/client/runtime/src/client/contract/workspaces.ts @@ -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. diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index 837a7daa03..c0eb46fcf9 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -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. diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index d389efe319..a5827173a8 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -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 } } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index fd7858d60c..a35983d890 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -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: () => {} } diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index 3d9cef547f..4323d7ffce 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -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() diff --git a/packages/client/test-runtime/package.json b/packages/client/test-runtime/package.json index e892d9cd52..6d7093a148 100644 --- a/packages/client/test-runtime/package.json +++ b/packages/client/test-runtime/package.json @@ -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:^", diff --git a/packages/client/test-runtime/src/connection.ts b/packages/client/test-runtime/src/connection.ts new file mode 100644 index 0000000000..5df5d5a053 --- /dev/null +++ b/packages/client/test-runtime/src/connection.ts @@ -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)}` + } +} diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index 5ef5350434..cdbdca75a2 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -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() diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 01e7db4c3d..95f6574405 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -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. diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index a9c4b0c9ca..3675671f26 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -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) diff --git a/packages/client/test-runtime/tsconfig.json b/packages/client/test-runtime/tsconfig.json index 6a758c66f9..681bff474c 100644 --- a/packages/client/test-runtime/tsconfig.json +++ b/packages/client/test-runtime/tsconfig.json @@ -17,6 +17,9 @@ { "path": "../web-react" }, + { + "path": "../connection" + }, { "path": "../runtime" }, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 2a2857839d..446c2084b1 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index ba55f07045..8c2075d615 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -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)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 4212908b35..634721b424 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -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))。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 88c09b5550..55036600c6 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -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:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 71f05267b3..a65ce1d7a4 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -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 diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 6427f6750c..b9dbe0d6ad 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -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. diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 51e31c9750..2763702c0b 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -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() diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 6cb46e7ea0..84cdd53eeb 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -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() diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 04b265bdd5..33d45124b4 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../web-react" }, + { + "path": "../connection" + }, { "path": "../runtime" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7073f04e4b..5fb7df52ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 From 8fb6c2bd698d75a621912570663505d4dd0d42e4 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <zzy211851@gmail.com> Date: Sat, 1 Aug 2026 03:15:54 -0700 Subject: [PATCH 06/12] refactor(web): open produced files through the Host, not over HTTP MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scope decision: previews for a browser that is not on the Host machine are not supported. With that settled, host.openPath answers the supported case completely — a file:// document in a real browser has full page capabilities and no reach into /api — and the HTTP serving this branch had built answered only the unsupported one. Removed: the /f route and its listener, the workspace-file URL shape, ApiProxy.workspaceRootOf, ConnectionHandle.fileUrl, and the port published into the index page. Kept, and finished: - the produced-files row a turn ends with, derived from mutation locations; - the path link now reads as a link at rest, not only on hover — the reported "I can't open what it made" was this, sitting on a working capability; - the Host opener prefers the default BROWSER for .html/.htm/.xhtml/.svg, so a developer who binds .html to an editor still gets a rendered page (macOS via the LaunchServices https handler, Linux via $BROWSER, every failure falling back to the default application). The retired designs and their measurements stay in the Agent Note, including why same-origin serving was unsafe and why the sandbox that fixed it broke the pages invisibly. --- ...6-07-31-web-workspace-file-links.i18n.yaml | 4 +- .../2026-07-31-web-workspace-file-links.md | 28 ++- .../2026-07-31-web-workspace-file-links.zh.md | 28 ++- apps/web/tests/produced-files.e2e.ts | 76 ++++++++ apps/web/tests/workspace-file-open.e2e.ts | 122 ------------- apps/web/tsconfig.json | 2 +- docs/config-catalog.md | 2 +- packages/client/connection/README.i18n.yaml | 4 +- packages/client/connection/README.md | 10 +- packages/client/connection/README.zh.md | 8 +- .../client/connection/src/client/fixture.ts | 4 - .../client/connection/src/client/index.ts | 25 --- .../client/connection/src/files-server.ts | 127 -------------- packages/client/connection/src/index.ts | 41 +---- .../client/connection/src/workspace-files.ts | 164 ------------------ .../connection/tests/client-apply.spec.ts | 26 +-- .../connection/tests/files-server.spec.ts | 44 ----- .../client/connection/tests/node-half.spec.ts | 114 ++---------- .../connection/tests/workspace-files.spec.ts | 142 --------------- .../client/runtime/tests/client-apply.spec.ts | 1 - .../client/runtime/tests/wire-events.spec.ts | 1 - packages/client/test-runtime/package.json | 2 - .../client/test-runtime/src/connection.ts | 48 ----- packages/client/test-runtime/src/index.ts | 6 - packages/client/test-runtime/tsconfig.json | 3 - .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- packages/client/ui-conversation/package.json | 2 - .../ui-conversation/src/client/apply.ts | 13 +- .../src/client/chat/ToolRow.module.css | 13 +- .../tests/apply-inject.spec.tsx | 16 +- .../tests/chat-code-subcalls.spec.tsx | 7 +- .../tests/chat-toolview-slot.spec.tsx | 7 +- packages/client/ui-conversation/tsconfig.json | 3 - packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 - packages/host/apiproxy/README.zh.md | 2 - packages/host/apiproxy/src/api-proxy.ts | 15 -- packages/host/apiproxy/src/api/files.ts | 98 ----------- packages/host/apiproxy/src/api/index.ts | 17 -- packages/host/apiproxy/src/index.ts | 2 - .../host/apiproxy/src/native-path-opener.ts | 76 +++++++- .../tests/api-proxy-workspace.spec.ts | 32 +--- .../apiproxy/tests/client-handler.spec.ts | 2 - .../host/apiproxy/tests/fetch-carrier.spec.ts | 2 - .../host/apiproxy/tests/files-path.spec.ts | 74 -------- .../apiproxy/tests/native-path-opener.spec.ts | 93 ++++++++++ pnpm-lock.yaml | 6 - tsconfig.host.json | 2 +- 50 files changed, 317 insertions(+), 1211 deletions(-) create mode 100644 apps/web/tests/produced-files.e2e.ts delete mode 100644 apps/web/tests/workspace-file-open.e2e.ts delete mode 100644 packages/client/connection/src/files-server.ts delete mode 100644 packages/client/connection/src/workspace-files.ts delete mode 100644 packages/client/connection/tests/files-server.spec.ts delete mode 100644 packages/client/connection/tests/workspace-files.spec.ts delete mode 100644 packages/client/test-runtime/src/connection.ts delete mode 100644 packages/host/apiproxy/src/api/files.ts delete mode 100644 packages/host/apiproxy/tests/files-path.spec.ts diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml index e8510d24e5..2b75bc2eff 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml @@ -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: 8eeb96517aa905e77e50ce36f0efb704353c0821 -2026-07-31-web-workspace-file-links.zh.md: 63d746b0a9aabaf78ba5653e16705bd662a54126 +2026-07-31-web-workspace-file-links.md: da99426ecb5ca81dcc110bbd4d5c1218390ae4bd +2026-07-31-web-workspace-file-links.zh.md: 91aa94c6fe253c64125eb31fd15973a5aaff1a8f diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md index 8eeb96517a..da99426ecb 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md @@ -4,36 +4,32 @@ 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, 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. +> Scope: the produced-files row a finished turn ends with, the file-path link that reads as one, and the Host opener preferring the default browser for documents a browser renders. Not in scope, by decision: serving workspace files over HTTP, and previews for a client that is not on the Host machine. ## Problem A web session that produced a file had no way to look at it. The agent wrote `deepseek-homepage.html`, said so, and the user's only recourse was to copy an absolute path like `/private/tmp/dsh-client-hotplug.ygPvsm/workspaces/plugin-hotplug/deepseek-homepage.html` into a terminal. -The parts were nearly all present, pointed at the wrong target. `ToolRow` already renders a mutation or read row's path as a real button, `ui-conversation` already routes its click through `openFile`, and `workspaces.openPath` already carries it to the Host's system opener. But that opener runs on the Host machine, and `host.openPath` is loopback-pinned by the `/api` trust fence, so the affordance answered nothing for a browser reached over the LAN and was invisible even locally (the path styled as plain text, underlined only on hover). Meanwhile `MarkdownText` strips every non-`http(s)` URL, so a path the model wrote into its closing message could never become a link at all, and `ToolCallView.locations` — the follow-along vocabulary the file tools already populate — had no consumer in the client. +Two distinct defects sat behind that. The transcript never said what a turn had produced: `ToolCallView.locations` — the follow-along vocabulary the file tools already populate — had no consumer in the client, so a reader's only account of the output was whatever the closing message happened to spell. And the affordance that did exist was invisible: `ToolRow` already renders a mutation or read row's path as a real button wired to `host.openPath`, but styled exactly like the surrounding prose and underlined only on hover, so nobody found it. The reported "I can't open what it made" was a discoverability failure sitting on top of a working capability. ## Decision -**One prefix route on the transport that already exists, not a new capability.** `client-connection` owns both browser-facing prefixes: `/api` for RPC and `/f/<sessionId>/<segments…>` for workspace-file reads. It was already the package holding `httpServer`, the `trustedHosts` config, and the browser-trust fence; a separate package would have duplicated the fence and the config, and forced `AppCLIEntry` to patch two rows for one `--trusted-host` flag. The webserver's own contract — every feature surface is a route some other plugin registers — makes the route the whole mechanism. Segments ride the path rather than a query parameter so a served document's relative references resolve to its siblings. +**A finished turn ends with the files it produced.** `turnDeliverables` reads them off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. The row renders under the closing assistant's body and above its IconActions, keyed to the seq `assistantActionsSeqs` already elects. -**The request names a Session; the gateway names the authority.** `ApiProxy.workspaceRootOf` answers where a Session's files live — a live agent's `session.header.cwd` first, then the persistence store, never a resume — as a second, non-envelope face of the `cwd` the session summaries already carry. The route reads that instead of `ctx.agents` directly, because `client-connection` is registered in the client program and importing the core service packages merges their host-side `sessions: SessionStore` declaration over the browser runtime's own `sessions: SessionsService` — the collision `tsconfig.host.json`/`tsconfig.client.json` exist to prevent. Both the cwd and the resolved target go through `realpath` before the prefix comparison, so a workspace-internal symlink pointing outward is refused by its target; traversal spellings are refused at parse time, before any filesystem call. Reads stream through `pipeline`, so a client that goes away destroys the descriptor and no request ever buffers a file. +**The path link reads as a link.** Underlined at rest, not only on hover. This is the smaller half of the diff and the larger half of the fix. -**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. +**Opening stays the Host's job, and prefers the default browser.** `host.openPath` hands the path to the operating system, which yields a `file://` document in a real browser: full page capabilities, and no reachability into `/api`, because a `file://` document is not same-origin with it. Measured on the reported artifact: `localStorage` works, the theme toggle flips, the tabs switch, and `fetch` to the API fails. For documents a browser renders — `.html`, `.htm`, `.xhtml`, `.svg` — the opener resolves the default *browser* rather than the type's default application, because a developer who binds `.html` to an editor would otherwise click a produced page and get source code. Each platform answers "which browser" as completely as it can (macOS from the LaunchServices `https` handler, Linux from `$BROWSER`), and every failure falls back to the default application rather than surfacing. -**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.** `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. +**Serving workspace files over HTTP is out of scope, and so are non-local clients.** An earlier revision served files from the harness itself — first same-origin with `/api`, then behind `CSP: sandbox`, then from a second listener whose own port gave served documents their own origin. Each step answered a real problem, and the whole line was retired once the product scope was settled: previews for a browser that is not on the Host machine are not supported. With that decided, the Host opener answers the supported case completely and the HTTP machinery answered only the unsupported one. ## Alternatives considered -- **The artifact capability family (RFC #268 / PR #272)** — a seam with ids, versions, snapshot storage, its own HTTP server, SSE live reload, and a browser auto-opener. Its review found seven critical issues, and every one of them came from that machinery: an unlistened opener spawn crashing the harness, the opener inheriting `DEEPSEEK_API_KEY`, in-flight publishes outliving disposal, `readFile` preceding the size cap, a snapshot TOCTOU, and retention leaking with undisposed agents. `dsh web` already runs an HTTP server and the user is already in a browser, so none of that machinery buys anything here. The RFC and its tests stay as the input for the day a real cross-session or versioned-artifact need appears; this route is that seam's natural mount point when it does. -- **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 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. +- **Serving `/f/<sessionId>/<segments…>` from the harness** — built and working, including confinement by dual `realpath`, the browser-trust fence, streamed reads, and a separate listener whose port gave served documents their own origin. It is the only design that shows a preview to a client on another machine, which is exactly the case ruled out of scope. Retired for that reason, not because it failed; its cost was a second socket with its own lifecycle, a port published into the page, and a URL-shape contract shared across two packages. +- **Same-origin HTTP serving without isolation** — measurably unsafe, and recorded so nobody retries it: a document served beside `/api` drove `settings.describe` to a `200` with full data and `session.list` to 35 KB of every session's transcript, from a page that need not be agent-authored at all (a read row makes every file in a cloned repository openable). +- **`Content-Security-Policy: sandbox` over that same-origin serving** — closes the hole by taking the document's origin away, which measurably breaks the pages this feature exists to show: 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 rendered perfectly, so the breakage was invisible. +- **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. +- **An embedded WebView in the desktop shell** — the strongest isolation available, since the preview then runs in a container the product owns rather than in the user's browser. It belongs to the desktop shell's own design, not to this surface, and is recorded here as the direction a future preview capability should take. ## 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 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. +Every existing file affordance changed at once: write, edit, read, and the generic single-file card all reach `openFile`, so the link fix and the browser preference apply to all of them without a per-row change. The keyless web lane (`apps/web/tests/produced-files.e2e.ts`) cold-seeds a recorded write turn and pins the row in the assembled application; it deliberately does not click, because the click hands a path to the Host's opener and would launch a real application on the machine running the suite. A produced file opens as a `file://` document, which cannot `fetch` its own siblings (a multi-file artifact that loads `./data.json` breaks, while `<script src>`, `<img>`, and CSS `@import` are unaffected) — the one capability HTTP serving had that this does not. A client reached over the network sees nothing when it clicks: `host.openPath` runs on the Host and is loopback-pinned by the `/api` trust fence. That is the scope decision showing through, not a defect, and it is why the row keeps the full path in its `title` for a reader who can only copy it. Markdown opens in whatever the platform hands `.md`, usually an editor rather than a renderer; rendering it inside the product is a separate, deferred surface. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md index 63d746b0a9..91aa94c6fe 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md @@ -4,36 +4,32 @@ Status: implemented [English](2026-07-31-web-workspace-file-links.md) | 中文 -> 范围:web 传输层上的 `/f` 工作区文件路由、其背后的 `IWorkspaces.fileUrl` 推导、会话中打开文件的交互改指向它,以及完成的一轮以其产出文件收尾的那一行。不在范围内:产物注册表、版本、实时重载,或任何面向模型的声明。 +> 范围:完成的一轮以其产出文件收尾的那一行、读得出是链接的文件路径链接,以及 Host 打开器对浏览器可渲染文档优先选用默认浏览器。经决定不在范围内:以 HTTP 提供工作区文件,以及为不在 Host 机器上的客户端提供预览。 ## 问题 一个产出了文件的 web 会话,没有办法看到那个文件。agent 写出了 `deepseek-homepage.html` 并如实告知,而用户唯一的办法是把 `/private/tmp/dsh-client-hotplug.ygPvsm/workspaces/plugin-hotplug/deepseek-homepage.html` 这样的绝对路径复制进终端。 -零件几乎都在,只是指错了目标。`ToolRow` 早已把改写行或读取行的路径渲染成一个真正的按钮,`ui-conversation` 早已把它的点击经由 `openFile` 转发,`workspaces.openPath` 也早已把它送到 Host 的系统打开器。但那个打开器运行在 Host 机器上,而 `host.openPath` 被 `/api` 信任 fence 钉在回环,所以这个交互对经 LAN 访问的浏览器什么都答不了,即便在本机也是隐形的(路径的样式就是普通文本,只有 hover 时才有下划线)。与此同时 `MarkdownText` 会剥掉每一个非 `http(s)` 的 URL,因此模型写进收尾消息里的路径根本不可能成为链接;而 `ToolCallView.locations`——文件工具早已填好的跟随文件词汇——在客户端没有任何消费方。 +这背后是两个不同的缺陷。转录从不说明一轮产出了什么:`ToolCallView.locations`——文件工具早已填好的跟随文件词汇——在客户端没有任何消费方,因此读者对产出的唯一交代,就是收尾消息恰好拼出来的那点内容。而已经存在的那个交互是隐形的:`ToolRow` 早已把改写行或读取行的路径渲染成一个接到 `host.openPath` 的真按钮,但它的样式与周围正文一模一样、只有悬停才有下划线,于是没人发现。所报告的“做完了打不开”,是一个可发现性失败叠在一项本就可用的能力之上。 ## 决定 -**在已有的传输层上加一条前缀路由,而不是加一项能力。** `client-connection` 持有两条面向浏览器的前缀:`/api` 承载 RPC,`/f/<sessionId>/<segments…>` 承载工作区文件读取。它本来就是持有 `httpServer`、`trustedHosts` 配置和浏览器信任 fence 的那个包;单开一个包会把 fence 和配置各复制一份,并逼着 `AppCLIEntry` 为一个 `--trusted-host` 标志去 patch 两行。webserver 自己的契约——每个特性面都是别的插件注册的一条路由——让这条路由本身就是全部机制。段落走路径而非查询参数,是为了让所服务文档的相对引用能解析到它的同级文件。 +**完成的一轮以它产出的文件收尾。** `turnDeliverables` 从改写工具自身的跟随文件 `locations` 中读出它们——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。该行渲染在收尾 assistant 正文之下、其 IconActions 之上,键控到 `assistantActionsSeqs` 早已选出的那个 seq。 -**请求指名 Session,由网关指名权限边界。** `ApiProxy.workspaceRootOf` 回答某个 Session 的文件位于何处——先看活跃 agent 的 `session.header.cwd`,再看持久化存储,绝不恢复会话——它是会话摘要早已携带的那个 `cwd` 的第二副面孔,只是不带信封。路由读取它而不是直接够 `ctx.agents`,因为 `client-connection` 注册在 client 程序里,而引入核心服务包会把它们 host 侧的 `sessions: SessionStore` 声明盖到浏览器运行时自己的 `sessions: SessionsService` 之上——这正是 `tsconfig.host.json`/`tsconfig.client.json` 分立所要防的那种冲突。cwd 与解析出的目标在前缀比较前都要过 `realpath`,因此工作区内指向工作区外的符号链接会因其目标而被拒绝;穿越写法在解析期就被拒,早于任何文件系统调用。读取经 `pipeline` 流出,因此客户端离开即销毁描述符,任何请求都不会把文件缓冲起来。 +**路径链接读得出是链接。** 静止状态下就带下划线,而不只在悬停时。这是本次改动中更小的那一半,却是修复中更大的那一半。 -**URL 形状落在 `dsh-host-apiproxy/api`,与其余浏览器可导入的契约面同处一地。** 两端必须就同一套编码达成一致,但客户端 bundle 不允许值导入另一个插件的包:`packages/client/tsdown.client.ts` 里的纯度 gate 只放行平台模块与 `INLINE_SAFE` 协议层,而 apiproxy 正是其中之一。把 `api/files.ts` 放在那里,才使构造 URL 的浏览器半侧与解析它的服务半侧共用单一来源,而且没有新增任何包依赖边——两侧本来就依赖 apiproxy。 +**打开仍然是 Host 的职责,并且优先选用默认浏览器。** `host.openPath` 把路径交给操作系统,得到的是真实浏览器里的一份 `file://` 文档:页面能力完整,且够不到 `/api`——因为 `file://` 文档与它并不同源。在所报告的那份产物上实测:`localStorage` 可用、主题切换生效、tabs 可切换,而对 API 的 `fetch` 失败。对浏览器能渲染的文档——`.html`、`.htm`、`.xhtml`、`.svg`——打开器解析的是默认**浏览器**而非该类型的默认应用,因为把 `.html` 绑给编辑器的开发者,否则点开一个产出的页面得到的会是源码。每个平台在自己能力范围内回答“哪个浏览器”(macOS 取 LaunchServices 的 `https` 处理程序,Linux 取 `$BROWSER`),任何一步失败都回退到默认应用,而不是把失败抛给用户。 -**工作区文件获得自己的端口,因而拥有自己的源。** 隔离这件事在落到此处之前走了三步。最初是加 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`,别无其他;并把端口注入首页供浏览器半侧寻址。 - -**客户端靠推导决定,而不是靠探测。** `ConnectionHandle.fileUrl(sessionId, cwd, path)` 把工具报告的路径表达为 session cwd 之下的段落,并返回工作区文件源上的绝对 URL——页面自身的主机名,加上已发布的端口——路径离开工作区或没有端口发布时返回 `undefined`。它落在 connection 句柄上,是因为传输层同时持有两端:提供字节的监听器,和寻址它的端口。`undefined` 恰好就是回退到 `openPath` 的信号,这也让无密钥 fixture 通道(不由任何 host 提供)退化为旧行为,而不是打开一个空标签页。 +**以 HTTP 提供工作区文件不在范围内,非本机客户端亦然。** 更早的一版由 harness 自己提供文件——先是与 `/api` 同源,随后加上 `CSP: sandbox`,再后来交给一个以自身端口给所服务文档独立源的第二监听器。每一步都在回答一个真实问题,而整条线在产品范围定下之后被整体退役:不为“浏览器不在 Host 机器上”的场景提供预览。这一点定下之后,Host 打开器完整回答了受支持的场景,而那套 HTTP 机制回答的只是不受支持的那个。 ## 考虑过的替代方案 -- **产物能力族(RFC #268 / PR #272)**——一条带 id、版本、快照存储、自有 HTTP 服务器、SSE 实时重载与浏览器自动打开器的 seam。它的评审给出了七个 critical,而每一个都来自那套机械结构:未监听的打开器 spawn 会让 harness 崩溃、打开器继承 `DEEPSEEK_API_KEY`、进行中的 publish 活过 dispose、`readFile` 先于大小上限、快照的 TOCTOU,以及未 dispose 的 agent 导致保留期泄漏。`dsh web` 本来就跑着一个 HTTP 服务器,用户本来就在浏览器里,那套机械结构在这里买不到任何东西。RFC 与其测试保留下来,作为真正出现跨会话或版本化产物需求那天的输入;届时这条路由就是那条 seam 的天然挂载点。 -- **单开一个 `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`**——针对“读走 SSE 流”这条向量经实测可行(Chrome 对 `window.open` 发 `Sec-Fetch-Dest: document`、对 `EventSource` 发 `empty`,回环也在内),但它从未触及更大的那条:真正够到配置面的是向 POST 方法发起的同源 `fetch`,而“从所服务文档一侧封住 `connect-src`”恰恰是敌意文档不会对自己做的事。 -- **保留 sandbox 并接受这条限制**——这笔交易的真实读数要量过才看得见:它不是“预览记不住主题”,而是“预览的整段脚本在第一次访问存储时就死了”,而页面照样渲染得完美无缺。一条没人看得见的限制,比一条要花掉一个端口的限制更糟。 -- **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带,产出文件行消费的正是它;因此把正文链接化是不必要,而不只是被推迟。 +- **由 harness 提供 `/f/<sessionId>/<segments…>`**——已经实现并可用,包含双 `realpath` 收敛、浏览器信任 fence、流式读取,以及一个以自身端口给所服务文档独立源的监听器。它是唯一能把预览呈现给另一台机器上客户端的设计,而那恰恰是被判出范围的场景。因此退役,而不是因为它失败了;它的代价是一个带自身生命周期的第二 socket、一个注入页面的端口,以及一份跨两个包共享的 URL 形状契约。 +- **同源 HTTP 提供且不加隔离**——经实测不安全,记录在此以免有人重试:与 `/api` 并排提供的文档把 `settings.describe` 打到 `200` 并拿到完整数据,把 `session.list` 打到 35 KB 的全部会话转录,而这个页面根本不必由 agent 撰写(一条 read 行就让 clone 下来的仓库里任何文件变得可打开)。 +- **在那套同源提供之上加 `Content-Security-Policy: sandbox`**——它以剥夺文档的源来堵住这个洞,而这经实测会破坏本功能存在的意义所在的那类页面:所报告的产物在加载时抛 `SecurityError`,又因为未捕获异常会中止其 `<script>` 的其余部分,该行之后声明的所有监听器——主题切换、移动端菜单、模型 tabs——统统不会绑定。报告者工作区里四份产物有两份在它之下是死页面,而且它们渲染得完美无缺,所以这种破坏是看不见的。 +- **把路径在助手的收尾消息里链接化**——这是用户开口要的形状(“在结尾附上链接”),但它让渲染取决于模型是否把路径拼写得可识别。工具调用已经把 `locations` 作为结构化事实携带,产出文件行消费的正是它。 +- **桌面端外壳中的内嵌 WebView**——可得到的最强隔离,因为那时预览跑在产品自己拥有的容器里,而不是用户的浏览器里。它属于桌面端外壳自身的设计,而非本交互面,记录在此作为未来预览能力应走的方向。 ## 影响 -现有的每一处文件交互都同时换了目标: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 内部的链接化,以及任何跨会话回看既往产物的视图。 +现有的每一处文件交互都同时改变了:write、edit、read 与通用单文件卡片都汇到 `openFile`,因此链接可见性修复与浏览器优先策略无需逐行改动即适用于全部。无密钥 web 通道(`apps/web/tests/produced-files.e2e.ts`)冷播种一段录制的 write 轮次,在组装后的应用中钉住该行;它刻意不点击,因为点击会把路径交给 Host 打开器,从而在跑测试的机器上启动一个真实应用。产出的文件以 `file://` 文档打开,它无法 `fetch` 自己的同级文件(一个加载 `./data.json` 的多文件产物会坏,而 `<script src>`、`<img>` 与 CSS `@import` 不受影响)——这是 HTTP 提供曾有、而此处没有的那一项能力。经网络访问的客户端点击后看不到任何东西:`host.openPath` 在 Host 上运行,且被 `/api` 信任 fence 钉在回环。那是范围决定的显现,不是缺陷,也正因如此该行把完整路径保留在 `title` 中,供只能复制它的读者使用。markdown 会由平台交给 `.md` 的默认处理程序打开,通常是编辑器而非渲染器;在产品内渲染它是另一个被推迟的交互面。 diff --git a/apps/web/tests/produced-files.e2e.ts b/apps/web/tests/produced-files.e2e.ts new file mode 100644 index 0000000000..c96131bf69 --- /dev/null +++ b/apps/web/tests/produced-files.e2e.ts @@ -0,0 +1,76 @@ +// Web e2e scenario: the produced-files row a finished turn ends with. Cold-seeds +// a recorded write turn (zero model calls). Package tests cover the derivation +// in isolation, but only the assembled application shows that a turn's writes +// reach the transcript as an openable row (docs/testing.md snapshot rule). The +// click itself is not driven here: it hands the path to the Host's opener, +// which would launch a real application on the machine running the suite. +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +// Borrowed read-only: this scenario needs any settled turn whose tools WROTE a +// file, not a new recording (the message-actions borrowing pattern). +const SEED = fileURLToPath(new URL('./snapshots/permission-policy-context/session.jsonl', import.meta.url)) +const MODE = webSnapshotMode() +const SEED_ID = 'produced-files-web-e2e' + +/** The file the borrowed recording's write tool produces. */ +const PRODUCED = 'policy-neutral.txt' + +describe('web e2e: a finished turn ends with the files it produced', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType<typeof watchConsole> + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + // The seeded Session's cwd is the scaffold workspace; the recording's own + // nested directory is created too, so its paths stay resolvable. + await mkdir(join(scaffold.workspaceCwd, 'workspace'), { recursive: true }) + await writeFile(join(scaffold.workspaceCwd, PRODUCED), 'neutral\n') + const raw = await readFile(SEED, 'utf8') + expect(raw, 'borrowed recording must carry the write this scenario reads').toContain(PRODUCED) + await seedSession(scaffold, raw, SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it.skipIf(MODE === 'record')('lists the written file under the closing message, as an opener', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-produced-files')) + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + + // The row the turn ends with — derived from the write call's locations, + // not from whatever the closing message happened to say. + const chip = page.getByRole('button', { name: `Open ${PRODUCED}`, exact: true }).first() + await chip.waitFor({ timeout: 15_000 }) + expect(await chip.innerText()).toBe(PRODUCED) + // The full path stays reachable for a reader who wants to copy it. + expect(await chip.getAttribute('title')).toContain(PRODUCED) + // A turn's produced files are labelled, not left as bare chips. + expect(await page.getByText('Produced', { exact: true }).count()).toBeGreaterThan(0) + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 90_000) +}) diff --git a/apps/web/tests/workspace-file-open.e2e.ts b/apps/web/tests/workspace-file-open.e2e.ts deleted file mode 100644 index 8f6bd6f665..0000000000 --- a/apps/web/tests/workspace-file-open.e2e.ts +++ /dev/null @@ -1,122 +0,0 @@ -// Web e2e scenario: a produced file, from the row that lists it to the bytes -// the browser gets. Cold-seeds a recorded write turn (zero model calls). -// Package tests cover the derivation and the route in isolation, but only the -// assembled application shows that the turn's Produced row, the URL it opens, -// and the file on disk are the same thing (docs/testing.md snapshot rule). -import { readFile, writeFile, mkdir } from 'node:fs/promises' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import type { Browser, Page } from 'playwright' -import { chromium } from 'playwright' -import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' -import { - launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, -} from './scaffold.ts' -import { newEnglishPage, saveFailureShot } from './support.ts' - -// Borrowed read-only: this scenario needs any settled turn whose tools WROTE a -// file, not a new recording (the message-actions borrowing pattern). -const SEED = fileURLToPath(new URL('./snapshots/permission-policy-context/session.jsonl', import.meta.url)) -const MODE = webSnapshotMode() -const SEED_ID = 'workspace-file-open-web-e2e' - -/** The file the borrowed recording's write tool produces. */ -const PRODUCED = 'policy-neutral.txt' -/** An active document placed alongside it, for the isolation header the route puts on those. */ -const ACTIVE = 'preview.html' - -describe('web e2e: opening a produced file from the conversation', () => { - let scaffold: WebScaffold - let browser: Browser - let page: Page - let tripwire: ReturnType<typeof watchConsole> - - beforeAll(async () => { - scaffold = await launchWebScaffold({}) - // The seeded Session's cwd is the scaffold workspace; the recording's own - // nested directory is created too, so its paths stay resolvable. - await mkdir(join(scaffold.workspaceCwd, 'workspace'), { recursive: true }) - await writeFile(join(scaffold.workspaceCwd, PRODUCED), 'neutral\n') - await writeFile(join(scaffold.workspaceCwd, ACTIVE), '<h1>produced</h1>\n') - const raw = await readFile(SEED, 'utf8') - expect(raw, 'borrowed recording must carry the write this scenario reads').toContain(PRODUCED) - await seedSession(scaffold, raw, SEED_ID) - browser = await chromium.launch() - page = await newEnglishPage(browser) - tripwire = watchConsole(page) - await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) - await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) - }, 120_000) - - afterAll(async () => { - await browser?.close() - await scaffold?.close() - }) - - it.skipIf(MODE === 'record')('ends the turn with its produced file, which opens as the workspace file itself', async () => { - onTestFailed(() => saveFailureShot(page, 'web-e2e-workspace-file-open')) - const groupRow = page.locator('[role="treeitem"]').first() - await groupRow.waitFor({ timeout: 15_000 }) - await groupRow.click() - const sessionRow = page.locator('[role="treeitem"]').nth(1) - await sessionRow.waitFor({ timeout: 10_000 }) - await sessionRow.click() - - // The row the turn ends with — derived from the write call's locations, - // not from whatever the closing message happened to say. - const chip = page.getByRole('button', { name: `Open ${PRODUCED}`, exact: true }).first() - await chip.waitFor({ timeout: 15_000 }) - expect(await chip.innerText()).toBe(PRODUCED) - - const [opened] = await Promise.all([ - page.context().waitForEvent('page', { timeout: 15_000 }), - chip.click(), - ]) - await opened.waitForLoadState('domcontentloaded') - 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() - - // 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. - expect((await page.request.get(`${filesOrigin}/f/${SEED_ID}/..%2Fetc%2Fhosts`)).status()).toBe(404) - - await active.close() - expect(tripwire.pageErrors).toEqual([]) - expect(tripwire.warnings).toEqual([]) - }, 90_000) -}) diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index 2c65f1e510..48db884aad 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -51,7 +51,7 @@ "tests/access-confirmation.e2e.ts", "tests/shipped-composition.e2e.ts", "tests/startup-auto-selection.e2e.ts", - "tests/workspace-file-open.e2e.ts" + "tests/produced-files.e2e.ts" ], "references": [ { diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 7c21713f01..a356a151e3 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -296,7 +296,7 @@ export interface ConnectionConfig { } ``` -Source: [`packages/client/connection/src/index.ts:25`](../packages/client/connection/src/index.ts) +Source: [`packages/client/connection/src/index.ts:20`](../packages/client/connection/src/index.ts) ## `@deepseek-ai/dsh-client-hmr` diff --git a/packages/client/connection/README.i18n.yaml b/packages/client/connection/README.i18n.yaml index af7d3d590d..682314605d 100644 --- a/packages/client/connection/README.i18n.yaml +++ b/packages/client/connection/README.i18n.yaml @@ -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: 5001da2458ea3470659f5983dffc8de832aadeac -README.zh.md: 47e745964e4087c6ccc59aae5bbfba69f96480e4 +README.md: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d +README.zh.md: f36cb4c4c6856089751e4492eb6e4b8e22abde56 diff --git a/packages/client/connection/README.md b/packages/client/connection/README.md index 5001da2458..c8b7c4787c 100644 --- a/packages/client/connection/README.md +++ b/packages/client/connection/README.md @@ -2,22 +2,14 @@ English | [中文](README.zh.md) -Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half owns both browser-facing prefixes — `/api` for RPC and `/f` for workspace-file reads — behind one trust fence. The `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. +Wire consumer layer: the client plugin's apply mounts `ctx.connection` (shared api client + single-consumer stream-loop starter); the export face carries the wire contract types, the `AbstractApiClient` seam, and the loop's sink/config types. The node half's `/api` route pins the privileged method set (`host.pickDirectory`, `host.openPath`, and the whole configuration plane — `settings.describe`/`update`/`replace`/`mutate` and `credentials.describe`/`set`/`unset`, reads included, since describing returns the exposed configuration and probing an arbitrary reference reports where a credential comes from) to loopback by passing the trust fence with an empty trust list — a declared `trustedHosts` authority reaches every other method, while these stay loopback-local until a real authentication layer exists. The platform subclasses (WebApiClient/FixtureApiClient), the ConnectionController loop, and the fixture data source are package-internal — apply selects and drives them; tests reach them via src. Contract: api-contracts v3 §3. ## /api browser-trust fence The node half guards every request under `/api` before bridging (`src/api-request-trust.ts`). Every request — browser-marked or not — must present a `Host` that is a loopback authority or matches a `trustedHosts` entry: exact on `host:port` entries, any port on port-less entries, both sides compared through WHATWG normalization (DNS-rebinding defense). There is deliberately no shortcut for requests without browser markers: over plain HTTP a browser attaches neither `Origin` nor Fetch-Metadata to reads (EventSource, images, navigations — those headers go only to trustworthy destinations), so an unmarked request may still be a rebound browser read with a readable response, and Host is the one header rebinding cannot forge; non-browser clients pass the same fence via loopback, the CLI-derived LAN IP literals, or a declared authority. When markers are present, an attached `Origin` must equal the Host authority, and an explicit `sec-fetch-site: cross-site` marker is refused. A `trustedHosts` entry that is not a bare, canonical `host[:port]` authority — one WHATWG parsing reads back exactly as written — fails the plugin load loudly: parsing would otherwise quietly authorize the hostname inside `harness.internal/path`, or broaden a dangling-colon or zero-padded port to an any-port grant. Failures answer plain 403 before any RPC dispatch. A non-loopback (`--host 0.0.0.0`) deployment therefore needs its serving authorities trusted: the dsh CLI derives the machine's LAN IP literals itself and its `--trusted-host` flag declares named ones, so `trustedHosts` in cordis.yml is for compositions the CLI does not boot. The fence is deliberately not an authentication layer — reachability policy stays with the webserver binding, and auth remains deferred work. Decision record: [the api browser-trust boundary Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md). -## /f workspace-file reads - -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. - -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 -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. ## Model Experience diff --git a/packages/client/connection/README.zh.md b/packages/client/connection/README.zh.md index 47e745964e..f36cb4c4c6 100644 --- a/packages/client/connection/README.zh.md +++ b/packages/client/connection/README.zh.md @@ -2,18 +2,12 @@ [English](README.md) | 中文 -协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧持有两条面向浏览器的前缀——`/api` 承载 RPC,`/f` 承载工作区文件读取——共用同一道信任 fence。`/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 +协议消费层:客户端插件的 apply 会挂载 `ctx.connection`(共享 API 客户端 + 单消费方流循环启动器);导出表层携带协议契约类型、`AbstractApiClient` seam,以及循环的 sink/配置类型。node 半侧的 `/api` 路由让特权方法集(`host.pickDirectory`、`host.openPath`,以及整个配置面——`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`,读取也在内,因为 describe 会返回已暴露的配置,而探测任意引用会报出某条凭据来自何处)以空信任表过信任 fence,从而钉在回环——已声明的 `trustedHosts` 授权可达其余全部方法,而这些方法在真正的认证层出现之前仍只限回环本机。平台子类(WebApiClient/FixtureApiClient)、ConnectionController 循环和 fixture 数据源都属于包内部:apply 负责选择并驱动它们,测试则通过 src 访问。契约:api-contracts v3 §3。 ## /api 浏览器信任栅栏 node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust.ts`)。每个请求——无论是否带浏览器标记——`Host` 都必须是回环地址权威,或与某个 `trustedHosts` 条目匹配:带端口的 `host:port` 条目精确匹配,不带端口的条目匹配任意端口,两侧均经 WHATWG 归一化后比较(DNS rebinding 防御)。刻意不为无浏览器标记的请求开捷径:明文 HTTP 下浏览器的读取(EventSource、图片、导航——这些头只发给可信目标)既不带 `Origin` 也不带 Fetch-Metadata,因此无标记请求仍可能是被重绑页面发起的、响应可被读走的读取,而 Host 是重绑唯一伪造不了的请求头;非浏览器客户端经由回环地址、CLI 推导的 LAN IP 字面量或已声明的权威通过同一道栅栏。当标记存在时,`Origin` 必须与 Host 权威完全一致;显式的 `sec-fetch-site: cross-site` 标记一律拒绝。不是纯的、规范形 `host[:port]` 权威的 `trustedHosts` 条目——即 WHATWG 解析读回后与原文不完全一致的——会让插件加载大声失败:否则解析会悄悄授权 `harness.internal/path` 这类笔误里的 hostname,或把悬空冒号、补零端口放大成任意端口授权。失败在任何 RPC 分发之前以纯 403 应答。因此非回环(`--host 0.0.0.0`)部署需要让自己的服务权威被信任:dsh CLI 会自行推导本机的 LAN IP 字面量,其 `--trusted-host` flag 用于声明具名权威,所以 cordis.yml 中的 `trustedHosts` 面向 CLI 不参与引导的组合。这道栅栏刻意不承担认证职责——可达性策略归 webserver 绑定配置,认证仍是延期工作。决策记录:[api 浏览器信任边界 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-28-api-browser-trust-boundary.md)。 -## /f 工作区文件读取 - -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` 定型而非作为下载给出,因为工作区读取本就是一个“让我看看这个文件”的请求。 - -工作区文件由它自己的端口提供,因而拥有自己的源。那个端口就是隔离:工作区文件未必由 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 页面不由任何 host 提供,因此没有工作区文件端口注入其中,`ConnectionHandle.fileUrl` 应答 `undefined`——文件路径行会回退到 Host 打开器,而不是打开一个空标签页。 diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index f5fa3e34ea..0549fc1160 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2363,10 +2363,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { return Promise.resolve({ accepted: true }) }, - // The fixture has no filesystem behind its Sessions, so it names no - // directory for any of them; the /f route belongs to the node half, which - // a fixture page never reaches. - workspaceRootOf: () => Promise.resolve(undefined), } } diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 004b9fe286..e286157e46 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -4,9 +4,6 @@ * 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' @@ -59,19 +56,6 @@ 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 } /** @@ -84,15 +68,6 @@ 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 diff --git a/packages/client/connection/src/files-server.ts b/packages/client/connection/src/files-server.ts deleted file mode 100644 index 73807c0810..0000000000 --- a/packages/client/connection/src/files-server.ts +++ /dev/null @@ -1,127 +0,0 @@ -/** - * 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}` -} diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index f0a60bbfb9..03f8aaa257 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -1,16 +1,11 @@ -/** Host HTTP bridge for browser-client RPC and workspace-file reads. */ +/** Host HTTP bridge for browser-client RPC. */ import type { Context } from 'cordis' 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' -// 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 { injectFilesPort, listenForWorkspaceFiles } from './files-server.ts' import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts' export { API_PATH } from './api-path.ts' @@ -66,17 +61,15 @@ const PRIVILEGED_METHODS = new Set([ ]) /** - * Mounts the API gateway and the workspace-file reads under the browser - * transport prefixes. Every request on either prefix passes the browser-trust - * fence first (DNS-rebinding and cross-site defense — - * [api-request-trust](./api-request-trust.ts)); privileged methods - * additionally pass it with an empty trust list, which pins them to loopback. + * Mounts the API gateway under the browser transport prefix. Every request on + * the prefix passes the browser-trust fence first (DNS-rebinding and + * cross-site defense — [api-request-trust](./api-request-trust.ts)); + * privileged methods 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 async function apply(ctx: Context, config?: ConnectionConfig): Promise<void> { +export function apply(ctx: Context, config?: ConnectionConfig): 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 @@ -104,24 +97,4 @@ export async function apply(ctx: Context, config?: ConnectionConfig): Promise<vo } ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') - // The gateway is the host's session authority: it answers where a Session's - // files live without this package reaching into the core services, which - // would merge their host-side Context declarations into the browser lane. - const cwdFor = (sessionId: string): Promise<string | undefined> => - ctx.apiProxy.workspaceRootOf(sessionId as SessionId) - // 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') } diff --git a/packages/client/connection/src/workspace-files.ts b/packages/client/connection/src/workspace-files.ts deleted file mode 100644 index c934b14516..0000000000 --- a/packages/client/connection/src/workspace-files.ts +++ /dev/null @@ -1,164 +0,0 @@ -/** - * The read half of the web transport: streams one file out of a session's - * workspace so the browser can open what the agent just produced. The RPC - * gateway carries structured session state; this route carries bytes, which a - * JSON-RPC envelope cannot stream and a `file://` link cannot reach from an - * http page. - * - * Confinement is the whole contract: a request names a session, the session - * names its cwd, and nothing outside that realpath is ever served. The caller - * owns the browser-trust fence ([api-request-trust](./api-request-trust.ts)) — - * this module is reached only by requests that already passed it. - * - * 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' -import { realpath, stat } from 'node:fs/promises' -import type { IncomingMessage, ServerResponse } from 'node:http' -import { extname, resolve, sep } from 'node:path' -import { pipeline } from 'node:stream/promises' -import { parseWorkspaceFilePath } from '@deepseek-ai/dsh-host-apiproxy/api' - -/** - * Content types served verbatim. Everything absent is `text/plain`, not - * `application/octet-stream`: a workspace read is a "show me what you made" - * gesture, and an unknown extension is far more often a source file to read - * than a binary to download. `nosniff` keeps that choice binding, so a - * mislabelled document can never be re-interpreted as HTML. - */ -const MIME: Record<string, string> = { - '.html': 'text/html; charset=utf-8', - '.htm': 'text/html; charset=utf-8', - '.xhtml': 'application/xhtml+xml', - '.svg': 'image/svg+xml', - '.css': 'text/css; charset=utf-8', - '.js': 'text/javascript; charset=utf-8', - '.mjs': 'text/javascript; charset=utf-8', - '.json': 'application/json', - '.pdf': 'application/pdf', - '.png': 'image/png', - '.jpg': 'image/jpeg', - '.jpeg': 'image/jpeg', - '.gif': 'image/gif', - '.webp': 'image/webp', - '.avif': 'image/avif', - '.ico': 'image/x-icon', - '.mp4': 'video/mp4', - '.webm': 'video/webm', - '.mp3': 'audio/mpeg', - '.wav': 'audio/wav', - '.wasm': 'application/wasm', -} - -const DEFAULT_MIME = 'text/plain; charset=utf-8' - -/** How the route learns which directory a session may serve from. */ -export interface WorkspaceFileDeps { - /** - * The session's absolute working directory. - * @param sessionId - the session named by the request path. - * @returns its cwd, or `undefined` when the id names no session this host serves. - */ - cwdFor: (sessionId: string) => Promise<string | undefined> -} - -function fail(res: ServerResponse, status: number): void { - res.writeHead(status) - res.end() -} - -/** - * Resolve one request's segments against a session cwd, refusing anything that - * leaves it. Both sides go through `realpath`, so a symlink inside the - * workspace pointing out of it is refused by its resolved target rather than - * its name. A component swapped between this resolution and the open below - * would still be followed; closing that window needs privileges that already - * imply workspace write access, which is strictly stronger than reading a - * workspace file, so the check stops here. - */ -async function confine(cwd: string, segments: readonly string[]): Promise<string | undefined> { - const root = await realpath(cwd) - // A filesystem root already ends in the separator; appending a second one - // would make every child fail the prefix test and 403 the whole workspace. - const prefix = root.endsWith(sep) ? root : root + sep - const real = await realpath(resolve(root, ...segments)) - return real.startsWith(prefix) ? real : undefined -} - -/** - * Serve one workspace-file request. The caller has already applied the - * browser-trust fence and rejected non-read methods. - * @param req - the request, read for its url and method only (no body). - * @param res - the response this function owns to completion. - * @param deps - the session-to-cwd lookup this host answers with. - */ -export async function handleWorkspaceFile( - req: IncomingMessage, - res: ServerResponse, - deps: WorkspaceFileDeps, -): Promise<void> { - /* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */ - const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname - const target = parseWorkspaceFilePath(pathname) - if (target === undefined) { - fail(res, 404) - return - } - const cwd = await deps.cwdFor(target.sessionId) - if (cwd === undefined) { - fail(res, 404) - return - } - - let file: string | undefined - let size: number - try { - file = await confine(cwd, target.segments) - if (file === undefined) { - fail(res, 403) - return - } - const info = await stat(file) - // A directory read has no answer here: the route serves files, and listing - // is the directory-picker capability's job, behind its own fence. - if (!info.isFile()) { - fail(res, 404) - return - } - size = info.size - } catch { - // Missing, unreadable, or a path whose ancestor is not a directory: all - // report as absent, so a probe cannot distinguish them. - fail(res, 404) - return - } - - const ext = extname(file).toLowerCase() - res.writeHead(200, { - 'content-type': MIME[ext] ?? DEFAULT_MIME, - 'content-length': String(size), - 'content-disposition': 'inline', - 'x-content-type-options': 'nosniff', - // 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', - }) - if (req.method === 'HEAD') { - res.end() - return - } - try { - // pipeline (not pipe) so a client disconnect destroys the read stream: - // an abandoned preview must not leave a descriptor open. - await pipeline(createReadStream(file), res) - } catch { - // The status line is already out, so a mid-stream read failure or client - // disconnect can only end the response abruptly. - res.destroy() - } -} diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 4b323182bb..f9fe1c1b71 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -8,11 +8,10 @@ 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; protocol?: string; hostname?: string }; __DSH_FILES_PORT__?: number } +type Win = { location?: { search: string } } afterEach(() => { delete (globalThis as Win).location - delete (globalThis as Win).__DSH_FILES_PORT__ }) async function mount(): Promise<ConnectionHandle> { @@ -64,27 +63,4 @@ 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() - }) }) diff --git a/packages/client/connection/tests/files-server.spec.ts b/packages/client/connection/tests/files-server.spec.ts deleted file mode 100644 index 4a2618709b..0000000000 --- a/packages/client/connection/tests/files-server.spec.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** 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', 4321) - expect(html).toContain('') - // Ahead of anything the shell might read it from. - expect(html.indexOf('__DSH_FILES_PORT__')).toBeLessThan(html.indexOf('')) - }) -}) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 2561a0846f..216484ad67 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -1,9 +1,6 @@ -/** Node half: registers the /api and /f prefix routes over the api gateway and the session workspaces. */ +/** Node half: registers the /api prefix route bridging to the api gateway. */ import { EventEmitter } from 'node:events' import { createServer, request as httpRequest } from 'node:http' -import { mkdtemp, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' import { Readable } from 'node:stream' import { Context } from 'cordis' import { describe, expect, it } from 'vitest' @@ -11,25 +8,17 @@ import type { AddressInfo } from 'node:net' import type { IncomingMessage, ServerResponse } from 'node:http' import type { ApiProxy } from '@deepseek-ai/dsh-host-apiproxy/api' import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' -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[], - taps: ((html: string) => string)[] = [], -): Pick<HttpServerService, 'register' | 'tapIndex' | 'port' | 'host'> { +function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> { return { register(route) { routes.push(route) return () => { routes.splice(routes.indexOf(route), 1) } }, - tapIndex(transform) { - taps.push(transform) - return () => { taps.splice(taps.indexOf(transform), 1) } - }, + tapIndex: () => () => {}, port: 0, - host: '127.0.0.1', } } @@ -60,47 +49,14 @@ function fakeResponse(): { response: ServerResponse; state: { status?: number; b return { response, state } } -/** The gateway stub: only the session-directory authority the /f route reads. */ -function fakeApiProxy(workspaces: Record<string, string> = {}): ApiProxy { - return { workspaceRootOf: async (id: string) => workspaces[id] } as unknown as ApiProxy -} - -async function mounted( - config?: { trustedHosts?: string[] }, - workspaces: Record<string, string> = {}, -): Promise<{ routes: WebRoute[]; taps: ((html: string) => string)[]; dispose: () => Promise<void> }> { +async function mounted(config?: { trustedHosts?: string[] }): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> { const ctx = new Context() const routes: WebRoute[] = [] - const taps: ((html: string) => string)[] = [] - ctx.provide('httpServer', fakeHttpServer(routes, taps) as HttpServerService) - ctx.provide('apiProxy', fakeApiProxy(workspaces)) + ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) + ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, config) await fiber.await() - return { routes, taps, dispose: () => fiber.dispose() } -} - -/** 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}` + return { routes, dispose: () => fiber.dispose() } } describe('connection node half', () => { @@ -108,25 +64,17 @@ describe('connection node half', () => { const routes: WebRoute[] = [] const ctx = new Context() ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService) - ctx.provide('apiProxy', fakeApiProxy()) + ctx.provide('apiProxy', {} as unknown as ApiProxy) const fiber = ctx.plugin({ inject: [...inject], apply }, { trustedHosts: ['harness.internal/path'] }) await expect(fiber).rejects.toThrow(/not a bare host\[:port\] authority/) expect(routes).toHaveLength(0) }) - 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. + it('registers the /api prefix route and removes it with the fiber', async () => { + const { routes, dispose } = await mounted() 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 () => { @@ -187,48 +135,6 @@ describe('connection node half', () => { }) }) -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-')) - await writeFile(join(cwd, 'index.html'), '<h1>ok</h1>') - return { cwd, remove: () => rm(cwd, { recursive: true, force: true }) } - } - - 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 { 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. - expect((await fetch(`${origin}${FILES_PATH}/s-absent/index.html`)).status).toBe(404) - await dispose() - await remove() - }) -}) - describe('connection node half over a real HTTP server', () => { /** Serve the registered prefix route from a real server and return its port. */ async function serve(routes: WebRoute[]): Promise<{ port: number; close: () => Promise<void> }> { diff --git a/packages/client/connection/tests/workspace-files.spec.ts b/packages/client/connection/tests/workspace-files.spec.ts deleted file mode 100644 index 8e33a6751b..0000000000 --- a/packages/client/connection/tests/workspace-files.spec.ts +++ /dev/null @@ -1,142 +0,0 @@ -/** - * Workspace-file reads over a real HTTP server and a real temporary - * workspace: confinement, content typing, and the sandbox header are wire - * facts, so they are asserted against responses Node actually produced. - */ -import { createServer } from 'node:http' -import type { AddressInfo } from 'node:net' -import type { ServerResponse } from 'node:http' -import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join, sep } from 'node:path' -import { Writable } from 'node:stream' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api' -import { handleWorkspaceFile } from '../src/workspace-files.ts' - -const SESSION = 's-1' - -let workspace: string -let outside: string -let origin: string -let close: () => Promise<void> - -beforeAll(async () => { - const root = await mkdtemp(join(tmpdir(), 'dsh-files-')) - workspace = join(root, 'workspace') - outside = join(root, 'outside') - await mkdir(join(workspace, 'out'), { recursive: true }) - await mkdir(outside, { recursive: true }) - await writeFile(join(workspace, 'index.html'), '<h1>产物</h1>') - await writeFile(join(workspace, 'notes.txt'), 'plain') - await writeFile(join(workspace, 'chart.svg'), '<svg xmlns="http://www.w3.org/2000/svg"/>') - await writeFile(join(workspace, 'model.safetensors'), 'unknown extension') - await writeFile(join(workspace, 'out', 'page.html'), '<p>nested</p>') - await writeFile(join(outside, 'secret.html'), 'SECRET') - await symlink(join(outside, 'secret.html'), join(workspace, 'escape.html')) - - const server = createServer((req, res) => { - void handleWorkspaceFile(req, res, { - // 'rooted' names the filesystem root, the separator-terminated realpath case. - cwdFor: async sessionId => sessionId === SESSION ? workspace : sessionId === 'rooted' ? sep : undefined, - }) - }) - await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve)) - origin = `http://127.0.0.1:${String((server.address() as AddressInfo).port)}` - close = () => new Promise<void>((resolve, reject) => { - server.close((error) => { - if (error === undefined || error === null) resolve() - else reject(error) - }) - }) - return async () => { await rm(root, { recursive: true, force: true }) } -}) - -afterAll(async () => { await close() }) - -function get(path: string, init?: RequestInit): Promise<Response> { - return fetch(`${origin}${path}`, init) -} - -describe('workspace file reads', () => { - 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') - // 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('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('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') - }) - - it('serves a workspace rooted at a filesystem root, whose realpath already ends in a separator', async () => { - // `realpath('/')` is '/', so a naive `root + sep` prefix is '//' and every - // child of that workspace would 403. - const rooted = await fetch(`${origin}${FILES_PATH}/rooted${new URL(`file://${workspace}/notes.txt`).pathname}`) - expect(rooted.status).toBe(200) - expect(await rooted.text()).toBe('plain') - }) - - it('shows an unknown extension as text rather than downloading it', async () => { - const response = await get(`${FILES_PATH}/${SESSION}/model.safetensors`) - expect(response.status).toBe(200) - expect(response.headers.get('content-type')).toBe('text/plain; charset=utf-8') - }) - - it('serves a nested path, so a document reaches its own siblings', async () => { - const response = await get(`${FILES_PATH}/${SESSION}/out/page.html`) - expect(response.status).toBe(200) - expect(await response.text()).toBe('<p>nested</p>') - }) - - it('answers HEAD with the length and no body', async () => { - const response = await get(`${FILES_PATH}/${SESSION}/notes.txt`, { method: 'HEAD' }) - expect(response.status).toBe(200) - expect(response.headers.get('content-length')).toBe('5') - expect(await response.text()).toBe('') - }) - - it('refuses a symlink whose target leaves the workspace', async () => { - const response = await get(`${FILES_PATH}/${SESSION}/escape.html`) - expect(response.status).toBe(403) - expect(await response.text()).not.toContain('SECRET') - }) - - it('reports missing files, directories, and unknown sessions as absent', async () => { - expect((await get(`${FILES_PATH}/${SESSION}/nope.html`)).status).toBe(404) - expect((await get(`${FILES_PATH}/${SESSION}/out`)).status).toBe(404) - // A path whose ancestor is a file, not a directory. - expect((await get(`${FILES_PATH}/${SESSION}/notes.txt/child`)).status).toBe(404) - expect((await get(`${FILES_PATH}/s-other/index.html`)).status).toBe(404) - expect((await get(`${FILES_PATH}/${SESSION}`)).status).toBe(404) - }) -}) - -describe('workspace file streaming failures', () => { - it('tears the response down instead of rejecting when the body cannot be written', async () => { - // A client that goes away mid-stream must not surface as a handler - // rejection: the webserver's last-resort guard would log it and try to - // answer 400 on a response whose status line is already out. - const sink = new Writable({ - write(_chunk, _encoding, callback) { callback(new Error('socket gone')) }, - }) - const response = Object.assign(sink, { writeHead: () => response }) as unknown as ServerResponse - await expect(handleWorkspaceFile( - { url: `${FILES_PATH}/${SESSION}/index.html`, method: 'GET', headers: {} } as never, - response, - { cwdFor: async () => workspace }, - )).resolves.toBeUndefined() - expect(sink.destroyed).toBe(true) - }) -}) diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index a5827173a8..d389efe319 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -26,7 +26,6 @@ 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 } } diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index a35983d890..fd7858d60c 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -20,7 +20,6 @@ async function mount(): Promise<Bench> { const bench: Bench = { ctx, sinks: undefined } const handle: ConnectionHandle = { api, - fileUrl: () => undefined, start: (sinks) => { bench.sinks = sinks return { stop: () => {} } diff --git a/packages/client/test-runtime/package.json b/packages/client/test-runtime/package.json index 6d7093a148..e892d9cd52 100644 --- a/packages/client/test-runtime/package.json +++ b/packages/client/test-runtime/package.json @@ -25,7 +25,6 @@ "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", @@ -36,7 +35,6 @@ "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:^", diff --git a/packages/client/test-runtime/src/connection.ts b/packages/client/test-runtime/src/connection.ts deleted file mode 100644 index 5df5d5a053..0000000000 --- a/packages/client/test-runtime/src/connection.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** 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)}` - } -} diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index cdbdca75a2..5ef5350434 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -29,13 +29,11 @@ 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' @@ -177,8 +175,6 @@ 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() }) @@ -199,10 +195,8 @@ 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() diff --git a/packages/client/test-runtime/tsconfig.json b/packages/client/test-runtime/tsconfig.json index 681bff474c..6a758c66f9 100644 --- a/packages/client/test-runtime/tsconfig.json +++ b/packages/client/test-runtime/tsconfig.json @@ -17,9 +17,6 @@ { "path": "../web-react" }, - { - "path": "../connection" - }, { "path": "../runtime" }, diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 446c2084b1..e85aa2ca30 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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: 8c2075d615eccad1bbc7f5de1255ea4add69fab8 -README.zh.md: 634721b4248da75cbd4e81528340936a31ece28d +README.md: a9d4aadf4b0acc21f3909319724645c10f08bd31 +README.zh.md: 16be57c9ed8f8101a61eb704ba5e94491803d9b5 diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 8c2075d615..a9d4aadf4b 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -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 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. +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 an underlined link — it reads as one at rest, not only on hover, because a path styled like the surrounding prose is an affordance nobody finds — and it opens the file through the Host (`host.openPath`, relative paths resolve against the session cwd). A document a browser renders opens in the default browser rather than the type's default application, so a produced page is shown rather than edited. The Host opens it on the Host's own machine: a client reached over a network sees nothing, which is the deliberate scope of this surface. 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)). diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 634721b424..16be57c9ed 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -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 图标和路径摘要;该路径是悬停下划线链接,点击即打开文件:位于会话工作区之内的文件在新浏览器标签页打开,位于传输层的工作区文件源上(`ConnectionHandle.fileUrl`),因此不在 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 图标和路径摘要;该路径是带下划线的链接——静止状态下就读得出是链接,而不只在悬停时,因为一条与周围正文同样样式的路径是没人会发现的交互——点击即经由 Host 打开文件(`host.openPath`,相对路径相对会话 cwd 解析)。浏览器能渲染的文档会用默认浏览器打开,而不是该类型的默认应用,因此产出的页面是被展示而不是被编辑。Host 在它自己的机器上打开:经网络访问的客户端看不到任何东西,这是本交互面刻意划定的范围。工具行不再是整行点击目标,也不会打开 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))。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 55036600c6..88c09b5550 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -39,7 +39,6 @@ "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", @@ -51,7 +50,6 @@ "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:^", diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index a65ce1d7a4..c67431e409 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -2,7 +2,6 @@ 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' @@ -43,7 +42,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { } /** Services required by the conversation plugin. */ -export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection'] +export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale'] // Static no-session sources for the composer-bar hooks compartment: module // constants so the render side's per-source hook cache (observableHook) keeps @@ -276,16 +275,6 @@ 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 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 - } void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => { // Host/OS open failures stay silent in the chat row; the native // app surfaces its own error dialog when the path is unusable. diff --git a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css index 81e41b066f..3e4fda9ebe 100644 --- a/packages/client/ui-conversation/src/client/chat/ToolRow.module.css +++ b/packages/client/ui-conversation/src/client/chat/ToolRow.module.css @@ -84,7 +84,10 @@ color: var(--dsw-alias-label-tertiary); } -/* File-tool path: same geometry as .summary; hover underline + pointer. */ +/* File-tool path: same geometry as .summary, but it must READ as a link. A + path styled exactly like the surrounding prose, underlined only on hover, is + an affordance nobody finds — the reported "I can't open what it made" was + this, not a missing capability. */ .fileLink { flex: 1 1 auto; min-width: 0; @@ -99,12 +102,16 @@ text-align: left; font-size: 14px; line-height: 24px; - color: var(--dsw-alias-label-tertiary); + color: var(--dsw-alias-label-secondary); + text-decoration: underline; + text-decoration-color: var(--dsw-alias-label-quaternary); + text-underline-offset: 3px; cursor: pointer; } .fileLink:hover { - text-decoration: underline; + color: var(--dsw-alias-label-primary); + text-decoration-color: currentColor; } /* Error row's collapsed summary: the failure's first line in the error color. */ diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index b9dbe0d6ad..cfbb0fdcbe 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -218,25 +218,13 @@ describe('conversation slot inject surface', () => { await b.runtime.dispose() }) - it('openFile (chat view face) opens a workspace file in a tab and falls back to the host opener outside it', async () => { + it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', 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 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(`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. - injected.openFile('/etc/hosts') await vi.waitFor(() => { - expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/etc/hosts'] }) + expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] }) }) - open.mockRestore() await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx index 2763702c0b..bafc6fe709 100644 --- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx @@ -136,9 +136,6 @@ async function bench(snapshot: ConversationSnapshot) { openPath: vi.fn(async () => {}), } 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) @@ -246,14 +243,12 @@ describe('run_code sub-calls through the real chat machinery', () => { subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'), ]]]) const b = await bench(snapshotWith([codeResult(10, parent)], dispatches)) - const open = vi.spyOn(window, 'open').mockReturnValue(null) const view = mountApp(b.slots) view.getByText('notes/demo.txt').click() expect(b.layout.openDetails).not.toHaveBeenCalled() await vi.waitFor(() => { - expect(open).toHaveBeenCalledWith('http://localhost:4321/f/s-1/notes/demo.txt', '_blank', 'noopener,noreferrer') + expect(b.workspaces.openPath).toHaveBeenCalledWith('notes/demo.txt') }) - open.mockRestore() view.getByText('List notes').click() expect(b.layout.openDetails).not.toHaveBeenCalled() }) diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx index 84cdd53eeb..eb48677d4f 100644 --- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx @@ -119,17 +119,14 @@ describe('keyed toolview hole through the real machinery', () => { await b.runtime.dispose() }) - it('file-path clicks travel owner openFile → chat inject → the served workspace URL', async () => { + it('file-path clicks travel owner openFile → chat inject → workspaces.openPath', 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() expect(b.layout.openDetails).not.toHaveBeenCalled() await vi.waitFor(() => { - expect(open).toHaveBeenCalledWith(expect.stringContaining('/src/a.ts'), '_blank', 'noopener,noreferrer') + expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['src/a.ts'] }) }) - open.mockRestore() await b.runtime.dispose() }) diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json index 33d45124b4..04b265bdd5 100644 --- a/packages/client/ui-conversation/tsconfig.json +++ b/packages/client/ui-conversation/tsconfig.json @@ -20,9 +20,6 @@ { "path": "../web-react" }, - { - "path": "../connection" - }, { "path": "../runtime" }, diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index b96bf528a7..27a1434e60 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -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/host/apiproxy/README.md -README.md: ee8e758a68f6efa3e363a36fcc9e8444e589ea40 -README.zh.md: 4ec3817e65543d6e248be9d902d0b74674f56e5a +README.md: 3c5a83a468b0cf9e596b8b13fafe40c409576fc5 +README.zh.md: f8533564575bf6b716f3fa7241ce47b8d4dd435f diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index ee8e758a68..3c5a83a468 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -36,8 +36,6 @@ The `command.*` and `skill.*` domains expose the host command registry and skill The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-page wire. The settings domain serves the namespaces addressed by registered configurable providers (`ctx.llm.listConfigurableProviders()`) plus a small explicit allowlist — the Web preference `permission` and the product-owned `ui-onboarding`; adding a Settings registration alone never makes it remotely readable or writable. Any other namespace answers `settings-not-exposed` — the same answer an unregistered namespace gets, so no caller can enumerate the registry by probing. `settings.describe` returns each exposed namespace's serialized schemastery schema, redacted layered values (resolved/`base`/`user` — a field's presence in `user` marks it user-overridden), the `secrets` slot list, and the section's `revision`. `settings.update`/`settings.replace` write the user layer; `settings.mutate` applies path ops (`set`/`unset`) against the section as stored, which is the removal path for a client holding the redacted view — rebuilding a section from it and replacing wholesale would delete the secrets the wire never returned. Any write may carry `expectedRevision`; a stale one answers `settings-conflict` with both revisions rather than overwriting the writer that landed first, and every other seam refusal folds into `settings-rejected`. Secret-role values never ride any response in any layer; a secret crosses the wire in exactly one direction — inside an `update`/`mutate` payload or `credentials.set`. `credentials.describe` returns value-free views (`configured`/`source`/`writable`), and `credentials.set`/`credentials.unset` map a shadowed-reference refusal onto `credential-rejected`. `llm.providers` merges the configurable-provider directory with live routes (dormant entries carry `active: false`; undeclared live routes append with no settings address) and `llm.models` is the session-independent catalog. Three invalidation frames keep every surface converged without polling: `host/settings-changed {ns}` (`settings/document-updated` passthrough, so a raw change whose resolved value is unchanged still reaches clients), `host/credentials-changed {ref}` (reference names only, never values), and `host/models-changed` — fired by `llm/adapters-updated` and by a change to a configurable-provider namespace, whose settings carry that provider's catalog and endpoint; a `permission` or `ui-onboarding` change emits only its settings invalidation. The browser carrier restricts the whole configuration plane, reads included (`settings.describe`/`update`/`replace`/`mutate`, `credentials.describe`/`set`/`unset`), to loopback same-origin requests — the `host.pickDirectory` privileged set. A composition without a settings or credential provider answers those domains with an actionable `internal` error naming the missing plugin. -Two members of `ApiProxy` are deliberately not wire methods. `respond` is the client-response entry (four-quadrant model), and `workspaceRootOf` answers where a Session's files live for an in-process reader — a live agent's header first, then the persistence store, never a resume. It has no wire face: a browser learns a Session's cwd from `sessions.view`, and reaches a file through the web transport's own `/f` route, never by asking for a host path. That route's URL shape (`api/files.ts`: `FILES_PATH`, `workspaceFileSegments`, `workspaceFileUrl`, `parseWorkspaceFilePath`) lives here with the other browser-importable contract surfaces, so the browser half that builds a `/f` URL and the serving half that parses one cannot drift apart; the route itself belongs to [`dsh-client-connection`](../../client/connection/README.md). - ## Carrier layer (`/client` + root) `AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` is the isomorphic point: the full wire serialization/validation path with no network, used by `dsh -p` headless. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index 4ec3817e65..f853356457 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -36,8 +36,6 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr `settings.*`、`credentials.*` 与 `llm.*` 领域是配置页协议。settings 领域服务于已注册可配置提供方所指向的 namespace(`ctx.llm.listConfigurableProviders()`),并额外服务于一份小型、显式的 allowlist——Web 偏好 `permission` 与产品持有的 `ui-onboarding`;仅新增一项 Settings 注册,绝不会使其可被远程读取或写入。其他任何 namespace 都只会得到 `settings-not-exposed`——未注册的 namespace 得到的是同一个答复,因此没有调用方能靠逐个探测把注册表枚举出来。`settings.describe` 为每个已暴露 namespace 提供其序列化 schemastery schema、脱敏后的分层值(resolved/`base`/`user`——字段出现在 `user` 中即标记其被用户覆盖)、`secrets` 槽位列表,以及该分节的 `revision`。`settings.update`/`settings.replace` 写入用户层;`settings.mutate` 则在已存分节上施加路径 op(`set`/`unset`),这是持有脱敏视图的客户端的删除路径——据此重建分节再整体替换,会删掉协议从未回传过的那些机密。任何写入都可携带 `expectedRevision`;过期的期望值会以 `settings-conflict` 连同两个 revision 作答,而不是覆盖先落地的那个写方,其余每种 seam 拒绝则折叠为 `settings-rejected`。secret 角色的值绝不在任何一层搭乘任何响应;secret 只沿一个方向跨越协议——在 `update`/`mutate` 载荷或 `credentials.set` 之内。`credentials.describe` 返回不含值的视图(`configured`/`source`/`writable`),`credentials.set`/`credentials.unset` 则把被遮蔽引用的拒绝映射为 `credential-rejected`。`llm.providers` 把可配置提供方目录与存活路由合并(休眠条目携带 `active: false`;未声明的存活路由追加在后,不带 settings 地址),`llm.models` 则是与会话无关的目录。三个失效帧让每个面无需轮询即保持收敛:`host/settings-changed {ns}`(`settings/document-updated` 透传,因此解析值未变的原始变更同样能到达客户端)、`host/credentials-changed {ref}`(只带引用名,绝不带值),以及 `host/models-changed`——它由 `llm/adapters-updated` 和可配置提供方 namespace 的变更触发,因为该提供方的设置正承载着它的目录与端点;`permission` 或 `ui-onboarding` 变更只会发出自身的 settings 失效通知。浏览器载体把整个配置面(含读取:`settings.describe`/`update`/`replace`/`mutate` 与 `credentials.describe`/`set`/`unset`)限制为仅接受来自回环地址的同源请求——即 `host.pickDirectory` 所在的特权集合。未装 settings 或凭据 provider 的组合会以指名缺失插件、包含解决建议的 `internal` 错误应答这些领域。 -`ApiProxy` 上有两个成员刻意不是协议方法。`respond` 是客户端响应入口(四象限模型),`workspaceRootOf` 则为进程内读取方回答某个 Session 的文件位于何处——先看活跃 agent 的 header,再看持久化存储,绝不恢复会话。它没有协议面:浏览器从 `sessions.view` 得知 Session 的 cwd,并经由 web 传输自己的 `/f` 路由抵达文件,而不是靠索要一条宿主路径。该路由的 URL 形状(`api/files.ts`:`FILES_PATH`、`workspaceFileSegments`、`workspaceFileUrl`、`parseWorkspaceFilePath`)与其余浏览器可导入的契约面一同放在这里,因此构造 `/f` URL 的浏览器半侧与解析它的服务半侧不会彼此漂移;路由本身则属于 [`dsh-client-connection`](../../client/connection/README.md)。 - ## 载体层(`/client` + 根路径) `AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供 `dsh -p` headless 模式使用。 diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 63e1f0007c..4e506ed262 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -2290,20 +2290,5 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro pending.resolve(payload.answer) return Promise.resolve({ accepted: true }) }, - - async workspaceRootOf(sessionId: SessionId): Promise<string | undefined> { - // A live agent answers from its own header; otherwise the store answers, - // deliberately without resuming — reading a session's directory must not - // pull an agent up the way the cold RPC path does. - const live = ctx.agents.get(sessionId) - if (live !== undefined) return live.session.header.cwd - const persistence = ctx.get('sessionPersistence') - if (persistence === undefined) return undefined - // TODO(persistence/by-id): a full listing per lookup. Harmless while the - // caller is one preview open, but a served document with N relative - // sub-resources pays it N times; a by-id header read on the persistence - // seam would retire it. - return (await persistence.list()).find(meta => meta.id === sessionId)?.cwd - }, } } diff --git a/packages/host/apiproxy/src/api/files.ts b/packages/host/apiproxy/src/api/files.ts deleted file mode 100644 index b4ba01f29b..0000000000 --- a/packages/host/apiproxy/src/api/files.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * The `/f` workspace-file URL shape: the contract half of the web transport - * that carries bytes rather than RPC. The browser turns a tool's file path - * into a URL, the serving side turns that URL back into the segments below a - * session's cwd, and both read this one encoding decision so neither can drift - * into serving a path the other never meant. Pure string work with no Node and - * no DOM, like the rest of `api/` — the browser bundle inlines it. - * @module @deepseek-ai/dsh-host-apiproxy/api/files - */ - -/** - * Route prefix owning every workspace-file read (`/f/<sessionId>/<segments…>`). - * The path carries the segments verbatim rather than a query parameter so a - * served document's relative references (`./logo.png`) resolve to their - * siblings in the same workspace directory. - */ -export const FILES_PATH = '/f' - -/** One parsed workspace-file request: whose workspace, and where inside it. */ -export interface WorkspaceFileTarget { - /** The owning session, still an opaque string — the caller resolves it to a cwd. */ - sessionId: string - /** Decoded path segments below that session's cwd; never empty, never `.` or `..`. */ - segments: string[] -} - -/** A segment that survived decoding but would re-enter path resolution as more than one name. */ -function isPlainSegment(segment: string): boolean { - return segment !== '' && segment !== '.' && segment !== '..' - && !segment.includes('/') && !segment.includes('\\') && !segment.includes('\0') -} - -function decode(raw: string): string | undefined { - try { - return decodeURIComponent(raw) - } catch { - // A malformed %-escape is a request we cannot interpret, not a miss. - return undefined - } -} - -/** - * Express one tool-reported file path as segments below the session cwd. - * @param cwd - the session's working directory, or `undefined` when unknown. - * @param path - the path the tool reported (absolute, or relative to `cwd`). - * @returns the segments below `cwd`, or `undefined` when the path names - * something outside the workspace (which this route never serves) or resolves - * to the workspace directory itself. - */ -export function workspaceFileSegments(cwd: string | undefined, path: string): string[] | undefined { - const slashed = path.replace(/\\/g, '/') - const absolute = /^\/|^[A-Za-z]:\//.test(slashed) - let relative: string - if (absolute) { - if (cwd === undefined || cwd === '') return undefined - const root = cwd.replace(/\\/g, '/').replace(/\/+$/, '') - if (!slashed.startsWith(`${root}/`)) return undefined - relative = slashed.slice(root.length + 1) - } else { - relative = slashed - } - const segments = relative.split('/').filter(segment => segment !== '' && segment !== '.') - if (segments.length === 0 || segments.some(segment => !isPlainSegment(segment))) return undefined - return segments -} - -/** - * Build the origin-relative URL serving one workspace file. - * @param sessionId - the session whose cwd anchors the path. - * @param segments - segments below that cwd, as {@link workspaceFileSegments} returns them. - * @returns the `/f/…` URL, resolved by the browser against the serving origin. - */ -export function workspaceFileUrl(sessionId: string, segments: readonly string[]): string { - const encoded = segments.map(segment => encodeURIComponent(segment)).join('/') - return `${FILES_PATH}/${encodeURIComponent(sessionId)}/${encoded}` -} - -/** - * Parse a request pathname back into the session and segments it names. - * @param pathname - the request's raw (still percent-encoded) pathname. - * @returns the target, or `undefined` when the pathname is not a well-formed - * workspace-file read — including every traversal shape, which is refused here - * before any filesystem call rather than being resolved and then judged. - */ -export function parseWorkspaceFilePath(pathname: string): WorkspaceFileTarget | undefined { - if (!pathname.startsWith(`${FILES_PATH}/`)) return undefined - const [rawSession, ...rawSegments] = pathname.slice(FILES_PATH.length + 1).split('/') - if (rawSession === undefined || rawSegments.length === 0) return undefined - const sessionId = decode(rawSession) - if (sessionId === undefined || sessionId === '') return undefined - const segments: string[] = [] - for (const raw of rawSegments) { - const segment = decode(raw) - if (segment === undefined || !isPlainSegment(segment)) return undefined - segments.push(segment) - } - return { sessionId, segments } -} diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index 227e26264e..c97cd33e1b 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -15,9 +15,6 @@ import type { SettingsApi } from './settings.ts' import type { CredentialsApi } from './credentials.ts' import type { LlmApi } from './llm.ts' import type { ClientResponse, RpcReceipt } from './rpc.ts' -// The merge-free types subpath: api/ is imported from the browser lane, where -// the host session service must not merge over the client runtime's own. -import type { SessionId } from '@deepseek-ai/dsh-session/types' /** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */ export interface ApiProxy { @@ -33,17 +30,6 @@ export interface ApiProxy { llm: LlmApi /** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */ respond(message: ClientResponse): Promise<RpcReceipt> - /** - * The directory a Session's files may be read from — the same `cwd` the - * session summaries carry, in non-envelope form for an in-process reader. - * Not a domain method: it has no wire face, because a browser learns a - * Session's cwd from `sessions.view` and a file it may read from the web - * transport's own `/f` route, never by asking for a host path. - * @param sessionId - the Session to locate. - * @returns its absolute working directory, or `undefined` when this host - * serves no such Session. Resolving one never resumes an agent. - */ - workspaceRootOf(sessionId: SessionId): Promise<string | undefined> } // ---- Domain interfaces and payload entities ---- @@ -63,9 +49,6 @@ export type { CredentialsApi, CredentialView } from './credentials.ts' export type { ConfigurableProviderView, LlmApi } from './llm.ts' export type { ApprovalResponsePayload } from './approvals.ts' -// ---- Workspace-file URL shape (the transport's byte-carrying half) ---- -export { FILES_PATH, workspaceFileSegments, workspaceFileUrl, parseWorkspaceFilePath } from './files.ts' -export type { WorkspaceFileTarget } from './files.ts' export type { QuestionResponsePayload } from './questions.ts' // ---- Message layer: narrow forms (domain-signature view) ---- diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index f6dec19420..339b1e777d 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -64,7 +64,6 @@ export class ApiProxyService extends Service implements ApiProxy { readonly llm: ApiProxy['llm'] readonly events: ApiProxy['events'] readonly respond: ApiProxy['respond'] - readonly workspaceRootOf: ApiProxy['workspaceRootOf'] constructor(ctx: Context, config: Config) { super(ctx, 'apiProxy') @@ -88,7 +87,6 @@ export class ApiProxyService extends Service implements ApiProxy { // createApiProxy returns closures (no `this` capture); bind only satisfies // the unbound-method lint without changing behavior. this.respond = api.respond.bind(api) - this.workspaceRootOf = api.workspaceRootOf.bind(api) } } diff --git a/packages/host/apiproxy/src/native-path-opener.ts b/packages/host/apiproxy/src/native-path-opener.ts index a4fbbaa72e..444cf3d408 100644 --- a/packages/host/apiproxy/src/native-path-opener.ts +++ b/packages/host/apiproxy/src/native-path-opener.ts @@ -1,5 +1,16 @@ -/** Cross-platform open-with-default-application used by the local GUI carrier. */ +/** + * Cross-platform open-with-default-application used by the local GUI carrier. + * + * A document a browser RENDERS is opened with the user's default browser + * rather than the default application for its type, when the platform can name + * one: a developer who binds `.html` to an editor would otherwise click a + * produced page and get source code. The contract is uniform — prefer the + * default browser, fall back to the default application — while how completely + * a platform can answer "which browser" differs, and every failure falls back + * rather than surfacing. + */ +import { extname } from 'node:path' import { runNativeCommand, type NativeCommandRunner } from '@deepseek-ai/dsh-native-command' /** Testable command boundary; native implementations never invoke a shell. */ @@ -9,6 +20,60 @@ export type PathOpenerRunner = NativeCommandRunner export interface PathOpenerInternals { platform?: NodeJS.Platform run?: PathOpenerRunner + /** Environment the linux browser convention reads; defaults to the process env. */ + env?: NodeJS.ProcessEnv +} + +/** Documents a browser renders, as opposed to ones an editor merely edits. */ +const BROWSER_DOCUMENTS = new Set(['.html', '.htm', '.xhtml', '.svg']) + +/** + * The macOS bundle registered for `https` — the default browser, as + * LaunchServices records it. The nested version dict is stripped first + * because it carries its own `LSHandlerRoleAll`. + */ +function macBundleForHttps(plist: string): string | undefined { + const stripped = plist.replace(/LSHandlerPreferredVersions\s*=\s*\{[^}]*\};/g, '') + const block = /\{[^{}]*LSHandlerURLScheme\s*=\s*"?https"?;[^{}]*\}/.exec(stripped)?.[0] + if (block === undefined) return undefined + return /LSHandlerRoleAll\s*=\s*"?([\w.-]+)"?;/.exec(block)?.[1] +} + +/** + * Open one browser-renderable document with the default browser. + * @returns true when a browser took it; false when this platform cannot name + * one, or naming it failed — the caller then uses the default application. + */ +async function openInBrowser( + path: string, signal: AbortSignal, platform: NodeJS.Platform, + run: PathOpenerRunner, env: NodeJS.ProcessEnv, +): Promise<boolean> { + if (platform === 'darwin') { + let bundle: string | undefined + try { + const { stdout } = await run( + 'defaults', ['read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], signal) + bundle = macBundleForHttps(stdout) + } catch { + // No LaunchServices record (a fresh account never changed a default): + // the content-type handler is then the system's own choice anyway. + return false + } + if (bundle === undefined) return false + await run('open', ['-b', bundle, path], signal) + return true + } + if (platform === 'linux') { + // $BROWSER is the portable convention; desktop-entry resolution through + // xdg-settings needs a launcher this package has no business shipping. + const browser = env.BROWSER + if (browser === undefined || browser === '') return false + await run(browser, [path], signal) + return true + } + // Windows names no browser without reading the UserChoice registry, and its + // .html association is the browser in the ordinary case. + return false } /** PowerShell single-quoted literal (doubles embedded quotes). */ @@ -17,10 +82,11 @@ function powershellLiteral(path: string): string { } /** - * Open a filesystem path with the operating system's default application. + * Open a filesystem path with the operating system's default application, or + * with the default browser when the path names a document a browser renders. * @param path - absolute or host-resolvable path (caller owns resolution). * @param signal - caller/connection lifetime; abort terminates the native command. - * @param internals - platform and runner seam for deterministic tests. + * @param internals - platform, environment, and runner seam for deterministic tests. */ export async function openNativePath( path: string, @@ -29,6 +95,10 @@ export async function openNativePath( ): Promise<void> { const platform = internals.platform ?? process.platform const run = internals.run ?? runNativeCommand + const env = internals.env ?? process.env + + if (BROWSER_DOCUMENTS.has(extname(path).toLowerCase()) + && await openInBrowser(path, signal, platform, run, env)) return if (platform === 'darwin') { await run('open', [path], signal) diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index cc30e5dee2..da05a4cd9b 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -62,11 +62,7 @@ function stubAgent(session: Session): Agent { async function harness( workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))), picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null }, - extras: { - openPath?: (path: string, signal: AbortSignal) => Promise<void> - /** Store contents behind the gateway, or 'absent' for a composition with no persistence at all. */ - persisted?: { id: SessionId; cwd?: string }[] | 'absent' - } = {}, + extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {}, ) { const ctx = new Context() await ctx.plugin(SessionStore) @@ -77,10 +73,7 @@ async function harness( const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} }) ctx.storage.mount('domain', storageDomain) ctx.provide('storageDomain', storageDomain) - if (extras.persisted !== 'absent') { - const persisted = extras.persisted ?? [] - ctx.provide('sessionPersistence', { list: () => Promise.resolve(persisted) } as never) - } + ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never) await ctx.plugin(WorkspaceRegistry) const factory: AgentFactory = { @@ -251,27 +244,6 @@ describe('host.openPath', () => { }) }) -describe('workspaceRootOf', () => { - it('answers from the live agent, then the store, and names nothing for an unknown session', async () => { - const { api, workspaceRoot } = await harness(undefined, undefined, { - persisted: [{ id: 's-cold' as SessionId, cwd: '/w/cold' }], - }) - const created = await api.sessions.create(request({ cwd: workspaceRoot })) - const sessionId = (created.result as { ok: true; value: { sessionId: SessionId } }).value.sessionId - // Live: the agent's own header, no store read involved. - await expect(api.workspaceRootOf(sessionId)).resolves.toBe(workspaceRoot) - // Not live: the store answers, and the lookup never resumes an agent — - // this harness's factory throws on resume, so a resuming lookup would fail. - await expect(api.workspaceRootOf('s-cold' as SessionId)).resolves.toBe('/w/cold') - await expect(api.workspaceRootOf('s-absent' as SessionId)).resolves.toBeUndefined() - }) - - it('names nothing at all when the host keeps no session store', async () => { - const { api } = await harness(undefined, undefined, { persisted: 'absent' }) - await expect(api.workspaceRootOf('s-any' as SessionId)).resolves.toBeUndefined() - }) -}) - describe('workspace.create', () => { it('serializes concurrent names and rejects the duplicate', async () => { const { api, workspaceRoot } = await harness() diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 2299949890..6307dfe8f9 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -108,8 +108,6 @@ function scriptedApi(overrides: { }, events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events }, respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })), - // No wire face, so the handler map never reaches it. - workspaceRootOf: () => Promise.resolve(undefined), } } diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index dac49a1234..ef111afe12 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -233,8 +233,6 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async respond(message: ClientResponse): Promise<RpcReceipt> { return message.rpcId === 'known' ? { accepted: true } : { accepted: false, reason: 'not-pending' } }, - // No wire face, so the carrier never reaches it. - workspaceRootOf: () => Promise.resolve(undefined), } } diff --git a/packages/host/apiproxy/tests/files-path.spec.ts b/packages/host/apiproxy/tests/files-path.spec.ts deleted file mode 100644 index df309a4783..0000000000 --- a/packages/host/apiproxy/tests/files-path.spec.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** The /f URL shape: one encoding decision, asserted from both ends. */ -import { describe, expect, it } from 'vitest' -import { - FILES_PATH, parseWorkspaceFilePath, workspaceFileSegments, workspaceFileUrl, -} from '../src/api/files.ts' - -describe('workspaceFileSegments', () => { - it('keeps a relative path as its own segments', () => { - expect(workspaceFileSegments('/w', 'out/index.html')).toEqual(['out', 'index.html']) - expect(workspaceFileSegments(undefined, 'index.html')).toEqual(['index.html']) - expect(workspaceFileSegments('/w', './a/./b.txt')).toEqual(['a', 'b.txt']) - }) - - it('strips the cwd prefix from an absolute path inside the workspace', () => { - expect(workspaceFileSegments('/w', '/w/a/b.html')).toEqual(['a', 'b.html']) - // A trailing separator on the cwd must not shift the split. - expect(workspaceFileSegments('/w/', '/w/a.html')).toEqual(['a.html']) - }) - - it('reads Windows paths on either separator', () => { - expect(workspaceFileSegments('C:\\w', 'C:\\w\\a\\b.html')).toEqual(['a', 'b.html']) - expect(workspaceFileSegments('C:/w', 'C:\\w\\a.html')).toEqual(['a.html']) - }) - - it('refuses everything the route would not serve', () => { - // Absolute, but not under this workspace. - expect(workspaceFileSegments('/w', '/etc/hosts')).toBeUndefined() - // A sibling directory sharing the cwd's name prefix is not inside it. - expect(workspaceFileSegments('/w', '/workspace-other/a')).toBeUndefined() - // Absolute with no cwd to anchor against. - expect(workspaceFileSegments(undefined, '/w/a.html')).toBeUndefined() - expect(workspaceFileSegments('', '/w/a.html')).toBeUndefined() - // Traversal, in either spelling. - expect(workspaceFileSegments('/w', '../secret')).toBeUndefined() - expect(workspaceFileSegments('/w', 'a/../../secret')).toBeUndefined() - // The workspace directory itself is not a file. - expect(workspaceFileSegments('/w', '/w')).toBeUndefined() - expect(workspaceFileSegments('/w', '.')).toBeUndefined() - }) -}) - -describe('workspaceFileUrl', () => { - it('percent-encodes each segment but keeps the separators structural', () => { - expect(workspaceFileUrl('s-1', ['out', 'a b.html'])).toBe(`${FILES_PATH}/s-1/out/a%20b.html`) - expect(workspaceFileUrl('s/1', ['a#b.html'])).toBe(`${FILES_PATH}/s%2F1/a%23b.html`) - }) -}) - -describe('parseWorkspaceFilePath', () => { - it('round-trips what the browser half builds', () => { - const url = workspaceFileUrl('s-1', ['out', 'a b.html']) - expect(parseWorkspaceFilePath(url)).toEqual({ sessionId: 's-1', segments: ['out', 'a b.html'] }) - }) - - it('refuses malformed, prefix-foreign, and traversal pathnames', () => { - expect(parseWorkspaceFilePath('/api/session.list')).toBeUndefined() - expect(parseWorkspaceFilePath(FILES_PATH)).toBeUndefined() - // Session named but no file below it. - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}//a.html`)).toBeUndefined() - // Traversal is refused at parse time, before any filesystem call. - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/../etc/hosts`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a/./b`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a//b`)).toBeUndefined() - // A separator smuggled through percent-encoding stays one segment's problem. - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%2F..%2Fb`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%5Cb`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%00b`)).toBeUndefined() - // Malformed percent-escapes are uninterpretable, not a miss to resolve. - expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%zz`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}/%zz/a.html`)).toBeUndefined() - expect(parseWorkspaceFilePath(`${FILES_PATH}//`)).toBeUndefined() - }) -}) diff --git a/packages/host/apiproxy/tests/native-path-opener.spec.ts b/packages/host/apiproxy/tests/native-path-opener.spec.ts index c8622002e2..f56ff1c66b 100644 --- a/packages/host/apiproxy/tests/native-path-opener.spec.ts +++ b/packages/host/apiproxy/tests/native-path-opener.spec.ts @@ -80,3 +80,96 @@ describe('native path opener', () => { }) }) }) + +describe('browser-renderable documents', () => { + const LS_PLIST = `{ + LSHandlers = ( + { + LSHandlerPreferredVersions = { + LSHandlerRoleAll = "-"; + }; + LSHandlerRoleAll = "com.google.chrome"; + LSHandlerURLScheme = https; + } + ); +}` + + it('opens a page with the default browser rather than the .html handler on darwin', async () => { + const calls: { command: string; args: readonly string[] }[] = [] + const run = async (command: string, args: readonly string[]) => { + calls.push({ command, args }) + return { stdout: command === 'defaults' ? LS_PLIST : '', stderr: '' } + } + await openNativePath('/w/page.html', new AbortController().signal, { platform: 'darwin', run }) + // A developer who bound .html to an editor still gets a rendered page. + expect(calls.map(c => [c.command, ...c.args])).toEqual([ + ['defaults', 'read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], + ['open', '-b', 'com.google.chrome', '/w/page.html'], + ]) + }) + + it('leaves every other document to the default application', async () => { + const calls: string[][] = [] + const run = async (command: string, args: readonly string[]) => { + calls.push([command, ...args]) + return { stdout: '', stderr: '' } + } + await openNativePath('/w/report.md', new AbortController().signal, { platform: 'darwin', run }) + // No LaunchServices read at all: markdown is not a browser document. + expect(calls).toEqual([['open', '/w/report.md']]) + }) + + it('falls back to the default application when no browser can be named', async () => { + // LaunchServices has no https record (a fresh account), so the system's + // own content-type choice is the best answer available. + const calls: string[][] = [] + const run = async (command: string, args: readonly string[]) => { + calls.push([command, ...args]) + if (command === 'defaults') throw new Error('domain not found') + return { stdout: '', stderr: '' } + } + await openNativePath('/w/page.html', new AbortController().signal, { platform: 'darwin', run }) + expect(calls).toEqual([ + ['defaults', 'read', 'com.apple.LaunchServices/com.apple.launchservices.secure'], + ['open', '/w/page.html'], + ]) + + // A record without an https handler is the same answer. + const bare: string[][] = [] + await openNativePath('/w/page.html', new AbortController().signal, { + platform: 'darwin', + run: async (command, args) => { + bare.push([command, ...args]) + return { stdout: '{ LSHandlers = ( ); }', stderr: '' } + }, + }) + expect(bare[1]).toEqual(['open', '/w/page.html']) + }) + + it('honors $BROWSER on linux and leaves windows to its association', async () => { + const linux: string[][] = [] + await openNativePath('/w/page.html', new AbortController().signal, { + platform: 'linux', + env: { BROWSER: 'firefox' }, + run: async (command, args) => { linux.push([command, ...args]); return { stdout: '', stderr: '' } }, + }) + expect(linux).toEqual([['firefox', '/w/page.html']]) + + // Unset $BROWSER: xdg-open's association is the fallback. + const bare: string[][] = [] + await openNativePath('/w/page.html', new AbortController().signal, { + platform: 'linux', + env: {}, + run: async (command, args) => { bare.push([command, ...args]); return { stdout: '', stderr: '' } }, + }) + expect(bare).toEqual([['xdg-open', '/w/page.html']]) + + // Windows names no browser without the UserChoice registry. + const win: string[][] = [] + await openNativePath('C:\\w\\page.html', new AbortController().signal, { + platform: 'win32', + run: async (command, args) => { win.push([command, ...args]); return { stdout: '', stderr: '' } }, + }) + expect(win[0]?.[0]).toBe('powershell.exe') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5fb7df52ad..7073f04e4b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1183,9 +1183,6 @@ 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 @@ -1266,9 +1263,6 @@ 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 diff --git a/tsconfig.host.json b/tsconfig.host.json index 9de5b51da0..89f57e364a 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -38,7 +38,7 @@ "apps/web/tests/access-confirmation.e2e.ts", "apps/web/tests/shipped-composition.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", - "apps/web/tests/workspace-file-open.e2e.ts", + "apps/web/tests/produced-files.e2e.ts", "apps/cli/tests/**/*.ts", "examples/*/src/**/*.ts", "examples/*/start.ts", From 87d0fc6fc42d1fe1afa244b40e1bd4faead658f0 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 6 Aug 2026 04:31:34 -0700 Subject: [PATCH 07/12] cleanup: drop leftovers from the retired HTTP-serving revision --- packages/client/connection/src/client/fixture.ts | 1 - packages/client/connection/src/index.ts | 3 +-- .../client/connection/tests/client-apply.spec.ts | 1 - packages/client/connection/tests/node-half.spec.ts | 13 +++++-------- .../client/runtime/src/client/workspaces/service.ts | 1 - packages/client/test-runtime/src/workspaces.ts | 1 - .../src/client/chat/Deliverables.tsx | 3 +-- 7 files changed, 7 insertions(+), 16 deletions(-) diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index 0549fc1160..9f091b26c5 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2362,7 +2362,6 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy { }) return Promise.resolve({ accepted: true }) }, - } } diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index 03f8aaa257..ed4af2d21f 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -13,7 +13,7 @@ export { API_PATH } from './api-path.ts' /** Stable Cordis plugin name. */ export const name = 'client-connection' -/** Services required before mounting the routes. */ +/** Services required before mounting the route. */ export const inject = ['httpServer', 'apiProxy'] /** Plugin config: the deployment's non-loopback serving authorities. */ @@ -96,5 +96,4 @@ export function apply(ctx: Context, config?: ConnectionConfig): void { }, } ctx.effect(() => ctx.httpServer.register(route), 'client-connection: /api route') - } diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index f9fe1c1b71..6892dc7721 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -62,5 +62,4 @@ describe('connection client apply', () => { } expect(seen.some(u => u.includes('/api/'))).toBe(true) }) - }) diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index 216484ad67..08c65de2ba 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -30,15 +30,11 @@ function fakeRequest(headers: Record<string, string>, url = `${API_PATH}/session } /** Response recorder compatible with both the fence's short-circuit and the bridge. */ -function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown; headers?: Record<string, string> } } { - const state: { status?: number; body?: unknown; headers?: Record<string, string> } = {} +function fakeResponse(): { response: ServerResponse; state: { status?: number; body?: unknown } } { + const state: { status?: number; body?: unknown } = {} const response = Object.assign(new EventEmitter(), { writableEnded: false, - writeHead(value: number, headers?: Record<string, string>) { - state.status = value - if (headers !== undefined) state.headers = headers - return this - }, + writeHead(value: number) { state.status = value; return this }, write() { return true }, end(this: { writableEnded: boolean }, value?: unknown) { if (value !== undefined) state.body = value @@ -72,7 +68,8 @@ describe('connection node half', () => { it('registers the /api prefix route and removes it with the fiber', async () => { const { routes, dispose } = await mounted() - expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }]) + expect(routes).toHaveLength(1) + expect(routes[0]).toMatchObject({ kind: 'prefix', path: API_PATH }) await dispose() expect(routes).toHaveLength(0) }) diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index c0eb46fcf9..a0a76670f2 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -239,7 +239,6 @@ export class WorkspacesService implements IWorkspaces { } } - /** * Rename a Workspace. * @param workspaceId - target workspace. diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts index 95f6574405..7e626a3660 100644 --- a/packages/client/test-runtime/src/workspaces.ts +++ b/packages/client/test-runtime/src/workspaces.ts @@ -98,7 +98,6 @@ export class TestWorkspaces implements IWorkspaces { await (this.stubs.get('openPath')?.(path) as Promise<void> | undefined) } - /** * Directory picker (recorded). The default cancels (null); stub to select. * @returns the picked path, or null. diff --git a/packages/client/ui-conversation/src/client/chat/Deliverables.tsx b/packages/client/ui-conversation/src/client/chat/Deliverables.tsx index 0a0160b486..7d62e23401 100644 --- a/packages/client/ui-conversation/src/client/chat/Deliverables.tsx +++ b/packages/client/ui-conversation/src/client/chat/Deliverables.tsx @@ -2,8 +2,7 @@ // from the mutation tools' follow-along locations (see turnDeliverables), never // from the closing prose, so the answer carries its own output whether or not // the model remembered to name it. Clicking one goes through the same openFile -// the tool rows use — in the browser that is a new tab served from the session -// workspace, and outside it the Host's own opener. +// the tool rows use — the Host's own opener, on the Host machine. import type { ChatViewSlotProps } from '../contract/slots.ts' import css from './Deliverables.module.css' From f00a44fd449f221e5548c1b040d9bc9264bafa44 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:21:19 -0700 Subject: [PATCH 08/12] refactor(web): move the produced-files row into its own plugin package ui-conversation now owns only the conversation.chat.turnTail hole; the row, its derivation, and its copy live in @deepseek-ai/dsh-client-ui-deliverables, composed in or out by one cordis.yml line. --- ...6-07-31-web-workspace-file-links.i18n.yaml | 4 +- .../2026-07-31-web-workspace-file-links.md | 2 +- .../2026-07-31-web-workspace-file-links.zh.md | 2 +- apps/cli/config/web.cordis.yml | 5 + apps/cli/package.json | 1 + docs/config-catalog.md | 1 + knip.json | 10 + .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 1 + .../src/client/chat/AssistantMarkdown.tsx | 17 +- .../src/client/chat/ChatView.tsx | 10 +- .../src/client/chat/Deliverables.tsx | 53 ----- .../src/client/chat/chat-flow.ts | 77 -------- .../src/client/contract/slots.ts | 31 ++- .../ui-conversation/src/client/index.ts | 2 +- .../ui-conversation/src/client/locales.ts | 6 - .../ui-conversation/tests/chat-view.spec.tsx | 93 +-------- .../client/ui-deliverables/README.i18n.yaml | 6 + packages/client/ui-deliverables/README.md | 21 ++ packages/client/ui-deliverables/README.zh.md | 21 ++ packages/client/ui-deliverables/package.json | 65 +++++++ .../src/client/ProducedFiles.module.css} | 0 .../src/client/ProducedFiles.tsx | 61 ++++++ .../ui-deliverables/src/client/index.ts | 42 ++++ .../ui-deliverables/src/client/locales.ts | 21 ++ .../src/client/turn-deliverables.ts | 78 ++++++++ .../ui-deliverables/src/css-modules.d.ts | 6 + packages/client/ui-deliverables/src/index.ts | 9 + .../client/ui-deliverables/src/invariant.ts | 32 +++ .../tests/produced-files.spec.tsx | 183 ++++++++++++++++++ packages/client/ui-deliverables/tsconfig.json | 30 +++ .../client/ui-deliverables/tsdown.config.ts | 3 + pnpm-lock.yaml | 34 ++++ .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 1 + tsconfig.client.json | 1 + 38 files changed, 685 insertions(+), 253 deletions(-) delete mode 100644 packages/client/ui-conversation/src/client/chat/Deliverables.tsx create mode 100644 packages/client/ui-deliverables/README.i18n.yaml create mode 100644 packages/client/ui-deliverables/README.md create mode 100644 packages/client/ui-deliverables/README.zh.md create mode 100644 packages/client/ui-deliverables/package.json rename packages/client/{ui-conversation/src/client/chat/Deliverables.module.css => ui-deliverables/src/client/ProducedFiles.module.css} (100%) create mode 100644 packages/client/ui-deliverables/src/client/ProducedFiles.tsx create mode 100644 packages/client/ui-deliverables/src/client/index.ts create mode 100644 packages/client/ui-deliverables/src/client/locales.ts create mode 100644 packages/client/ui-deliverables/src/client/turn-deliverables.ts create mode 100644 packages/client/ui-deliverables/src/css-modules.d.ts create mode 100644 packages/client/ui-deliverables/src/index.ts create mode 100644 packages/client/ui-deliverables/src/invariant.ts create mode 100644 packages/client/ui-deliverables/tests/produced-files.spec.tsx create mode 100644 packages/client/ui-deliverables/tsconfig.json create mode 100644 packages/client/ui-deliverables/tsdown.config.ts diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml index 2b75bc2eff..e38447bf37 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml @@ -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: da99426ecb5ca81dcc110bbd4d5c1218390ae4bd -2026-07-31-web-workspace-file-links.zh.md: 91aa94c6fe253c64125eb31fd15973a5aaff1a8f +2026-07-31-web-workspace-file-links.md: 5bac48286c0d9066154a649f418b2f9c2df36539 +2026-07-31-web-workspace-file-links.zh.md: 2b2802ad00bbf4eb84d7ad81bca3b4a0838092c9 diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md index da99426ecb..5bac48286c 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md @@ -14,7 +14,7 @@ Two distinct defects sat behind that. The transcript never said what a turn had ## Decision -**A finished turn ends with the files it produced.** `turnDeliverables` reads them off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. The row renders under the closing assistant's body and above its IconActions, keyed to the seq `assistantActionsSeqs` already elects. +**A finished turn ends with the files it produced.** The row is its own plugin, `@deepseek-ai/dsh-client-ui-deliverables`, registered into the `conversation.chat.turnTail` hole the chat view renders between a closing message's body and its IconActions — ui-conversation owns the hole and the owner currency (nodes, closing seq, `openFile`), the plugin owns every policy. `producedForClosing` reads the paths off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. One cordis.yml line composes the surface in or out; the unregistered hole renders nothing. **The path link reads as a link.** Underlined at rest, not only on hover. This is the smaller half of the diff and the larger half of the fix. diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md index 91aa94c6fe..2b2802ad00 100644 --- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.zh.md @@ -14,7 +14,7 @@ Status: implemented ## 决定 -**完成的一轮以它产出的文件收尾。** `turnDeliverables` 从改写工具自身的跟随文件 `locations` 中读出它们——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。该行渲染在收尾 assistant 正文之下、其 IconActions 之上,键控到 `assistantActionsSeqs` 早已选出的那个 seq。 +**完成的一轮以它产出的文件收尾。** 该行是独立插件 `@deepseek-ai/dsh-client-ui-deliverables`,注册进 chat 视图在收尾消息正文与其 IconActions 之间渲染的 `conversation.chat.turnTail` 空位——ui-conversation 拥有空位与 owner 通货(节点、收尾 seq、`openFile`),插件拥有全部策略。`producedForClosing` 从改写工具自身的跟随文件 `locations` 中读出路径——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。cordis.yml 中的一行即可把该交互面组合进来或去掉;未注册的空位什么也不渲染。 **路径链接读得出是链接。** 静止状态下就带下划线,而不只在悬停时。这是本次改动中更小的那一半,却是修复中更大的那一半。 diff --git a/apps/cli/config/web.cordis.yml b/apps/cli/config/web.cordis.yml index daf597916e..62731fd51e 100644 --- a/apps/cli/config/web.cordis.yml +++ b/apps/cli/config/web.cordis.yml @@ -141,6 +141,11 @@ - id: ui-conversation name: '@deepseek-ai/dsh-client-ui-conversation' + # Turn tail: the produced-files row under each closing assistant message. + # Remove this entry to turn the surface off; the tail hole renders empty. + - id: ui-deliverables + name: '@deepseek-ai/dsh-client-ui-deliverables' + - id: ui-workspace name: '@deepseek-ai/dsh-client-ui-workspace' diff --git a/apps/cli/package.json b/apps/cli/package.json index 4ba1da7e86..58b92bc41d 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -30,6 +30,7 @@ "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-command": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-deliverables": "workspace:^", "@deepseek-ai/dsh-client-ui-goal": "workspace:^", "@deepseek-ai/dsh-client-ui-layout": "workspace:^", "@deepseek-ai/dsh-client-ui-model": "workspace:^", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 89f1529387..710022c11c 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2376,6 +2376,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts)) - `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts)) - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-deliverables` ([`packages/client/ui-deliverables/src/index.ts`](../packages/client/ui-deliverables/src/index.ts)) - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts)) - `@deepseek-ai/dsh-client-ui-layout` ([`packages/client/ui-layout/src/index.ts`](../packages/client/ui-layout/src/index.ts)) - `@deepseek-ai/dsh-client-ui-model` ([`packages/client/ui-model/src/index.ts`](../packages/client/ui-model/src/index.ts)) diff --git a/knip.json b/knip.json index dfb8058d7c..b16e085723 100644 --- a/knip.json +++ b/knip.json @@ -127,6 +127,16 @@ "tests/**/*.tsx" ] }, + "packages/client/ui-deliverables": { + "entry": [ + "tests/**/*.spec.tsx" + ], + "project": [ + "src/**/*.ts", + "src/**/*.tsx", + "tests/**/*.tsx" + ] + }, "packages/client/web-react": { "entry": [ "tests/**/*.spec.tsx" diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index f1e7c35db7..7c0fd0e42a 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/README.i18n.yaml @@ -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: 674cbe7ddf6f2ddc64c337cbebb4ee553e1e3f96 -README.zh.md: d3e8475c7670c57575aa8eeecbf29646c6672c2f +README.md: 8e31a41ad682dfa21d22c93673b17954a784dd4f +README.zh.md: 05b3eeb3185166a1f16596a206ef4111a50e789d diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 674cbe7ddf..8e31a41ad6 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -50,7 +50,7 @@ The chat stats line takes its token accounting from the generic token-meter `tok `src/client/` is organized by domain. `contract/` is the sole inter-domain shared face (`slots.ts` slot declarations and composed props, `views.ts` shared primitives, `tool-call-model.ts`); the `skeleton/`, `chat/`, and `toolviews/` directories import contract files and never each other. `apply.ts` is the only assembly point allowed to import all three domains. The `/client` export surface is the contract only — `apply`/`inject`, the two service classes, and the `contract/` type families; implementation components and the store factory stay internal and reach the page through apply's slot registrations. -A finished turn ends with the files it produced. `chat-flow.ts`'s `turnDeliverables` reads them off the mutation tools' own follow-along `locations` — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a turn's output is listed whether or not the closing message named it, and a new mutation tool joins by declaring what it does rather than by being added to a list. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. The row renders under the closing assistant's body and above its IconActions, keyed to the same seq `assistantActionsSeqs` elects. It shows six chips (basename, full path as the title) plus an explicit remainder count, and each chip opens through the same `openFile` the tool rows use. +A finished turn ends with a turn-tail hole: the chat view renders the `conversation.chat.turnTail` list slot between the closing assistant's body and its IconActions, once per turn at the seq `assistantActionsSeqs` elects, dispatching `TurnTailOwnerProps` (the snapshot nodes, the closing seq, and the tool rows' `openFile`). This package owns only the hole; the produced-files row that fills it — derivation from the mutation tools' `locations`, the chip cap, the copy — lives in `@deepseek-ai/dsh-client-ui-deliverables`, so composing that plugin out of cordis.yml turns the surface off while the hole renders empty at zero cost. ## Model Experience diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index d3e8475c76..05b3eeb318 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -50,7 +50,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu `src/client/` 按领域组织。`contract/` 是唯一的跨领域共享表层(`slots.ts` slot 声明与组合后的 props、`views.ts` 共享原语、`tool-call-model.ts`);`skeleton/`、`chat/` 和 `toolviews/` 目录只导入 contract 文件,彼此之间从不互相导入。`apply.ts` 是唯一允许导入全部三个领域的组装点。`/client` 导出表层只包含契约:`apply`/`inject`、两个服务类和 `contract/` 类型家族;实现组件与 store factory 保持内部,经 apply 的 slot 注册抵达页面。 -完成的一轮以它产出的文件收尾。`chat-flow.ts` 的 `turnDeliverables` 从改写工具自身的跟随文件 `locations` 中读出它们——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此无论收尾消息是否点名,这一轮的产出都会被列出;新的改写工具靠声明自己做了什么加入,而不是靠被加进某张名单。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。该行渲染在收尾 assistant 正文之下、其 IconActions 之上,键控到 `assistantActionsSeqs` 选出的同一个 seq。它展示六枚 chip(文本为文件名,完整路径作为 title),外加一个显式的剩余计数,每枚 chip 都经由工具行所用的同一个 `openFile` 打开。 +完成的一轮以一个 turn-tail 空位收尾:chat 视图在收尾 assistant 正文与其 IconActions 之间渲染 `conversation.chat.turnTail` list slot,每轮一次、位于 `assistantActionsSeqs` 选出的 seq,派发 `TurnTailOwnerProps`(快照节点、收尾 seq,以及工具行的 `openFile`)。本包只拥有空位;填充它的产物行——从改写工具 `locations` 的派生、chip 上限、文案——都在 `@deepseek-ai/dsh-client-ui-deliverables` 里,因此把那个插件从 cordis.yml 中组合掉即可关闭该交互面,空位以零成本渲染为空。 ## 模型体验 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 6bc9068cfc..8eb78139c4 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -303,6 +303,7 @@ export function apply(ctx: Context): void { children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' }, 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' }, + 'conversation.chat.turnTail': { kind: 'list', scope: 'session' }, }, store: chatStore, inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 77cd3316d8..d6b6504bd0 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -9,13 +9,12 @@ // only when the node is also the completed turn's transcript tail. Think / // tool-head-only nodes stay chrome-free. -import { memo, useMemo } from 'react' +import { memo, useMemo, type ReactNode } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' import { IconThinkOutline14, JsonBlock, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { Deliverables } from './Deliverables.tsx' import { MessageIconActions } from './MessageIconActions.tsx' import { ToolRow } from './ToolRow.tsx' import css from './AssistantMarkdown.module.css' @@ -39,11 +38,9 @@ export interface AssistantMarkdownProps { seq?: number | undefined /** Fork the session through this finalized message's completed turn when eligible. */ onFork?: ((seq: number) => void) | undefined - /** Files the closing turn produced, listed under the body; omitted for a - * mid-turn assistant and for a turn that wrote nothing. */ - produced?: readonly string[] | undefined - /** Opens one produced file; omitted wherever `produced` is. */ - openFile?: ((path: string) => void) | undefined + /** Turn-tail content (the chat view's turnTail hole, rendered by the + * owner); omitted for a mid-turn assistant. */ + tail?: ReactNode | undefined /** The message is not the transcript tail of a completed turn. */ forkUnavailable?: boolean | undefined /** The owning view's locale seat, passed down as a plain prop. */ @@ -92,7 +89,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, produced, openFile, t, + blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, tail, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -130,9 +127,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ })} {interrupted && <span className={css.stopped}>{t('message.stopped')}</span>} </div> - {showActions && produced !== undefined && openFile !== undefined && ( - <Deliverables paths={produced} openFile={openFile} t={t} /> - )} + {showActions && tail} {showActions && ( <MessageIconActions text={copyText(blocks)} diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index 725a7403e2..3e0015ed20 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -30,7 +30,7 @@ import type { import type { SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' import { IconChevronDownOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' import type { ChatViewSlotProps } from '../contract/slots.ts' -import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, runningTurnStartTime, turnDeliverables, type ChatFlowItem } from './chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, messageBranchSeqs, runningTurnStartTime, type ChatFlowItem } from './chat-flow.ts' import { AssistantMarkdown } from './AssistantMarkdown.tsx' import { GenericCommandCard } from './GenericCommandCard.tsx' import { GenericToolCard } from './GenericToolCard.tsx' @@ -361,9 +361,6 @@ export function ChatView({ // Only the last content assistant of each turn owns IconActions; mid-turn // text (before tools) omits `time` so AssistantMarkdown stays chrome-free. const actionSeqs = useMemo(() => assistantActionsSeqs(nodes), [nodes]) - // Produced files per closing assistant: derived from the mutation tools' - // locations, so a turn's output is listed whether or not the model named it. - const produced = useMemo(() => turnDeliverables(nodes), [nodes]) const branchSeqs = useMemo(() => messageBranchSeqs(nodes, turnEnds), [nodes, turnEnds]) const runningTurnStart = useMemo(() => runningTurnStartTime(turnTimings), [turnTimings]) const turnMetrics = useMemo(() => deriveTurnMetrics(nodes), [nodes]) @@ -621,8 +618,9 @@ export function ChatView({ seq={node.seq} onFork={forkAt} forkUnavailable={!branchSeqs.has(node.seq)} - produced={produced.get(node.seq)} - openFile={openFile} + tail={actionSeqs.has(node.seq) + ? renderSlot('conversation.chat.turnTail', { nodes, seq: node.seq, openFile }) + : undefined} t={t} /> ) diff --git a/packages/client/ui-conversation/src/client/chat/Deliverables.tsx b/packages/client/ui-conversation/src/client/chat/Deliverables.tsx deleted file mode 100644 index 7d62e23401..0000000000 --- a/packages/client/ui-conversation/src/client/chat/Deliverables.tsx +++ /dev/null @@ -1,53 +0,0 @@ -// Deliverables: the produced-file row a finished turn ends with. The paths come -// from the mutation tools' follow-along locations (see turnDeliverables), never -// from the closing prose, so the answer carries its own output whether or not -// the model remembered to name it. Clicking one goes through the same openFile -// the tool rows use — the Host's own opener, on the Host machine. - -import type { ChatViewSlotProps } from '../contract/slots.ts' -import css from './Deliverables.module.css' - -/** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */ -const SHOWN = 6 - -/** Trailing path segment, the part that identifies the file at a glance. */ -function basename(path: string): string { - const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) - return at === -1 ? path : path.slice(at + 1) -} - -/** - * Render one turn's produced files as openable chips. - * @param props - the turn's paths (tool order, already deduped), the chat - * view's file opener, and the owning view's locale seat. - * @returns The row, or `null` when the turn produced nothing. - */ -export function Deliverables({ paths, openFile, t }: { - paths: readonly string[] - openFile: (path: string) => void - t: ChatViewSlotProps['t'] -}) { - if (paths.length === 0) return null - const shown = paths.slice(0, SHOWN) - const hidden = paths.length - shown.length - return ( - <div className={css.root}> - <span className={css.label}>{t('produced.label')}</span> - {shown.map(path => ( - <button - key={path} - type="button" - className={css.file} - // The full path is the disambiguator when two turns produce files - // that share a basename; the chip itself stays short. - title={path} - aria-label={t('produced.open', { name: path })} - onClick={() => { openFile(path) }} - > - {basename(path)} - </button> - ))} - {hidden > 0 && <span className={css.more}>{t('produced.more', { count: String(hidden) })}</span>} - </div> - ) -} diff --git a/packages/client/ui-conversation/src/client/chat/chat-flow.ts b/packages/client/ui-conversation/src/client/chat/chat-flow.ts index 7d5e5100f6..57d2ac1bb0 100644 --- a/packages/client/ui-conversation/src/client/chat/chat-flow.ts +++ b/packages/client/ui-conversation/src/client/chat/chat-flow.ts @@ -32,21 +32,6 @@ function rendersNothing(node: ConversationNode): boolean { || ((b.kind === 'text' || b.kind === 'reasoning') && b.text.trim() === '')) } -/** - * Paths a call view reports having created or changed, by render intent rather - * than tool name: a diff card, or a generic card whose kind is `edit` (the - * shape `str_replace_editor`'s insert presents). Every other card produces - * nothing to open — a read looked, a delete removed, a terminal ran. - */ -function producedPaths(view: ToolResultNode['callView']): readonly string[] { - if (view === null) return [] - if (view.card === 'diff') return (view.locations ?? []).map(location => location.path) - if (view.card === 'generic' && view.kind === 'edit') { - return (view.locations ?? []).map(location => location.path) - } - return [] -} - /** * Seq set of assistants that own IconActions: the last content-text assistant * in each turn. Mid-turn narration (text before tools) stays chrome-free. @@ -62,68 +47,6 @@ export function assistantActionsSeqs(nodes: readonly ConversationNode[]): Readon return new Set(lastByTurn.values()) } -/** - * Files each turn produced, keyed by the assistant seq that closes it — the - * same anchor {@link assistantActionsSeqs} elects, so the row lands under the - * message that reports the work rather than after some mid-turn narration. - * - * The source is the mutation tools' own follow-along `locations`, not the - * closing prose: a produced file must be listed whether or not the model - * remembered to name it. A mutation is recognized by render intent, not by - * tool name — a diff card, or a generic card whose `kind` is `edit` (the shape - * `str_replace_editor`'s insert presents) — so a new mutation tool joins by - * declaring what it does. Reads contribute nothing (looking at a file does not - * produce it), and neither do deletes (there is nothing left to open) or - * failed calls. Paths keep first-seen order and appear once, so a file written - * and then edited in the same turn is one entry. - * - * Accumulation resets on the turn boundary, not merely at the closing - * assistant: a turn that mutates files and then ends without content text - * (interrupted mid-tool, or a turn whose last text precedes its last tool - * result) must not spill its paths into the next turn's row, nor leave `seen` - * suppressing a file the next turn legitimately rewrites. - * @param nodes - snapshot nodes (surface order). - * @returns Per-closing-seq produced paths; a turn that produced none is absent. - */ -export function turnDeliverables(nodes: readonly ConversationNode[]): ReadonlyMap<number, readonly string[]> { - const closing = assistantActionsSeqs(nodes) - const byClosingSeq = new Map<number, readonly string[]>() - let pending: string[] = [] - let seen = new Set<string>() - let turn: number | undefined - for (const node of nodes) { - if (node.kind === 'tool-result') { - if (node.isError) continue - for (const path of producedPaths(node.callView)) { - if (seen.has(path)) continue - seen.add(path) - pending.push(path) - } - continue - } - // Tool results carry no turn of their own, so the boundary is read off the - // nodes that do. A user message opens a turn without reporting a number, - // which is why the tracked turn goes back to undefined there: the next - // node to report one is stating the current turn, not entering a new one. - if (node.kind === 'user') { - turn = undefined - pending = [] - seen = new Set() - } else if ('turn' in node) { - if (turn !== undefined && node.turn !== turn) { - pending = [] - seen = new Set() - } - turn = node.turn - } - if (node.kind !== 'assistant' || !closing.has(node.seq)) continue - if (pending.length > 0) byClosingSeq.set(node.seq, pending) - pending = [] - seen = new Set() - } - return byClosingSeq -} - /** * Exact start time of the latest in-window turn without a matching end time. * @param turnTimings - In-window turn timings in event order. diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a84b4a3bf0..1246433a33 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -3,7 +3,7 @@ import type { ReactNode, RefObject } from 'react' import type { InjectFace, MaybeSnapshotSelectorHook, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore, SnapshotSelectorHook, } from '@deepseek-ai/dsh-client-ui-slots' -import type { CommandNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' +import type { CommandNode, ConversationNode, ConversationSnapshot, ObservableSnapshot, PendingInteraction, PendingWait, SessionId, ToolCallBlock, WorkspaceId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-layout/client' import type { ComposerKeyboard, EditSelection, InputActions, InputNotice, InputState } from '../input/contract.ts' import type { createChatStore } from '../stores.ts' @@ -46,6 +46,15 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * registration, and a domain upgrades by registering one row component. */ 'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps } + /** + * The chat view's turn-tail hole: rendered between a closing assistant + * message's body and its IconActions footer, once per turn (the render + * site elects the closing seq). Declared by the chat view entry; feature + * plugins (ui-deliverables' produced-files row) derive what they show + * from the owner currency, and an unregistered hole renders nothing — + * composing such a plugin out of cordis.yml turns its surface off. + */ + 'conversation.chat.turnTail': { kind: 'list'; scope: 'session'; owner: TurnTailOwnerProps } /** * The composer takeover chain: entries are selector-routed replacements * of the default InputBar. Declared by this package's 'conversation' @@ -150,6 +159,24 @@ export interface ConvViewOwnerProps { onInspectDone?: () => void } +/** + * Owner currency of the chat view's turn-tail hole: the finalized snapshot + * and the closing assistant's anchor. Registrants derive their own facts + * from the nodes (the owner never pre-chews a feature's vocabulary), and + * open files through the same opener the tool rows use. + */ +export interface TurnTailOwnerProps { + /** Finalized snapshot nodes in surface order. */ + nodes: readonly ConversationNode[] + /** The closing assistant's seq — the anchor the tail renders under. */ + seq: number + /** + * Open a filesystem path through the Host (tool-row semantics; the chat + * view resolves relative paths against the session cwd). + */ + openFile: (path: string) => void +} + /** * Owner share of a per-view toolview slot: the call material the rendering * view supplies per row. Uniform across views — the trajectory/waterfall @@ -480,7 +507,7 @@ export interface ChatViewInjected { /** Full chat-view component props: runtime & the declared toolview/commandview holes' render share & store & injected & locale seat. */ export type ChatViewSlotProps = - PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview'> + PropsRuntime<'conversation.view'> & PropsRenderSlots<'conversation.chat.toolview' | 'conversation.chat.commandview' | 'conversation.chat.turnTail'> & PropsStore<ChatStore> & ChatViewInjected & PropsLocale<'conversation'> /** diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index ac5f6574c8..725868d57a 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -17,7 +17,7 @@ export type { ComposerChainProps, ConversationInjected, ConversationSessionHeaderInjected, ConversationSessionInjected, ConversationSlotProps, ConvViewOwnerProps, ConvViewProps, DetailsInjected, DetailsSlotProps, - EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps, + EmptyWorkspaceOwnerProps, ToolRowOwnerProps, ToolRowProps, TurnTailOwnerProps, } from './contract/slots.ts' // Export discipline: packages/client/AGENTS.md. diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index b489bb29a1..9ba5ed3876 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -85,9 +85,6 @@ export const zh = { 'message.unknownSurface': '未知 surface 事件:{type}', 'message.unknownBlock': '未知内容块', 'message.stopped': '已停止', - 'produced.label': '产物', - 'produced.more': '还有 {count} 个', - 'produced.open': '打开 {name}', 'message.branch': '在新对话中分支', 'message.branchUnavailable': '仅可从已完成轮次的最后一条消息分支', 'message.retry.active': '正在重试模型请求', @@ -228,9 +225,6 @@ export const en = { 'message.unknownSurface': 'Unknown surface event: {type}', 'message.unknownBlock': 'Unknown content block', 'message.stopped': 'Stopped', - 'produced.label': 'Produced', - 'produced.more': '{count} more', - 'produced.open': 'Open {name}', 'message.branch': 'Branch into a new conversation', 'message.branchUnavailable': 'Available only on the last message of a completed turn', 'message.retry.active': 'Retrying model request', diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index dc3e6f5241..4da138d1b5 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -20,7 +20,7 @@ import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts import { createChatStore } from '../src/client/stores.ts' import { ChatView } from '../src/client/chat/ChatView.tsx' import { zh } from '../src/client/locales.ts' -import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs, runningTurnStartTime, turnDeliverables } from '../src/client/chat/chat-flow.ts' +import { assistantActionsSeqs, deriveChatFlow, flowKeys, messageBranchSeqs, runningTurnStartTime } from '../src/client/chat/chat-flow.ts' import { formatRunDuration } from '../src/client/chat/message-chrome.ts' afterEach(() => { @@ -242,93 +242,6 @@ describe('chat-flow derivation', () => { expect([...seqs].sort((a, b) => a - b)).toEqual([5, 7]) }) - it('turnDeliverables attributes each turn’s written files to the assistant that closes it', () => { - const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ - ...toolResult(seq, callId, 'write'), - callView: { - card: 'diff', title: `Write ${paths[0] ?? ''}`, - diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), - locations: paths.map(path => ({ path })), - }, - }) - const produced = turnDeliverables([ - user(1, 'build it'), - assistant(2, 'writing', 1), - wrote(3, 'a', 'out/index.html'), - // Same file touched twice in one turn is one deliverable, in first-seen order. - wrote(4, 'b', 'out/app.css', 'out/index.html'), - // A read is not a deliverable; a failed write has no file to open. - { ...toolResult(5, 'c', 'read'), callView: { card: 'generic', title: 'Read x', locations: [{ path: 'x.ts' }] } }, - { ...wrote(6, 'd', 'out/broken.html'), isError: true }, - assistant(7, 'done', 1), - user(8, 'again'), - assistant(9, 'second turn', 2), - ]) - expect(produced.get(7)).toEqual(['out/index.html', 'out/app.css']) - // A turn that produced nothing is absent, not an empty row. - expect(produced.has(9)).toBe(false) - // Nothing at all written: no entries. - expect(turnDeliverables([user(1, 'hi'), assistant(2, 'hello', 1)]).size).toBe(0) - }) - - it('turnDeliverables counts a generic edit and never spills across the turn boundary', () => { - const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ - ...toolResult(seq, callId, 'str_replace_editor'), - // str_replace_editor's insert mutates behind a generic card, so the - // discriminant is the render intent, not the card shape alone. - callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }, - }) - const wrote = (seq: number, callId: string, path: string): ToolResultNode => ({ - ...toolResult(seq, callId, 'write'), - callView: { - card: 'diff', title: 'Write', diffs: [{ path, oldText: null, newText: 'x' }], locations: [{ path }], - }, - }) - const produced = turnDeliverables([ - user(1, 'insert a line'), - inserted(2, 'i', 'notes.md'), - assistant(3, 'inserted', 1), - // Turn 2 mutates and then ends with no content text (interrupted, or its - // last text preceded the tool): its paths must not ride into turn 3. - user(4, 'now rewrite it'), - wrote(5, 'w', 'leaked.txt'), - user(6, 'and again'), - wrote(7, 'w2', 'notes.md'), - assistant(8, 'done', 3), - ]) - expect(produced.get(3)).toEqual(['notes.md']) - // Turn 3 lists only its own file — and `seen` did not suppress the rewrite - // of a path an earlier turn already touched. - expect(produced.get(8)).toEqual(['notes.md']) - expect([...produced.values()].flat()).not.toContain('leaked.txt') - }) - - it('renders the produced files under the closing message and opens one on click', () => { - const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ - ...toolResult(seq, callId, 'write'), - callView: { - card: 'diff', title: 'Write', - diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), - locations: paths.map(path => ({ path })), - }, - }) - // Seven files: six chips plus an explicit remainder — the row bounds what - // it shows and says so rather than dropping the rest silently. - const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts'] - const h = makeHarness({ - nodes: [user(1, 'build it'), wrote(2, 'w', ...paths), assistant(3, 'done', 1)], - }) - const view = render(<h.ChatView {...h.props} />) - expect(view.getByText('产物')).toBeTruthy() - // Chips carry the basename; the full path stays reachable as the title. - const chip = view.getByRole('button', { name: '打开 deep/a.html' }) - expect(chip.textContent).toBe('a.html') - expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull() - expect(view.getByText('还有 1 个')).toBeTruthy() - fireEvent.click(chip) - expect(h.openFile).toHaveBeenCalledWith('deep/a.html') - }) - it('runningTurnStartTime selects the latest turn/start without a turn/end', () => { expect(runningTurnStartTime(new Map([ [1, { startTime: 1_000, endTime: 5_000 }], @@ -790,7 +703,9 @@ describe('ChatView', () => { // Count renderSlot invocations: the memo boundary holds when CallRow does // not re-render, so the row's renderSlot call count freezes during chunks. let rowRenders = 0 - h.props.renderSlot = ((_key: string, _owner: object) => { + h.props.renderSlot = ((key: string, _owner: object) => { + // The turnTail hole renders through the same share; only tool rows count here. + if (key !== 'conversation.chat.toolview') return null rowRenders += 1 return <div data-testid="counting-row" /> }) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml new file mode 100644 index 0000000000..ee4c23c18c --- /dev/null +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# 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-deliverables/README.md +README.md: b8b0ea2ef1cbc9b18b905fc08b41278f403ef043 +README.zh.md: a16535b8a8d3625ca1cf90e88c6d9dca742d916b diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md new file mode 100644 index 0000000000..b8b0ea2ef1 --- /dev/null +++ b/packages/client/ui-deliverables/README.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-client-ui-deliverables + +English | [中文](README.zh.md) + +Produced-files feature owner: registers the deliverables row a finished turn ends with into the chat view's `conversation.chat.turnTail` hole. All policy lives here; removing this plugin's line from cordis.yml removes the surface entirely, and the owning view renders an empty hole at zero cost. + +`producedForClosing` derives one turn's produced files from the tail hole's owner currency — the finalized snapshot nodes and the closing assistant's seq. The vocabulary is the mutation tools' own follow-along `locations`, never the closing prose: a produced file is listed whether or not the model remembered to name it. A mutation is recognized by render intent, not tool name — a diff card, or a generic card whose `kind` is `edit` (the shape `str_replace_editor`'s insert presents) — so a new mutation tool joins by declaring what it does. Reads, deletes, and failed calls contribute nothing; a path appears once per turn in first-seen order; accumulation resets on the turn boundary, so a turn that mutates and then ends without content text cannot spill into the next turn's row. + +`ProducedFiles` renders the row between the closing message's body and its IconActions footer: a quiet label, up to six chips (basename text, full path as the `title`), and an explicit remainder count past the cap. Each chip opens through the owner-supplied `openFile` — the same Host opener the tool rows use, with the chat view resolving relative paths against the session cwd. Design rationale: the [workspace file links Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md). + +## Model Experience + +None, as the row is a pure client derivation over already-logged tool metadata and nothing here reaches a model request. + +#### KV Cache effect + +None; this package neither assembles nor sends provider requests. + +## Known Limitations and Deferred Work + +- **Prose mentions stay inert.** An inline-code file name in the closing message does not open the file yet; linking it to the same `locations` vocabulary is the stacked follow-up. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md new file mode 100644 index 0000000000..a16535b8a8 --- /dev/null +++ b/packages/client/ui-deliverables/README.zh.md @@ -0,0 +1,21 @@ +# @deepseek-ai/dsh-client-ui-deliverables + +[English](README.md) | 中文 + +产物文件的功能属主:把"完成的一轮以其产出文件收尾"的产物行注册进 chat 视图的 `conversation.chat.turnTail` 空位。全部策略都在本包内;从 cordis.yml 中删去本插件那一行即可整体移除该交互面,属主视图以零成本渲染一个空的空位。 + +`producedForClosing` 从 tail 空位的 owner 通货——定稿的快照节点与收尾 assistant 的 seq——推导一轮产出的文件。词表是改写工具自身的跟随 `locations`,绝不是收尾正文:无论模型是否记得点名,产出文件都会被列出。改写按渲染意图识别而非工具名——diff 卡片,或 `kind` 为 `edit` 的 generic 卡片(即 `str_replace_editor` 的 insert 所呈现的形状)——因此新的改写工具靠声明自己做了什么加入。read、删除与失败的调用不贡献任何条目;同一路径在一轮内按首见顺序只出现一次;累积在 turn 边界重置,因此一轮若先改写文件、随后没有正文内容就结束,不会溢进下一轮的行里。 + +`ProducedFiles` 在收尾消息正文与其 IconActions 之间渲染该行:一个安静的标签、至多六枚 chip(文本为文件名,完整路径作为 `title`),超出上限则显示一个明确的剩余计数。每枚 chip 经由 owner 提供的 `openFile` 打开——与工具行相同的 Host 打开器,chat 视图会把相对路径按会话 cwd 解析。设计原理:[workspace 文件链接 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md)。 + +## 模型体验 + +无。该行是对已记录工具元数据的纯客户端派生,这里没有任何内容进入模型请求。 + +#### KV Cache 影响 + +无;该包既不组装也不发送提供方请求。 + +## 已知限制与暂缓事项 + +- **正文提及仍是死文本。**收尾消息里以行内代码写出的文件名尚不能点击打开;把它接到同一份 `locations` 词表是 stacked 的后续工作。 diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json new file mode 100644 index 0000000000..0b5318499b --- /dev/null +++ b/packages/client/ui-deliverables/package.json @@ -0,0 +1,65 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-deliverables", + "description": "Produced-files turn tail: the deliverables row a finished turn ends with", + "version": "0.0.1", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation" + ], + "platform": "web" + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "dependencies": { + "react": "^18.2.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "^0.0.1", + "@deepseek-ai/dsh-client-runtime": "^0.0.1", + "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", + "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "cordis": "^4.0.0-rc.7" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-conversation/src/client/chat/Deliverables.module.css b/packages/client/ui-deliverables/src/client/ProducedFiles.module.css similarity index 100% rename from packages/client/ui-conversation/src/client/chat/Deliverables.module.css rename to packages/client/ui-deliverables/src/client/ProducedFiles.module.css diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx new file mode 100644 index 0000000000..609a688586 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx @@ -0,0 +1,61 @@ +// ProducedFiles: the produced-file row a finished turn ends with. The paths +// come from the mutation tools' follow-along locations (see +// producedForClosing), never from the closing prose, so the answer carries +// its own output whether or not the model remembered to name it. Clicking one +// goes through the same openFile the tool rows use — the Host's own opener, +// on the Host machine. + +import { useMemo } from 'react' +import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' +import { producedForClosing } from './turn-deliverables.ts' +import type { NS } from './locales.ts' +import css from './ProducedFiles.module.css' + +/** Files past this stay counted but unlisted: a refactor turn must not bury the answer. */ +const SHOWN = 6 + +/** Trailing path segment, the part that identifies the file at a glance. */ +function basename(path: string): string { + const at = Math.max(path.lastIndexOf('/'), path.lastIndexOf('\\')) + return at === -1 ? path : path.slice(at + 1) +} + +/** Full props: the turn-tail owner currency plus this plugin's locale seat. */ +export type ProducedFilesProps = TurnTailOwnerProps & PropsLocale<typeof NS> + +/** + * Render one turn's produced files as openable chips. + * @param props - the tail hole's owner currency (snapshot nodes, the closing + * assistant's seq, the chat view's file opener) and the locale seat. + * @returns The row, or `null` when the turn produced nothing. + */ +export function ProducedFiles({ nodes, seq, openFile, t }: ProducedFilesProps) { + // Per-closing-message derivation over the windowed snapshot: O(nodes) on + // node-identity change only, which is the same cadence the owning view + // re-derives its own flow at. + const paths = useMemo(() => producedForClosing(nodes, seq), [nodes, seq]) + if (paths.length === 0) return null + const shown = paths.slice(0, SHOWN) + const hidden = paths.length - shown.length + return ( + <div className={css.root}> + <span className={css.label}>{t('produced.label')}</span> + {shown.map(path => ( + <button + key={path} + type="button" + className={css.file} + // The full path is the disambiguator when two turns produce files + // that share a basename; the chip itself stays short. + title={path} + aria-label={t('produced.open', { name: path })} + onClick={() => { openFile(path) }} + > + {basename(path)} + </button> + ))} + {hidden > 0 && <span className={css.more}>{t('produced.more', { count: String(hidden) })}</span>} + </div> + ) +} diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts new file mode 100644 index 0000000000..536c019b01 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -0,0 +1,42 @@ +/** + * Deliverables plugin, browser half: registers the produced-files row into + * the chat view's turn-tail hole. All policy lives here — the derivation + * from the mutation tools' `locations`, the chip cap, and the copy — so + * composing this plugin out of cordis.yml removes the surface entirely; the + * owning view renders an empty hole at zero cost. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { ProducedFiles } from './ProducedFiles.tsx' +import { en, NS, zh, type DeliverablesKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Produced-files row copy. */ + 'deliverables': DeliverablesKey + } +} + +export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx' +export { producedForClosing } from './turn-deliverables.ts' + +/** Required services for the tail-slot registration and its dictionaries. */ +export const inject = ['slots', 'locale'] + +/** + * Client plugin body: register the dictionaries and the turn-tail entry. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') + ctx.slots.inject( + 'conversation.chat.turnTail', + () => ctx.slots.register({ + name: 'conversation.chat.turnTail', + id: 'produced-files', + order: 0, + locale: NS, + }, ProducedFiles), + ) +} diff --git a/packages/client/ui-deliverables/src/client/locales.ts b/packages/client/ui-deliverables/src/client/locales.ts new file mode 100644 index 0000000000..aa51aa75a7 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/locales.ts @@ -0,0 +1,21 @@ +/** `deliverables` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'deliverables' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'produced.label': '产物', + 'produced.more': '还有 {count} 个', + 'produced.open': '打开 {name}', +} + +/** English dictionary (same key set). */ +export const en: Record<DeliverablesKey, string> = { + 'produced.label': 'Produced', + 'produced.more': '{count} more', + 'produced.open': 'Open {name}', +} + +/** Union of this namespace's dictionary keys. */ +export type DeliverablesKey = keyof typeof zh diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts new file mode 100644 index 0000000000..faa0455b37 --- /dev/null +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -0,0 +1,78 @@ +/** + * Pure derivation of one turn's produced files from finalized snapshot + * nodes. Client-only and model-free: the vocabulary is the mutation tools' + * own follow-along `locations`, never the closing prose. + */ +import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' + +/** + * Paths a call view reports having created or changed, by render intent rather + * than tool name: a diff card, or a generic card whose kind is `edit` (the + * shape `str_replace_editor`'s insert presents). Every other card produces + * nothing to open — a read looked, a delete removed, a terminal ran. + */ +function producedPaths(view: ToolResultNode['callView']): readonly string[] { + if (view === null) return [] + if (view.card === 'diff') return (view.locations ?? []).map(location => location.path) + if (view.card === 'generic' && view.kind === 'edit') { + return (view.locations ?? []).map(location => location.path) + } + return [] +} + +/** + * Files produced by the turn the assistant at `seq` closes — the anchor the + * render site elects, so the row lands under the message that reports the + * work rather than after some mid-turn narration. + * + * The source is the mutation tools' own follow-along `locations`, not the + * closing prose: a produced file must be listed whether or not the model + * remembered to name it. A mutation is recognized by render intent, not by + * tool name — a diff card, or a generic card whose `kind` is `edit` (the shape + * `str_replace_editor`'s insert presents) — so a new mutation tool joins by + * declaring what it does. Reads contribute nothing (looking at a file does not + * produce it), and neither do deletes (there is nothing left to open) or + * failed calls. Paths keep first-seen order and appear once, so a file written + * and then edited in the same turn is one entry. + * + * Accumulation resets on the turn boundary — a user message, or a node + * reporting a different turn number — so a turn that mutates files and then + * ends without content text cannot spill its paths into the next turn's row, + * nor leave the dedup set suppressing a file the next turn legitimately + * rewrites. Tool results carry no turn of their own; the boundary is read off + * the nodes that do, and a user message resets the tracked turn to undefined + * because the next node to report one is stating the current turn, not + * entering a new one. + * @param nodes - snapshot nodes (surface order). + * @param seq - the closing assistant's seq (the render site's anchor). + * @returns Produced paths in first-seen order; empty when the turn wrote nothing. + */ +export function producedForClosing(nodes: readonly ConversationNode[], seq: number): readonly string[] { + let pending: string[] = [] + let seen = new Set<string>() + let turn: number | undefined + for (const node of nodes) { + if (node.kind === 'tool-result') { + if (node.isError) continue + for (const path of producedPaths(node.callView)) { + if (seen.has(path)) continue + seen.add(path) + pending.push(path) + } + continue + } + if (node.kind === 'user') { + turn = undefined + pending = [] + seen = new Set() + } else if ('turn' in node) { + if (turn !== undefined && node.turn !== turn) { + pending = [] + seen = new Set() + } + turn = node.turn + } + if (node.kind === 'assistant' && node.seq === seq) return pending + } + return [] +} diff --git a/packages/client/ui-deliverables/src/css-modules.d.ts b/packages/client/ui-deliverables/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-deliverables/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record<string, string> + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-deliverables/src/index.ts b/packages/client/ui-deliverables/src/index.ts new file mode 100644 index 0000000000..012876cc2d --- /dev/null +++ b/packages/client/ui-deliverables/src/index.ts @@ -0,0 +1,9 @@ +/** + * Deliverables plugin, node half. Pure UI plugin: the empty apply exists so + * the plugin appears in the host cordis.yml / Loader; the browser half ships + * via exports["./client"], discovered through the package.json dshClient + * declaration. + */ + +/** Host plugin body — no host-side behavior for this surface plugin. */ +export function apply(): void {} diff --git a/packages/client/ui-deliverables/src/invariant.ts b/packages/client/ui-deliverables/src/invariant.ts new file mode 100644 index 0000000000..39c39591cf --- /dev/null +++ b/packages/client/ui-deliverables/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-deliverables`. + * @module @deepseek-ai/dsh-client-ui-deliverables/invariant + */ + +/* jscpd:ignore-start */ +import type { Context } from 'cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-deliverables' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-deliverables-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: one slot registration and one dictionary + * registration, both effect-owned with disposal proven by the HMR-safety + * spec — the plugin emits no cordis events and owns no cross-plugin mutable + * state. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) +/* jscpd:ignore-end */ diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx new file mode 100644 index 0000000000..e5d92424a3 --- /dev/null +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -0,0 +1,183 @@ +// @vitest-environment jsdom +/** + * ui-deliverables browser half: the derivation contract of + * `producedForClosing` over finalized snapshot nodes, the row's rendering + * and opener wiring, and the plugin registrations' fiber-teardown removal + * (HMR safety) against the real SlotsService. + */ +import { Context } from 'cordis' +import { cleanup, fireEvent, render } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import type { + AssistantMessageNode, ConversationNode, ToolResultNode, UserMessageNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import { ProducedFiles } from '../src/client/ProducedFiles.tsx' +import { producedForClosing } from '../src/client/turn-deliverables.ts' +import { apply, inject } from '../src/client/index.ts' +import { apply as applyNode } from '../src/index.ts' +import { apply as applyInvariant } from '../src/invariant.ts' +import { zh } from '../src/client/locales.ts' + +afterEach(cleanup) + +const user = (seq: number, text: string): UserMessageNode => ({ + kind: 'user', + seq, + time: seq * 1000, + content: [{ type: 'text', text }] as never, + source: null, +}) +const assistant = (seq: number, text: string, turn = 1): AssistantMessageNode => ({ + kind: 'assistant', seq, time: seq * 1_000, turn, step: 1, blocks: [{ kind: 'text', text }], +}) +const toolResult = (seq: number, callId: string, name = 'bash'): ToolResultNode => ({ + kind: 'tool-result', seq, time: seq * 1_000, callId, + call: { name, argsRaw: `{"command":"cmd-${callId}","description":"run ${callId}"}` }, + callTime: seq * 1_000 - 500, + content: [], isError: false, callView: null, resultView: null, +}) +const wrote = (seq: number, callId: string, ...paths: string[]): ToolResultNode => ({ + ...toolResult(seq, callId, 'write'), + callView: { + card: 'diff', title: `Write ${paths[0] ?? ''}`, + diffs: paths.map(path => ({ path, oldText: null, newText: 'x' })), + locations: paths.map(path => ({ path })), + }, +}) + +describe('producedForClosing derivation', () => { + it('attributes each turn’s written files to the assistant that closes it', () => { + const nodes: ConversationNode[] = [ + user(1, 'build it'), + assistant(2, 'writing', 1), + wrote(3, 'a', 'out/index.html'), + // Same file touched twice in one turn is one deliverable, in first-seen order. + wrote(4, 'b', 'out/app.css', 'out/index.html'), + // A read is not a deliverable; a failed write has no file to open. + { ...toolResult(5, 'c', 'read'), callView: { card: 'generic', title: 'Read x', locations: [{ path: 'x.ts' }] } }, + { ...wrote(6, 'd', 'out/broken.html'), isError: true }, + assistant(7, 'done', 1), + user(8, 'again'), + assistant(9, 'second turn', 2), + ] + expect(producedForClosing(nodes, 7)).toEqual(['out/index.html', 'out/app.css']) + // A turn that produced nothing yields the empty list, and so does an + // anchor the window does not contain. + expect(producedForClosing(nodes, 9)).toEqual([]) + expect(producedForClosing([user(1, 'hi'), assistant(2, 'hello', 1)], 2)).toEqual([]) + expect(producedForClosing(nodes, 999)).toEqual([]) + }) + + it('counts a generic edit and never spills across the turn boundary', () => { + const inserted = (seq: number, callId: string, path: string): ToolResultNode => ({ + ...toolResult(seq, callId, 'str_replace_editor'), + // str_replace_editor's insert mutates behind a generic card, so the + // discriminant is the render intent, not the card shape alone. + callView: { card: 'generic', title: `insert ${path}`, kind: 'edit', locations: [{ path }] }, + }) + const nodes: ConversationNode[] = [ + user(1, 'insert a line'), + inserted(2, 'i', 'notes.md'), + assistant(3, 'inserted', 1), + // Turn 2 mutates and then ends with no content text (interrupted, or its + // last text preceded the tool): its paths must not ride into turn 3. + user(4, 'now rewrite it'), + wrote(5, 'w', 'leaked.txt'), + user(6, 'and again'), + wrote(7, 'w2', 'notes.md'), + assistant(8, 'done', 3), + ] + expect(producedForClosing(nodes, 3)).toEqual(['notes.md']) + // Turn 3 lists only its own file — and the dedup set did not suppress the + // rewrite of a path an earlier turn already touched. + expect(producedForClosing(nodes, 8)).toEqual(['notes.md']) + expect(producedForClosing(nodes, 8)).not.toContain('leaked.txt') + }) + + it('resets on a turn-number change and skips turnless, viewless, and locationless nodes', () => { + const nodes: ConversationNode[] = [ + user(1, 'go'), + // A turnless surface node neither tracks nor resets the boundary. + { kind: 'unknown', seq: 1.5, time: 1_500, type: 'x', data: null }, + wrote(2, 'w', 'turn-one.txt'), + // A view-less result (window truncation) and cards without locations + // contribute nothing rather than crashing the walk. + toolResult(3, 'plain'), + { ...toolResult(4, 'nl', 'write'), callView: { card: 'diff', title: 'Write', diffs: [] } }, + { ...toolResult(5, 'ge', 'str_replace_editor'), callView: { card: 'generic', title: 'insert', kind: 'edit' } }, + assistant(6, 'mid narration', 1), + // Turn number advances with no user message in the window (truncated + // history): the accumulator must reset all the same. + assistant(7, 'closing', 2), + ] + expect(producedForClosing(nodes, 6)).toEqual(['turn-one.txt']) + expect(producedForClosing(nodes, 7)).toEqual([]) + }) +}) + +describe('ProducedFiles row', () => { + const t = makeTranslate(zh) + + it('renders capped chips with the full path reachable and opens one on click', () => { + // Seven files: six chips plus an explicit remainder — the row bounds what + // it shows and says so rather than dropping the rest silently. + const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts'] + const openFile = vi.fn<(path: string) => void>() + const nodes: ConversationNode[] = [user(1, 'build it'), wrote(2, 'w', ...paths), assistant(3, 'done', 1)] + const view = render(<ProducedFiles nodes={nodes} seq={3} openFile={openFile} t={t} />) + expect(view.getByText('产物')).toBeTruthy() + // Chips carry the basename; the full path stays reachable as the title. + const chip = view.getByRole('button', { name: '打开 deep/a.html' }) + expect(chip.textContent).toBe('a.html') + expect(chip.getAttribute('title')).toBe('deep/a.html') + expect(view.queryByRole('button', { name: '打开 g.ts' })).toBeNull() + expect(view.getByText('还有 1 个')).toBeTruthy() + fireEvent.click(chip) + expect(openFile).toHaveBeenCalledWith('deep/a.html') + }) + + it('a turn that produced nothing renders no row at all', () => { + const nodes: ConversationNode[] = [user(1, 'hi'), assistant(2, 'hello', 1)] + const view = render(<ProducedFiles nodes={nodes} seq={2} openFile={() => {}} t={t} />) + expect(view.container.firstChild).toBeNull() + }) +}) + +describe('package shells', () => { + it('the node half mounts inert and the invariant companion registers ownership', async () => { + // The node half is deliberately inert; mounting it must simply not throw. + applyNode() + const registered: string[] = [] + const ctx = new Context() + ctx.provide('invariants') + ctx.set('invariants', { + register: (pkg: string) => { registered.push(pkg); return () => {} }, + } as never) + const dispose = await applyInvariant(ctx) + expect(registered).toEqual(['@deepseek-ai/dsh-client-ui-deliverables']) + expect(dispose).toBeTypeOf('function') + }) +}) + +describe('plugin registration', () => { + it('registers the tail entry and fiber disposal removes it', async () => { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + // The owning view's child declaration, stood up by a bench root entry. + ctx.slots.register({ + name: 'root', + children: { 'conversation.chat.turnTail': { kind: 'list', scope: 'session' } }, + } as never, () => null) + await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() + + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(1) + + await fiber.dispose() + expect(ctx.slots.entries('conversation.chat.turnTail')).toHaveLength(0) + }) +}) diff --git a/packages/client/ui-deliverables/tsconfig.json b/packages/client/ui-deliverables/tsconfig.json new file mode 100644 index 0000000000..3fa938986f --- /dev/null +++ b/packages/client/ui-deliverables/tsconfig.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../locale" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-deliverables/tsdown.config.ts b/packages/client/ui-deliverables/tsdown.config.ts new file mode 100644 index 0000000000..ce1a8cefcc --- /dev/null +++ b/packages/client/ui-deliverables/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-deliverables', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 208e43aa5f..e50fa70b2a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -179,6 +179,9 @@ importers: '@deepseek-ai/dsh-client-ui-conversation': specifier: workspace:^ version: link:../../packages/client/ui-conversation + '@deepseek-ai/dsh-client-ui-deliverables': + specifier: workspace:^ + version: link:../../packages/client/ui-deliverables '@deepseek-ai/dsh-client-ui-goal': specifier: workspace:^ version: link:../../packages/client/ui-goal @@ -1440,6 +1443,37 @@ importers: specifier: ^18.2.0 version: 18.3.1 + packages/client/ui-deliverables: + dependencies: + react: + specifier: ^18.2.0 + version: 18.3.1 + devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/client/ui-goal: devDependencies: '@deepseek-ai/dsh-client-connection': diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 041972cb9f..cb7ffb7460 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -61,6 +61,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = { 'packages/client/ui-layout': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-sidebar': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-deliverables': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' }, 'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the host snapshots the target at the next prompt-assembly boundary and owns the model-visible effect.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 9ba9ba5d84..94e08da920 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -151,6 +151,7 @@ "@deepseek-ai/dsh-client-ui-layout": ["./packages/client/ui-layout/src"], "@deepseek-ai/dsh-client-ui-sidebar": ["./packages/client/ui-sidebar/src"], "@deepseek-ai/dsh-client-ui-conversation": ["./packages/client/ui-conversation/src"], + "@deepseek-ai/dsh-client-ui-deliverables": ["./packages/client/ui-deliverables/src"], "@deepseek-ai/dsh-client-ui-slash": ["./packages/client/ui-slash/src"], "@deepseek-ai/dsh-client-ui-command": ["./packages/client/ui-command/src"], "@deepseek-ai/dsh-client-ui-model": ["./packages/client/ui-model/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index e1d4088061..03a2b8bb59 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -56,6 +56,7 @@ { "path": "./packages/client/ui-layout" }, { "path": "./packages/client/ui-sidebar" }, { "path": "./packages/client/ui-conversation" }, + { "path": "./packages/client/ui-deliverables" }, { "path": "./packages/client/ui-workspace" }, { "path": "./packages/client/ui-slash" }, { "path": "./packages/client/ui-command" }, From 7aedc02ae32b1f049dfe8a5ca5a61f43a7748837 Mon Sep 17 00:00:00 2001 From: ZiyaZhang <199893125+ZiyaZhang@users.noreply.github.com> Date: Thu, 6 Aug 2026 06:39:08 -0700 Subject: [PATCH 09/12] docs: regenerate module graph for ui-deliverables --- docs/module-graph.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/module-graph.md b/docs/module-graph.md index 3ad8ef0b7e..70f2570aa1 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -159,6 +159,7 @@ flowchart TD pkg_client_test_runtime["client-test-runtime"] pkg_client_ui_command["client-ui-command"] pkg_client_ui_conversation["client-ui-conversation"] + pkg_client_ui_deliverables["client-ui-deliverables"] pkg_client_ui_goal["client-ui-goal"] pkg_client_ui_layout["client-ui-layout"] pkg_client_ui_model["client-ui-model"] @@ -840,6 +841,11 @@ flowchart TD pkg_client_ui_command --> pkg_client_ui_slash pkg_client_ui_command --> pkg_client_ui_slots pkg_client_ui_command --> pkg_invariants + pkg_client_ui_deliverables --> pkg_client_locale + pkg_client_ui_deliverables --> pkg_client_runtime + pkg_client_ui_deliverables --> pkg_client_ui_conversation + pkg_client_ui_deliverables --> pkg_client_ui_slots + pkg_client_ui_deliverables --> pkg_invariants pkg_client_ui_goal --> pkg_client_connection pkg_client_ui_goal --> pkg_client_locale pkg_client_ui_goal --> pkg_client_runtime @@ -1243,6 +1249,7 @@ flowchart TD | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/ui/tool-ask-user) | `ui` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/ui/user-interaction) | | [`client-ui-command`](../packages/client/ui-command) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | +| [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | From 7a76365585aac59a8c3ad8f7554cb104162ddf02 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 7 Aug 2026 13:27:15 +0800 Subject: [PATCH 10/12] feat(web): restyle hero preview badge and grow wide-sidebar settings icon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hero Preview tag becomes a superscript mono pill riding the title's top-right (r24, bordered, business-tertiary fill), colored by the new --dsw-alias-label-primary-bluish alias over --dsw-static-blue-900 — the first design-owner-approved addition under the token-sheet authority exception recorded in the ui-theme README. The expanded sidebar trigger now uses the native 16px settings icon instead of the 14px asset. --- .../src/client/skeleton/HeroShell.module.css | 22 +++++++++++-------- .../ui-settings-general/src/client/chrome.tsx | 4 ++-- packages/client/ui-theme/README.i18n.yaml | 4 ++-- packages/client/ui-theme/README.md | 2 +- packages/client/ui-theme/README.zh.md | 2 +- .../ui-theme/src/styles/design-platform.css | 4 ++++ 6 files changed, 23 insertions(+), 15 deletions(-) diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index 3166a81565..0e730a5b30 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -24,12 +24,12 @@ } /* figma 34:10411: fish + title, gap 10, centered; 26/32 wt500. The preview - badge is a product addition outside that source and aligns to the title. */ + badge is a product addition outside that source: a mono superscript pill + riding the title's top-right. */ .headline { display: grid; - grid-template-columns: 34px auto; + grid-template-columns: 34px auto auto; column-gap: 10px; - row-gap: 4px; align-items: center; justify-content: center; font-size: 26px; @@ -44,13 +44,17 @@ } .previewBadge { - grid-row: 2; - grid-column: 2; - justify-self: start; - padding: 0 4px; - border-radius: 4px; + grid-row: 1; + grid-column: 3; + align-self: start; + margin-top: 2px; + margin-left: -3px; + padding: 1px 7px 0; + border: 1px solid var(--dsw-alias-interactive-bg-hover); + border-radius: 24px; background: var(--dsw-alias-state-business-tertiary); - color: var(--dsw-alias-label-primary); + color: var(--dsw-alias-label-primary-bluish); + font-family: var(--ds-font-family-code); font-size: 12px; line-height: 18px; font-weight: 500; diff --git a/packages/client/ui-settings-general/src/client/chrome.tsx b/packages/client/ui-settings-general/src/client/chrome.tsx index 28af15ab2f..9698d90868 100644 --- a/packages/client/ui-settings-general/src/client/chrome.tsx +++ b/packages/client/ui-settings-general/src/client/chrome.tsx @@ -4,7 +4,7 @@ * The shell renders the surrounding chrome (button, nav heading row) and * reads each entry's `label` option for aria text. */ -import { IconSettingsOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconSettingsOutline14, IconSettingsOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import css from './chrome.module.css' @@ -22,7 +22,7 @@ export type HeaderContentProps = PropsRuntime<'settings.header'> & PropsLocale<' export function TriggerContent({ wide, t }: TriggerContentProps) { return ( <> - <IconSettingsOutline14 size={wide ? 14 : 18} /> + {wide ? <IconSettingsOutline16 size={16} /> : <IconSettingsOutline14 size={18} />} {wide && <span className={css.triggerLabel}>{t('trigger')}</span>} </> ) diff --git a/packages/client/ui-theme/README.i18n.yaml b/packages/client/ui-theme/README.i18n.yaml index 76bcbaf608..eb915bff8d 100644 --- a/packages/client/ui-theme/README.i18n.yaml +++ b/packages/client/ui-theme/README.i18n.yaml @@ -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-theme/README.md -README.md: 88e21fe214ec806b101050949690283d811be36d -README.zh.md: ba781ba89a62292928a7b05ab94ea1cd930b4f50 +README.md: 648213b258169b2e8869a93d058fcc65aece175e +README.zh.md: b807ebd66253c91dc8b79f01ac4d3b2335682001 diff --git a/packages/client/ui-theme/README.md b/packages/client/ui-theme/README.md index 88e21fe214..648213b258 100644 --- a/packages/client/ui-theme/README.md +++ b/packages/client/ui-theme/README.md @@ -21,4 +21,4 @@ None; this package neither assembles nor sends a provider request. ## Known Limitations and Deferred Work - **Third-party themes are a surface, not a product** — registering one means overriding same-named alias variables; no validation exists that an override set is complete. -- **The token sheets are the sole color authority** — values absent from cssdesign (for example the design's #4176E6 tab blue) are deliberately not appended; the nearest semantic token wins (arbitrated 2026-07-22). +- **The token sheets are the sole color authority** — values absent from cssdesign (for example the design's #4176E6 tab blue) are deliberately not appended; the nearest semantic token wins (arbitrated 2026-07-22). Design-owner-approved additions are the exception and enter as a static step plus a semantic alias in the same change (`--dsw-static-blue-900` / `--dsw-alias-label-primary-bluish`, 2026-08-07). diff --git a/packages/client/ui-theme/README.zh.md b/packages/client/ui-theme/README.zh.md index ba781ba89a..b807ebd662 100644 --- a/packages/client/ui-theme/README.zh.md +++ b/packages/client/ui-theme/README.zh.md @@ -21,4 +21,4 @@ ## 已知限制与暂缓事项 - **第三方主题是表层,不是产品**:注册主题意味着覆盖同名别名变量;目前不会验证一组覆盖是否完整。 -- **token 样式表是颜色值的唯一权威来源**:会有意不补入 cssdesign 中缺失的值(例如设计中的 #4176E6 标签页蓝色);一律采用最接近的语义 token(裁定于 2026-07-22)。 +- **token 样式表是颜色值的唯一权威来源**:会有意不补入 cssdesign 中缺失的值(例如设计中的 #4176E6 标签页蓝色);一律采用最接近的语义 token(裁定于 2026-07-22)。设计负责人批准的新增值是例外:须在同一变更中以一个 static 梯度值加一个语义 alias 的形式进入(`--dsw-static-blue-900` / `--dsw-alias-label-primary-bluish`,2026-08-07)。 diff --git a/packages/client/ui-theme/src/styles/design-platform.css b/packages/client/ui-theme/src/styles/design-platform.css index 3e8710822e..00d9d7106b 100644 --- a/packages/client/ui-theme/src/styles/design-platform.css +++ b/packages/client/ui-theme/src/styles/design-platform.css @@ -17,6 +17,7 @@ body { --dsw-static-blue-600: rgb(37, 99, 235); --dsw-static-blue-75: rgb(229, 240, 255); --dsw-static-blue-800: rgb(30, 64, 175); + --dsw-static-blue-900: rgb(14, 48, 116); --dsw-static-blue-950: rgb(23, 37, 84); --dsw-static-deepseek-100: rgb(228, 237, 253); --dsw-static-deepseek-200: rgb(211, 226, 255); @@ -92,6 +93,7 @@ body[data-ds-dark-theme] { --dsw-static-blue-600: rgb(37, 99, 235); --dsw-static-blue-75: rgb(229, 240, 255); --dsw-static-blue-800: rgb(30, 64, 175); + --dsw-static-blue-900: rgb(14, 48, 116); --dsw-static-blue-950: rgb(23, 37, 84); --dsw-static-deepseek-100: rgb(228, 237, 253); --dsw-static-deepseek-200: rgb(211, 226, 255); @@ -197,6 +199,7 @@ body { --dsw-alias-interactive-bg-hover: rgba(38, 49, 72, 0.06); --dsw-alias-label-caption: var(--dsw-static-neutral-bluish-400); --dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-200); + --dsw-alias-label-primary-bluish: var(--dsw-static-blue-900); --dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-950); --dsw-alias-label-primary-foreground: var(--dsw-static-neutral-bluish-00); --dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-00); @@ -287,6 +290,7 @@ body[data-ds-dark-theme] { --dsw-alias-interactive-bg-hover: rgba(255, 255, 255, 0.08); --dsw-alias-label-caption: var(--dsw-static-neutral-bluish-600); --dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-750); + --dsw-alias-label-primary-bluish: var(--dsw-static-neutral-bluish-50); --dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-100); --dsw-alias-label-primary-foreground: var(--dsw-static-neutral-bluish-1000); --dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-800); From 2efce69d7921a0b2e1610f15d6efb5344b3b5b83 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:15:27 +0800 Subject: [PATCH 11/12] fix(client): route turn-tail through chain selector --- .../ui-conversation/src/client/apply.ts | 2 +- .../src/client/chat/AssistantMarkdown.tsx | 14 ++++----- .../src/client/chat/ChatView.tsx | 6 ++-- .../src/client/contract/slots.ts | 11 ++++--- .../ui-conversation/tests/chat-view.spec.tsx | 4 ++- .../src/client/ProducedFiles.tsx | 29 +++++++------------ .../ui-deliverables/src/client/index.ts | 4 +-- .../src/client/turn-deliverables.ts | 11 +++++++ .../tests/produced-files.spec.tsx | 15 ++++------ 9 files changed, 48 insertions(+), 48 deletions(-) diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index 8eb78139c4..24325c714e 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -303,7 +303,7 @@ export function apply(ctx: Context): void { children: { 'conversation.chat.toolview': { kind: 'keyed', scope: 'session' }, 'conversation.chat.commandview': { kind: 'keyed', scope: 'session' }, - 'conversation.chat.turnTail': { kind: 'list', scope: 'session' }, + 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' }, }, store: chatStore, inject: (sessionId: SessionId, actions: BoundActions<typeof chatStore>): ChatViewInjected => { diff --git a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx index 687e1ae86c..bc1c6c7e32 100644 --- a/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx +++ b/packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx @@ -9,12 +9,13 @@ // their branch action is enabled only when the node is also the completed // turn's transcript tail. Think / tool-head-only nodes stay chrome-free. -import { memo, useMemo, type ReactNode } from 'react' +import { memo, useMemo } from 'react' import type { AssistantBlock } from '@deepseek-ai/dsh-client-runtime/client' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' import { IconThinkOutline14, JsonBlock, MarkdownText, } from '@deepseek-ai/dsh-client-ui-primitives' -import type { ChatViewSlotProps } from '../contract/slots.ts' +import type { ChatViewSlotProps, TurnTailOwnerProps } from '../contract/slots.ts' import { hasContentText } from './chat-flow.ts' import { MessageIconActions } from './MessageIconActions.tsx' import { ToolRow } from './ToolRow.tsx' @@ -40,9 +41,8 @@ export interface AssistantMarkdownProps { seq?: number | undefined /** Fork the session through this finalized message's completed turn when eligible. */ onFork?: ((seq: number) => void) | undefined - /** Turn-tail content (the chat view's turnTail hole, rendered by the - * owner); omitted for a mid-turn assistant. */ - tail?: ReactNode | undefined + /** Turn-tail slot dispatch share and owner currency; omitted for a mid-turn assistant. */ + turnTail?: (Pick<PropsRenderSlots<'conversation.chat.turnTail'>, 'renderSlotChain'> & { owner: TurnTailOwnerProps }) | undefined /** The message is not the transcript tail of a completed turn. */ forkUnavailable?: boolean | undefined /** The owning view's locale seat, passed down as a plain prop. */ @@ -86,7 +86,7 @@ function ThinkRow({ text, running, t }: { text: string; running: boolean; t: Ass } export const AssistantMarkdown = memo(function AssistantMarkdown({ - blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, tail, t, + blocks, streaming, interrupted, time, runMs, ttftMs, tokensPerSecond, seq, onFork, forkUnavailable, turnTail, t, }: AssistantMarkdownProps) { // Stable per locale revision (t identity changes on switch): a fresh object // per render would rebuild MarkdownText's component table every chunk. @@ -124,7 +124,7 @@ export const AssistantMarkdown = memo(function AssistantMarkdown({ })} {interrupted && <span className={css.stopped}>{t('message.stopped')}</span>} </div> - {showActions && tail} + {showActions && turnTail?.renderSlotChain('conversation.chat.turnTail', turnTail.owner)} {showActions && ( <MessageIconActions text={copyText(blocks)} diff --git a/packages/client/ui-conversation/src/client/chat/ChatView.tsx b/packages/client/ui-conversation/src/client/chat/ChatView.tsx index ee43ff080d..b0907f5a80 100644 --- a/packages/client/ui-conversation/src/client/chat/ChatView.tsx +++ b/packages/client/ui-conversation/src/client/chat/ChatView.tsx @@ -335,7 +335,7 @@ function StreamingTail({ useSession, t }: { * render through the declared keyed hole's renderSlot share). */ export function ChatView({ - useSession, useSessions, useStore, renderSlot, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t, + useSession, useSessions, useStore, renderSlot, renderSlotChain, sessionId, openFile, loadOlder, inspectCall, chatScroll, forkAt, t, }: ChatViewSlotProps) { const nodes = useSession(s => s.nodes) const turnTimings = useSession(s => s.turnTimings) @@ -600,8 +600,8 @@ export function ChatView({ seq={node.seq} onFork={forkAt} forkUnavailable={!branchSeqs.has(node.seq)} - tail={actionSeqs.has(node.seq) - ? renderSlot('conversation.chat.turnTail', { nodes, seq: node.seq, openFile }) + turnTail={actionSeqs.has(node.seq) + ? { renderSlotChain, owner: { nodes, seq: node.seq, openFile } } : undefined} t={t} /> diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index 1246433a33..89f7986dba 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -47,14 +47,13 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { */ 'conversation.chat.commandview': { kind: 'keyed'; scope: 'session'; owner: CommandRowOwnerProps } /** - * The chat view's turn-tail hole: rendered between a closing assistant + * The chat view's turn-tail chain: rendered between a closing assistant * message's body and its IconActions footer, once per turn (the render - * site elects the closing seq). Declared by the chat view entry; feature - * plugins (ui-deliverables' produced-files row) derive what they show - * from the owner currency, and an unregistered hole renders nothing — - * composing such a plugin out of cordis.yml turns its surface off. + * site elects the closing seq). Entries derive a match from the owner + * currency before mounting, so presentation components never mount only + * to return null; an all-declined chain renders nothing. */ - 'conversation.chat.turnTail': { kind: 'list'; scope: 'session'; owner: TurnTailOwnerProps } + 'conversation.chat.turnTail': { kind: 'chain'; scope: 'session'; owner: TurnTailOwnerProps } /** * The composer takeover chain: entries are selector-routed replacements * of the default InputBar. Declared by this package's 'conversation' diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index f2bb8709d7..b8cd94de52 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -130,6 +130,8 @@ function makeHarness(init?: Partial<ConversationSnapshot>) { const chat = createChatStore().create() const renderSlot = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) => opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlot'] + const renderSlotChain = ((_key: string, _owner: object, opts?: { fallback?: React.ReactNode }) => + opts?.fallback ?? null) as unknown as ChatViewSlotProps['renderSlotChain'] // SessionProvider seat arrives with the session-scope child declaration; // ChatView never invokes it (render-prop pass-through stub). const SessionProviderStub: ChatViewSlotProps['SessionProvider'] = ({ children }) => <>{children(SID)}</> @@ -144,6 +146,7 @@ function makeHarness(init?: Partial<ConversationSnapshot>) { useStore: bindSnapshotSelector(chat), actions: chat.actions, renderSlot, + renderSlotChain, SessionProvider: SessionProviderStub, openDetails, openFile, @@ -733,7 +736,6 @@ describe('ChatView', () => { // not re-render, so the row's renderSlot call count freezes during chunks. let rowRenders = 0 h.props.renderSlot = ((key: string, _owner: object) => { - // The turnTail hole renders through the same share; only tool rows count here. if (key !== 'conversation.chat.toolview') return null rowRenders += 1 return <div data-testid="counting-row" /> diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx index 609a688586..ab85869de2 100644 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx @@ -1,14 +1,11 @@ // ProducedFiles: the produced-file row a finished turn ends with. The paths -// come from the mutation tools' follow-along locations (see -// producedForClosing), never from the closing prose, so the answer carries -// its own output whether or not the model remembered to name it. Clicking one -// goes through the same openFile the tool rows use — the Host's own opener, -// on the Host machine. +// come pre-matched by the turn-tail chain from the mutation tools' +// follow-along locations, never from the closing prose. Clicking one goes +// through the same openFile the tool rows use — the Host's own opener, on the +// Host machine. -import { useMemo } from 'react' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import { producedForClosing } from './turn-deliverables.ts' import type { NS } from './locales.ts' import css from './ProducedFiles.module.css' @@ -21,21 +18,17 @@ function basename(path: string): string { return at === -1 ? path : path.slice(at + 1) } -/** Full props: the turn-tail owner currency plus this plugin's locale seat. */ -export type ProducedFilesProps = TurnTailOwnerProps & PropsLocale<typeof NS> +/** Matched paths plus the opener and locale seats needed to present them. */ +export type ProducedFilesProps = Pick<TurnTailOwnerProps, 'openFile'> & { + matched: readonly string[] +} & PropsLocale<typeof NS> /** * Render one turn's produced files as openable chips. - * @param props - the tail hole's owner currency (snapshot nodes, the closing - * assistant's seq, the chat view's file opener) and the locale seat. - * @returns The row, or `null` when the turn produced nothing. + * @param props - selector-matched paths, the chat view's file opener, and the locale seat. + * @returns The produced-files row. */ -export function ProducedFiles({ nodes, seq, openFile, t }: ProducedFilesProps) { - // Per-closing-message derivation over the windowed snapshot: O(nodes) on - // node-identity change only, which is the same cadence the owning view - // re-derives its own flow at. - const paths = useMemo(() => producedForClosing(nodes, seq), [nodes, seq]) - if (paths.length === 0) return null +export function ProducedFiles({ matched: paths, openFile, t }: ProducedFilesProps) { const shown = paths.slice(0, SHOWN) const hidden = paths.length - shown.length return ( diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 536c019b01..6dc7bc4b84 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -10,6 +10,7 @@ import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import { ProducedFiles } from './ProducedFiles.tsx' import { en, NS, zh, type DeliverablesKey } from './locales.ts' +import { selectProducedFiles } from './turn-deliverables.ts' declare module '@deepseek-ai/dsh-client-ui-slots' { interface LocaleNamespaceMap { @@ -34,8 +35,7 @@ export function apply(ctx: ClientContext): void { 'conversation.chat.turnTail', () => ctx.slots.register({ name: 'conversation.chat.turnTail', - id: 'produced-files', - order: 0, + select: selectProducedFiles, locale: NS, }, ProducedFiles), ) diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index faa0455b37..a3ddf40b59 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -4,6 +4,7 @@ * own follow-along `locations`, never the closing prose. */ import type { ConversationNode, ToolResultNode } from '@deepseek-ai/dsh-client-runtime/client' +import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-conversation/client' /** * Paths a call view reports having created or changed, by render intent rather @@ -76,3 +77,13 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb } return [] } + +/** + * Claim the turn-tail chain only when its closing turn produced files. + * @param owner - Turn-tail owner currency for the closing assistant. + * @returns Produced paths as the component's match, or null to decline before mount. + */ +export function selectProducedFiles({ nodes, seq }: TurnTailOwnerProps): readonly string[] | null { + const paths = producedForClosing(nodes, seq) + return paths.length === 0 ? null : paths +} diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index e5d92424a3..49e41ebd86 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -15,7 +15,7 @@ import type { import { apply as applyLocale } from '@deepseek-ai/dsh-client-locale/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { ProducedFiles } from '../src/client/ProducedFiles.tsx' -import { producedForClosing } from '../src/client/turn-deliverables.ts' +import { producedForClosing, selectProducedFiles } from '../src/client/turn-deliverables.ts' import { apply, inject } from '../src/client/index.ts' import { apply as applyNode } from '../src/index.ts' import { apply as applyInvariant } from '../src/invariant.ts' @@ -64,6 +64,8 @@ describe('producedForClosing derivation', () => { assistant(9, 'second turn', 2), ] expect(producedForClosing(nodes, 7)).toEqual(['out/index.html', 'out/app.css']) + expect(selectProducedFiles({ nodes, seq: 7, openFile: () => {} })).toEqual(['out/index.html', 'out/app.css']) + expect(selectProducedFiles({ nodes, seq: 9, openFile: () => {} })).toBeNull() // A turn that produced nothing yields the empty list, and so does an // anchor the window does not contain. expect(producedForClosing(nodes, 9)).toEqual([]) @@ -126,8 +128,7 @@ describe('ProducedFiles row', () => { // it shows and says so rather than dropping the rest silently. const paths = ['deep/a.html', 'b.css', 'c.ts', 'd.ts', 'e.ts', 'f.ts', 'g.ts'] const openFile = vi.fn<(path: string) => void>() - const nodes: ConversationNode[] = [user(1, 'build it'), wrote(2, 'w', ...paths), assistant(3, 'done', 1)] - const view = render(<ProducedFiles nodes={nodes} seq={3} openFile={openFile} t={t} />) + const view = render(<ProducedFiles matched={paths} openFile={openFile} t={t} />) expect(view.getByText('产物')).toBeTruthy() // Chips carry the basename; the full path stays reachable as the title. const chip = view.getByRole('button', { name: '打开 deep/a.html' }) @@ -138,12 +139,6 @@ describe('ProducedFiles row', () => { fireEvent.click(chip) expect(openFile).toHaveBeenCalledWith('deep/a.html') }) - - it('a turn that produced nothing renders no row at all', () => { - const nodes: ConversationNode[] = [user(1, 'hi'), assistant(2, 'hello', 1)] - const view = render(<ProducedFiles nodes={nodes} seq={2} openFile={() => {}} t={t} />) - expect(view.container.firstChild).toBeNull() - }) }) describe('package shells', () => { @@ -169,7 +164,7 @@ describe('plugin registration', () => { // The owning view's child declaration, stood up by a bench root entry. ctx.slots.register({ name: 'root', - children: { 'conversation.chat.turnTail': { kind: 'list', scope: 'session' } }, + children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, } as never, () => null) await ctx.plugin({ inject: ['slots'], apply: applyLocale }).await() From daac50c84ce3637bffbdeac350cac0a6ef0beb76 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Fri, 7 Aug 2026 17:19:26 +0800 Subject: [PATCH 12/12] fix(client): name turn-tail selector owner --- .../client/ui-deliverables/src/client/turn-deliverables.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index a3ddf40b59..c9754d1da4 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -83,7 +83,8 @@ export function producedForClosing(nodes: readonly ConversationNode[], seq: numb * @param owner - Turn-tail owner currency for the closing assistant. * @returns Produced paths as the component's match, or null to decline before mount. */ -export function selectProducedFiles({ nodes, seq }: TurnTailOwnerProps): readonly string[] | null { +export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[] | null { + const { nodes, seq } = owner const paths = producedForClosing(nodes, seq) return paths.length === 0 ? null : paths }