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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 打开器,而不是打开一个空标签页。
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}`
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>'))
|
||||
})
|
||||
})
|
||||
@@ -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> }> {
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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 } }
|
||||
|
||||
@@ -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: () => {} }
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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)}`
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -17,9 +17,6 @@
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
|
||||
README.md: 8c2075d615eccad1bbc7f5de1255ea4add69fab8
|
||||
README.zh.md: 634721b4248da75cbd4e81528340936a31ece28d
|
||||
README.md: a9d4aadf4b0acc21f3909319724645c10f08bd31
|
||||
README.zh.md: 16be57c9ed8f8101a61eb704ba5e94491803d9b5
|
||||
|
||||
@@ -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)).
|
||||
|
||||
|
||||
@@ -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))。
|
||||
|
||||
|
||||
@@ -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:^",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
@@ -20,9 +20,6 @@
|
||||
{
|
||||
"path": "../web-react"
|
||||
},
|
||||
{
|
||||
"path": "../connection"
|
||||
},
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user