refactor(web): open produced files through the Host, not over HTTP

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.
This commit is contained in:
ZiyaZhang
2026-08-01 03:15:54 -07:00
parent 59bfe77fb8
commit 8fb6c2bd69
50 changed files with 317 additions and 1211 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/connection/README.md
README.md: 5001da2458ea3470659f5983dffc8de832aadeac
README.zh.md: 47e745964e4087c6ccc59aae5bbfba69f96480e4
README.md: c8b7c4787cbcbf6a202fb944459a589fcadd7c8d
README.zh.md: f36cb4c4c6856089751e4492eb6e4b8e22abde56

View File

@@ -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

View File

@@ -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 打开器,而不是打开一个空标签页。

View File

@@ -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),
}
}

View File

@@ -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

View File

@@ -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}`
}

View File

@@ -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')
}

View File

@@ -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()
}
}

View File

@@ -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()
})
})

View File

@@ -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</title></head></html>', 4321)
expect(html).toContain('<head><script>window.__DSH_FILES_PORT__ = 4321</script>')
// Ahead of anything the shell might read it from.
expect(html.indexOf('__DSH_FILES_PORT__')).toBeLessThan(html.indexOf('<title>'))
})
})

View File

@@ -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> }> {

View File

@@ -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)
})
})

View File

@@ -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 } }

View File

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

View File

@@ -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:^",

View File

@@ -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)}`
}
}

View File

@@ -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()

View File

@@ -17,9 +17,6 @@
{
"path": "../web-react"
},
{
"path": "../connection"
},
{
"path": "../runtime"
},

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
README.md: 8c2075d615eccad1bbc7f5de1255ea4add69fab8
README.zh.md: 634721b4248da75cbd4e81528340936a31ece28d
README.md: a9d4aadf4b0acc21f3909319724645c10f08bd31
README.zh.md: 16be57c9ed8f8101a61eb704ba5e94491803d9b5

View File

@@ -14,7 +14,7 @@ Approvals take over the composer through the chain this package declares: `Appro
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file: one inside the session workspace opens in a new browser tab 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)).

View File

@@ -12,7 +12,7 @@
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px超出后滚动并以内联 JSON 展示 `content``source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击即打开文件:位于会话工作区之内的文件在新浏览器标签页打开,位于传输层的工作区文件源上(`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))。

View File

@@ -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:^",

View File

@@ -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.

View File

@@ -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. */

View File

@@ -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()
})

View File

@@ -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()
})

View File

@@ -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()
})

View File

@@ -20,9 +20,6 @@
{
"path": "../web-react"
},
{
"path": "../connection"
},
{
"path": "../runtime"
},