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

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

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

fileUrl moves from IWorkspaces to ConnectionHandle: the transport owns both
the listener that serves the bytes and the port that addresses it.
This commit is contained in:
ZiyaZhang
2026-08-01 02:17:25 -07:00
parent 1082518520
commit 59bfe77fb8
37 changed files with 469 additions and 197 deletions

View File

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

View File

@@ -12,11 +12,11 @@ The node half guards every request under `/api` before bridging (`src/api-reques
The node half also serves one file at a time out of a Session's workspace under `/f/<sessionId>/<segments…>`, so a produced deliverable is reachable from the page that reported it — an `http` page cannot follow a `file://` link, and a browser that is not on the Host machine has no such path anyway. The segments ride the URL rather than a query parameter so a served document's relative references resolve to its siblings. The request names a Session and the gateway names that Session's directory (`ApiProxy.workspaceRootOf`, which answers from a live agent's header or the persistence store and never resumes an agent to serve a file); this package reads the authority rather than the core services, because holding their host-side Context declarations would merge them over the browser runtime's own. The URL shape itself lives with the other browser-importable contract surfaces, in [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md), so the browser half that builds a URL and this half that parses one share a single encoding decision. Both the cwd and the resolved target go through `realpath` before comparison, so a symlink inside the workspace pointing out of it is refused by its target rather than its name; traversal spellings are refused earlier still, at parse time, before any filesystem call. Reads stream (no request buffers a file), answer `GET`/`HEAD` only, and carry `nosniff` with `no-store`. Extensions outside the served content-type table are typed `text/plain` rather than offered as a download, because a workspace read is a request to see a file.
Documents that can execute script — `.html`, `.htm`, `.xhtml`, `.svg` — additionally carry `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`, which runs them in an opaque origin. A workspace file is not necessarily agent-authored: a read row makes every file in a cloned repository openable, so an active document served same-origin with `/api` would have its script pass the browser-trust fence into every method, the loopback-pinned settings and credential plane included. The cost is borne by the preview `localStorage`, cookies, and same-origin `fetch` are unavailable inside it, so a generated page that remembers a theme will not — and `host.openPath` remains the full-capability way to open the same file on the Host machine. Restoring those capabilities without reopening the hole needs a separate origin, not a weaker header. The same trust fence gates this prefix, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads.
Workspace files are served from their own port, and therefore their own origin. That port is the isolation: a workspace file is not necessarily agent-authored a read row makes every file in a cloned repository openable so an active document served beside `/api` would have its script pass the browser-trust fence into every method, the loopback-pinned settings and credential plane included. A different origin closes that without touching the document: a preview keeps `localStorage`, cookies, and its own `fetch`, while a call to the API is cross-origin and refused twice over — by the fence's Origin check and by CORS. The alternative, `Content-Security-Policy: sandbox`, buys the same boundary by taking the document's origin away entirely, which measurably breaks the pages this route exists to show (a page that reads `localStorage` throws on load, and every listener declared after that line in the same script never binds). The listener binds the same host as the API, so a client that can reach the app can reach its previews; it answers the `/f` prefix and nothing else — no index, no SPA fallback, no API — and its port is published into the index page as `window.__DSH_FILES_PORT__`, which the browser half reads to address it. The same trust fence gates it, so a `trustedHosts` deployment serves workspace files exactly where it serves ordinary reads.
## Keyless fixture
The fixture carrier has no `/f` route, and `IWorkspaces.fileUrl` derives its URL in the browser regardless of carrier, so a file-path row clicked under `fixture=` opens a tab that 404s where the Host opener used to be a silent no-op. Fixture pages carry no file rows today; a fixture scenario that adds them should stub the derivation rather than teach the in-memory carrier to serve bytes.
A fixture page is served by no host, so no workspace-file port is published into it and `ConnectionHandle.fileUrl` answers `undefined` a file-path row falls back to the Host opener rather than opening a dead tab.
Any `fixture` query parameter selects the in-memory carrier. `fixture=empty` starts with no Workspace or Session; `fixturePrompt=reject` rejects prompts before acceptance; `fixtureAttach=fail` publishes a Session but rejects its Workspace attachment; `fixtureSessionCreate=drop-response` publishes and frames a Session before dropping the create response; and `fixtureFrames=workspace-first` reverses the default session-first create-frame order. Workspace creation by name/path and caller-preallocated SessionIds remain deterministic enough for assembled Web tests to reconcile list and frame arrival. Fixture content search preserves the production-facing `unicode61`-style case, diacritic, and token-phrase behavior and returns a match-centered snippet of at most 120 Unicode code points.

View File

@@ -12,11 +12,11 @@ node 半侧在桥接前守卫 `/api` 下的每个请求(`src/api-request-trust
node 半侧还会在 `/f/<sessionId>/<segments…>` 下逐个提供某个 Session 工作区里的文件,让产出的交付物能从报告它的那个页面直接抵达——`http` 页面无法跟随 `file://` 链接,而不在 Host 机器上的浏览器本来也没有那条路径。段落走 URL 而非查询参数,是为了让所服务文档的相对引用能解析到它的同级文件。请求指名一个 Session由网关指名该 Session 的目录(`ApiProxy.workspaceRootOf`,它从活跃 agent 的 header 或持久化存储作答,绝不会为了提供一个文件而恢复 agent本包读取这个权威来源而不去够核心服务因为持有它们的 host 侧 Context 声明会把它们盖到浏览器运行时自己的声明之上。URL 形状本身与其余浏览器可导入的契约面放在一起,位于 [`@deepseek-ai/dsh-host-apiproxy/api`](../../host/apiproxy/README.md),因此构造 URL 的浏览器半侧与解析 URL 的这一半共享同一个编码决定。cwd 与解析出的目标在比较前都要过 `realpath`,因此工作区内指向工作区外的符号链接会因其目标而被拒绝,而不是因其名字;穿越写法拒得更早,在解析期、任何文件系统调用之前。读取是流式的(没有请求会把文件缓冲起来),只应答 `GET``HEAD`,并带上 `nosniff``no-store`。所服务的内容类型表之外的扩展名一律按 `text/plain` 定型而非作为下载给出,因为工作区读取本就是一个“让我看看这个文件”的请求。
能执行脚本的文档——`.html``.htm``.xhtml``.svg`——还会额外带上 `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`,让它们运行在不透明源中。工作区文件未必由 agent 撰写一条 read 行就能让 clone 下来的仓库里任何文件变得可打开因此与 `/api` 同源提供的活动文档,其脚本会带着浏览器信任 fence 通行到每一个方法,包括那些正因会改动设置与凭据而被钉在回环的方法。代价由预览承担——其中无法使用 `localStorage`、cookie 与同源 `fetch`因此一个会记住主题的生成页面在预览里记不住——而 `host.openPath` 仍是在 Host 机器上以完整能力打开同一文件的方式。要在不重新打开这个洞的前提下取回那些能力,需要的是一个独立的源,而不是一个更弱的头。这条前缀由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。
工作区文件由它自己的端口提供,因而拥有自己的源。那个端口就是隔离:工作区文件未必由 agent 撰写——一条 read 行就能让 clone 下来的仓库里任何文件变得可打开——因此与 `/api` 并排提供的活动文档,其脚本会带着浏览器信任 fence 通行到每一个方法,包括那些正因会改动设置与凭据而被钉在回环的方法。换一个源即可堵死这条,且不必动文档本身:预览保有 `localStorage`、cookie 与自己的 `fetch`而对 API 的调用属于跨源会被两道独立的关卡拒绝——fence 的 Origin 校验,以及 CORS。另一种做法 `Content-Security-Policy: sandbox` 用"干脆剥夺文档的源"换来同一条边界,而这经实测会破坏本路由存在的意义所在的那类页面(读 `localStorage` 的页面在加载时抛异常,同一 script 块中该行之后声明的所有监听器都不会绑定)。该监听器绑定与 API 相同的 host因此能访问应用的客户端也能访问它的预览它只应答 `/f` 前缀,别无其他——没有首页、没有 SPA 兜底、没有 API——其端口以 `window.__DSH_FILES_PORT__` 注入首页,由浏览器半侧读取来寻址。它由同一道信任 fence 把守,因此配置了 `trustedHosts` 的部署提供工作区文件的范围,与它提供普通读取的范围完全一致。
## 无密钥 fixture
fixture 载体没有 `/f` 路由,而 `IWorkspaces.fileUrl` 无论载体为何都在浏览器侧推导 URL因此在 `fixture=` 下点击文件路径行会打开一个 404 的标签页,而此处从前是 Host 打开器的静默空操作。今天的 fixture 页面并不含文件行;若某个 fixture 场景要加上它们,应当把这段推导打桩,而不是教这个内存载体去提供字节
fixture 页面不由任何 host 提供,因此没有工作区文件端口注入其中,`ConnectionHandle.fileUrl` 应答 `undefined`——文件路径行会回退到 Host 打开器,而不是打开一个空标签页
任何 `fixture` 查询参数都会选择内存载体。`fixture=empty` 启动时不含 Workspace 或 Session`fixturePrompt=reject` 在接受前拒绝提示词;`fixtureAttach=fail` 发布 Session 但拒绝将其附加到 Workspace`fixtureSessionCreate=drop-response` 在丢弃创建响应前发布 Session 并为其发出帧;`fixtureFrames=workspace-first` 则反转默认的 Session 优先创建帧顺序。按名称/路径创建 Workspace 以及由调用方预先分配 SessionId均具有足够的确定性组装后的 Web 测试可以据此协调列表与帧的到达。fixture 内容搜索会保留面向生产环境的 `unicode61` 式大小写、变音符号和 token短语行为并返回以匹配位置为中心、最多包含 120 个 Unicode 码点的 snippet。

View File

@@ -4,6 +4,9 @@
* controller with its sinks.
*/
import type { Context } from 'cordis'
import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { FILES_PORT_GLOBAL } from '../files-server.ts'
import type { IApiClient } from './api.ts'
import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts'
import { FixtureApiClient } from './fixture.ts'
@@ -56,6 +59,19 @@ export interface ConnectionHandle {
* @returns stop handle for the loop.
*/
start(sinks: ConnectionSinks, config?: ConnectionConfig): { stop(): void }
/**
* Absolute URL serving one file out of a Session's workspace, on the
* transport's own workspace-file origin — the same hostname the page is
* reached by, a different port, so a served document is isolated from this
* API without being stripped of its own capabilities.
* @param sessionId - the Session whose cwd anchors the path.
* @param cwd - that Session's working directory, or `undefined` when unknown.
* @param path - the path a tool reported (absolute, or relative to `cwd`).
* @returns the URL, or `undefined` when the path lies outside the workspace
* (which this transport never serves) or when this page was not served by a
* host that published a workspace-file port (the fixture carrier).
*/
fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined
}
/**
@@ -68,6 +84,15 @@ export function apply(ctx: Context): void {
let started = false
const handle: ConnectionHandle = {
api,
fileUrl(sessionId, cwd, path) {
// Published by the node half's index tap; absent means no host is
// serving workspace files to this page (the keyless fixture lane).
const port = (globalThis as unknown as Record<string, unknown>)[FILES_PORT_GLOBAL]
if (typeof port !== 'number') return undefined
const segments = workspaceFileSegments(cwd, path)
if (segments === undefined) return undefined
return `${location.protocol}//${location.hostname}:${String(port)}${workspaceFileUrl(sessionId, segments)}`
},
start(sinks, config) {
if (started) throw new Error('connection: the stream loop is already owned by another consumer')
started = true

View File

@@ -0,0 +1,127 @@
/**
* The workspace-file listener: a second loopback/LAN socket on the same host
* as the API, serving nothing but `/f`.
*
* The port is the isolation. A workspace file is not necessarily
* agent-authored — a read row makes every file in a cloned repository
* openable — so an active document must not be same-origin with `/api`, where
* its script would pass the browser-trust fence into every method, the
* loopback-pinned settings and credential plane included. A different port is
* a different origin, which the browser enforces for free: the document keeps
* `localStorage`, cookies, and its own `fetch`, while a call to the API is
* cross-origin and refused twice over — by the fence's Origin check and by
* CORS. The alternative, `Content-Security-Policy: sandbox`, buys the same
* boundary by taking the document's origin away entirely, which measurably
* breaks the pages this route exists to show.
*/
import { createServer } from 'node:http'
import type { IncomingMessage, Server, ServerResponse } from 'node:http'
import type { AddressInfo } from 'node:net'
import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api'
import { isTrustedApiRequest } from './api-request-trust.ts'
import { handleWorkspaceFile, type WorkspaceFileDeps } from './workspace-files.ts'
/** A listening workspace-file server: its port, and the teardown that reaches quiescence. */
export interface FilesServer {
/** The bound port (OS-assigned), which the browser half needs to address this origin. */
port: number
/** Close the socket and destroy held connections; resolves once quiet. */
close: () => Promise<void>
}
/**
* Bind the workspace-file listener.
* @param host - the same bind host the API uses, so a client that can reach
* the app can reach its previews (a LAN deployment included).
* @param trustedHosts - the deployment's non-loopback serving authorities,
* applied through the same fence as `/api`.
* @param deps - the session-to-directory lookup reads are confined by.
* @param onSocketError - reports a post-listen socket error; without a
* listener node would raise it as an unhandled 'error' event.
* @returns the bound port and its disposer.
*/
export async function listenForWorkspaceFiles(
host: string,
trustedHosts: readonly string[],
deps: WorkspaceFileDeps,
onSocketError: (error: Error) => void,
): Promise<FilesServer> {
const handle = async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
if (!isTrustedApiRequest(req, trustedHosts)) {
res.writeHead(403)
res.end('forbidden')
return
}
/* v8 ignore next -- `?? '/'` arm: node:http always sets url on server requests. */
const pathname = new URL(req.url ?? '/', 'http://dsh.internal').pathname
// This origin serves one prefix and nothing else: no index, no SPA
// fallback, no API. Anything else is not here — answered before the method
// check, because a 405 would claim the resource exists.
if (pathname !== FILES_PATH && !pathname.startsWith(`${FILES_PATH}/`)) {
res.writeHead(404)
res.end()
return
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
// RFC 9110 §15.5.6: a 405 names the methods the resource does support.
res.writeHead(405, { allow: 'GET, HEAD' })
res.end()
return
}
await handleWorkspaceFile(req, res, deps)
}
const server: Server = createServer((req, res) => {
handle(req, res).catch((error: unknown) => {
// A malformed request must not become an unhandled rejection that takes
// the process down; the API carrier guards its own handler the same way.
if (res.headersSent) {
res.destroy()
return
}
onSocketError(error instanceof Error ? error : new Error(String(error)))
res.writeHead(400)
res.end()
})
})
await new Promise<void>((resolve, reject) => {
server.once('error', reject)
server.listen(0, host, () => {
server.off('error', reject)
server.on('error', onSocketError)
resolve()
})
})
return {
port: (server.address() as AddressInfo).port,
// close + closeAllConnections: a held-open response would otherwise keep
// teardown waiting forever.
close: () => new Promise<void>((resolve) => {
server.close(() => { resolve() })
server.closeAllConnections()
}),
}
}
/** The global the node half hands its port to the browser half through. */
export const FILES_PORT_GLOBAL = '__DSH_FILES_PORT__'
/**
* Inject the workspace-file port into index.html, ahead of the shell bundle
* that reads it. A boot-time fact of the serving host, delivered the way the
* module graph is: synchronously on the page, so the first click on a produced
* file does not race a round trip.
* @param html - the index.html source.
* @param port - the bound workspace-file port.
* @returns the html with the port script injected.
*/
export function injectFilesPort(html: string, port: number): string {
const script = `<script>window.${FILES_PORT_GLOBAL} = ${String(port)}</script>`
const head = html.indexOf('<head>')
if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
/* v8 ignore next -- headless fixture pages may lack <head>; prepending keeps read-before-shell ordering. */
return `${script}${html}`
}

View File

@@ -4,14 +4,13 @@ import z from 'schemastery'
// Activates the httpServer Context merge used below.
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api'
// The merge-free types subpath: pulling the session package's root into this
// client-registered program would merge the host `sessions` service over the
// browser runtime's own.
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { API_PATH } from './api-path.ts'
import { bridge } from './http-bridge.ts'
import { handleWorkspaceFile } from './workspace-files.ts'
import { injectFilesPort, listenForWorkspaceFiles } from './files-server.ts'
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
export { API_PATH } from './api-path.ts'
@@ -74,8 +73,10 @@ const PRIVILEGED_METHODS = new Set([
* additionally pass it with an empty trust list, which pins them to loopback.
* @param ctx - Host plugin context.
* @param config - resolved plugin config (schema defaults applied).
* @returns a promise settling once the workspace-file listener is bound and
* its port published — the page must never render before it can address one.
*/
export function apply(ctx: Context, config?: ConnectionConfig): void {
export async function apply(ctx: Context, config?: ConnectionConfig): Promise<void> {
// The Loader resolves schema defaults; hand-built test contexts may pass none.
const trustedHosts = config?.trustedHosts ?? []
// Config boundary: a malformed entry fails the load loudly here rather than
@@ -108,23 +109,19 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
// would merge their host-side Context declarations into the browser lane.
const cwdFor = (sessionId: string): Promise<string | undefined> =>
ctx.apiProxy.workspaceRootOf(sessionId as SessionId)
const filesRoute: WebRoute = {
kind: 'prefix',
path: FILES_PATH,
handler: async (req, res) => {
if (!isTrustedApiRequest(req, trustedHosts)) {
res.writeHead(403)
res.end('forbidden')
return
}
if (req.method !== 'GET' && req.method !== 'HEAD') {
// RFC 9110 §15.5.6: a 405 names the methods the resource does support.
res.writeHead(405, { allow: 'GET, HEAD' })
res.end()
return
}
await handleWorkspaceFile(req, res, { cwdFor })
},
}
ctx.effect(() => ctx.httpServer.register(filesRoute), 'client-connection: /f route')
// Workspace files get their own port, and therefore their own origin: an
// active document served beside `/api` would reach every method through the
// fence below. The listen is awaited inside the effect so the port is known
// before the index tap that publishes it can run.
await ctx.effect(async () => {
const files = await listenForWorkspaceFiles(
ctx.httpServer.host, trustedHosts, { cwdFor },
(error) => { ctx.logger.error(error) },
)
const untap = ctx.httpServer.tapIndex(html => injectFilesPort(html, files.port))
return async () => {
untap()
await files.close()
}
}, 'client-connection: /f listener')
}

View File

@@ -10,13 +10,11 @@
* owns the browser-trust fence ([api-request-trust](./api-request-trust.ts)) —
* this module is reached only by requests that already passed it.
*
* Script-capable documents are served into an opaque origin. A workspace file
* is not necessarily agent-authored — a read row makes every file in a cloned
* repository openable — so an active document served same-origin with `/api`
* reaches the whole RPC surface, the loopback-pinned settings and credential
* methods included. The sandbox costs a preview its `localStorage` and
* cookies; restoring those without reopening that hole needs a separate
* origin, not a weaker header.
* Isolation is the listener's, not this module's: these responses carry no
* sandbox header because they are served from their own port, and therefore
* their own origin ([files-server](./files-server.ts)). A served document
* keeps `localStorage`, cookies, and its own `fetch`, while the API stays
* cross-origin to it.
*/
import { createReadStream } from 'node:fs'
@@ -59,17 +57,6 @@ const MIME: Record<string, string> = {
const DEFAULT_MIME = 'text/plain; charset=utf-8'
/** Extensions whose top-level navigation can execute script, and so need the sandbox. */
const SCRIPTABLE = new Set(['.html', '.htm', '.xhtml', '.svg'])
/**
* The opaque origin an active workspace document runs in. Without it the
* document is same-origin with `/api` and its script passes the browser-trust
* fence, which admits every method — including the ones pinned to loopback
* precisely because they mutate settings and credentials.
*/
const SANDBOX_CSP = 'sandbox allow-scripts allow-popups allow-modals allow-forms'
/** How the route learns which directory a session may serve from. */
export interface WorkspaceFileDeps {
/**
@@ -160,7 +147,6 @@ export async function handleWorkspaceFile(
// Workspace files change under the agent's hands; a cached preview would
// show the previous turn's output after the next edit.
'cache-control': 'no-store',
...SCRIPTABLE.has(ext) ? { 'content-security-policy': SANDBOX_CSP } : {},
})
if (req.method === 'HEAD') {
res.end()

View File

@@ -8,10 +8,11 @@ import { apply, type ConnectionHandle } from '../src/client/index.ts'
import { FixtureApiClient } from '../src/client/fixture.ts'
import { WebApiClient } from '../src/client/web-api-client.ts'
type Win = { location?: { search: string } }
type Win = { location?: { search: string; protocol?: string; hostname?: string }; __DSH_FILES_PORT__?: number }
afterEach(() => {
delete (globalThis as Win).location
delete (globalThis as Win).__DSH_FILES_PORT__
})
async function mount(): Promise<ConnectionHandle> {
@@ -62,4 +63,28 @@ describe('connection client apply', () => {
}
expect(seen.some(u => u.includes('/api/'))).toBe(true)
})
it('addresses a workspace file on the port the host published, and only inside the workspace', async () => {
const win = globalThis as Win
win.location = { search: '', protocol: 'http:', hostname: '192.168.1.5' }
win.__DSH_FILES_PORT__ = 4321
const handle = await mount()
const session = 's-1' as never
// Same hostname the page was reached by — a LAN client must reach previews
// too — and the published port, which is what makes it another origin.
expect(handle.fileUrl(session, '/w/alpha', '/w/alpha/out/a b.html'))
.toBe('http://192.168.1.5:4321/f/s-1/out/a%20b.html')
// Outside the workspace there is nothing this transport may serve, which
// is the signal a caller falls back to openPath on.
expect(handle.fileUrl(session, '/w/alpha', '/etc/hosts')).toBeUndefined()
})
it('serves no file URL on a page no host published a port into', async () => {
const win = globalThis as Win
win.location = { search: '?fixture', protocol: 'http:', hostname: '127.0.0.1' }
const handle = await mount()
// The keyless fixture lane: no workspace-file origin exists, so the row
// falls back to the Host opener instead of opening a dead tab.
expect(handle.fileUrl('s-1' as never, '/w', 'a.txt')).toBeUndefined()
})
})

View File

@@ -0,0 +1,44 @@
/** The workspace-file listener's own failure and publication paths. */
import { describe, expect, it } from 'vitest'
import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api'
import { injectFilesPort, listenForWorkspaceFiles } from '../src/files-server.ts'
describe('workspace-file listener', () => {
it('answers 400 and reports the failure when the directory lookup throws', async () => {
const seen: Error[] = []
const files = await listenForWorkspaceFiles(
'127.0.0.1', [],
{ cwdFor: () => Promise.reject(new Error('store unavailable')) },
(error) => { seen.push(error) },
)
try {
// A lookup failure is the host's problem, not a miss: it must not become
// an unhandled rejection, and it must not be reported as "not found".
const response = await fetch(`http://127.0.0.1:${String(files.port)}${FILES_PATH}/s-1/a.txt`)
expect(response.status).toBe(400)
expect(seen.map(error => error.message)).toEqual(['store unavailable'])
} finally {
await files.close()
}
})
it('closes idempotently and stops answering', async () => {
const files = await listenForWorkspaceFiles(
'127.0.0.1', [], { cwdFor: async () => undefined }, () => {},
)
const origin = `http://127.0.0.1:${String(files.port)}`
expect((await fetch(`${origin}${FILES_PATH}/s-1/a.txt`)).status).toBe(404)
await files.close()
await files.close()
await expect(fetch(`${origin}${FILES_PATH}/s-1/a.txt`)).rejects.toThrow()
})
})
describe('injectFilesPort', () => {
it('publishes the port as the first script in head', () => {
const html = injectFilesPort('<html><head><title>x</title></head></html>', 4321)
expect(html).toContain('<head><script>window.__DSH_FILES_PORT__ = 4321</script>')
// Ahead of anything the shell might read it from.
expect(html.indexOf('__DSH_FILES_PORT__')).toBeLessThan(html.indexOf('<title>'))
})
})

View File

@@ -15,14 +15,21 @@ import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api'
import { API_PATH, apply, inject } from '../src/index.ts'
/** Structural httpServer fake: the plugin only touches register(). */
function fakeHttpServer(routes: WebRoute[]): Pick<HttpServerService, 'register' | 'tapIndex' | 'port'> {
function fakeHttpServer(
routes: WebRoute[],
taps: ((html: string) => string)[] = [],
): Pick<HttpServerService, 'register' | 'tapIndex' | 'port' | 'host'> {
return {
register(route) {
routes.push(route)
return () => { routes.splice(routes.indexOf(route), 1) }
},
tapIndex: () => () => {},
tapIndex(transform) {
taps.push(transform)
return () => { taps.splice(taps.indexOf(transform), 1) }
},
port: 0,
host: '127.0.0.1',
}
}
@@ -61,21 +68,39 @@ function fakeApiProxy(workspaces: Record<string, string> = {}): ApiProxy {
async function mounted(
config?: { trustedHosts?: string[] },
workspaces: Record<string, string> = {},
): Promise<{ routes: WebRoute[]; dispose: () => Promise<void> }> {
): Promise<{ routes: WebRoute[]; taps: ((html: string) => string)[]; dispose: () => Promise<void> }> {
const ctx = new Context()
const routes: WebRoute[] = []
ctx.provide('httpServer', fakeHttpServer(routes) as HttpServerService)
const taps: ((html: string) => string)[] = []
ctx.provide('httpServer', fakeHttpServer(routes, taps) as HttpServerService)
ctx.provide('apiProxy', fakeApiProxy(workspaces))
const fiber = ctx.plugin({ inject: [...inject], apply }, config)
await fiber.await()
return { routes, dispose: () => fiber.dispose() }
return { routes, taps, dispose: () => fiber.dispose() }
}
/** The /f route is registered after /api; both are prefix routes on the same server. */
function filesRoute(routes: WebRoute[]): WebRoute {
const route = routes.find(candidate => candidate.path === FILES_PATH)
if (route === undefined) throw new Error('the /f route was not registered')
return route
/** One raw GET whose Host header is spoofed (fetch forbids setting it). */
function statusWithHost(origin: string, path: string, host: string): Promise<number> {
const url = new URL(origin)
return new Promise((resolve, reject) => {
const request = httpRequest(
{ host: url.hostname, port: url.port, path, method: 'GET', headers: { host } },
(response) => {
response.resume()
response.on('end', () => { resolve(response.statusCode ?? 0) })
},
)
request.on('error', reject)
request.end()
})
}
/** The workspace-file origin the node half published into the index page. */
function filesOrigin(taps: ((html: string) => string)[]): string {
const html = taps.reduce((acc, tap) => tap(acc), '<head></head>')
const port = /__DSH_FILES_PORT__ = (\d+)/.exec(html)?.[1]
if (port === undefined) throw new Error(`no workspace-file port was published: ${html}`)
return `http://127.0.0.1:${port}`
}
describe('connection node half', () => {
@@ -89,11 +114,19 @@ describe('connection node half', () => {
expect(routes).toHaveLength(0)
})
it('registers both transport prefix routes and removes them with the fiber', async () => {
const { routes, dispose } = await mounted()
expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }, { kind: 'prefix', path: FILES_PATH }])
it('registers the /api route and publishes a separate workspace-file origin, both removed with the fiber', async () => {
const { routes, taps, dispose } = await mounted()
// The API keeps one prefix on the shared server; workspace files get a
// port of their own, which is the origin boundary between them.
expect(routes).toMatchObject([{ kind: 'prefix', path: API_PATH }])
const origin = filesOrigin(taps)
expect(new URL(origin).port).not.toBe('')
expect((await fetch(`${origin}${FILES_PATH}/absent/x.txt`)).status).toBe(404)
await dispose()
expect(routes).toHaveLength(0)
expect(taps).toHaveLength(0)
// Disposal reaches quiescence: the socket is gone, not merely unrouted.
await expect(fetch(`${origin}${FILES_PATH}/absent/x.txt`)).rejects.toThrow()
})
it('refuses an untrusted Host on any /api path before the bridge runs', async () => {
@@ -154,7 +187,7 @@ describe('connection node half', () => {
})
})
describe('connection node half: the /f workspace-file route', () => {
describe('connection node half: the workspace-file origin', () => {
/** A workspace holding one file, torn down with the returned disposer. */
async function workspace(): Promise<{ cwd: string; remove: () => Promise<void> }> {
const cwd = await mkdtemp(join(tmpdir(), 'dsh-node-half-'))
@@ -162,40 +195,35 @@ describe('connection node half: the /f workspace-file route', () => {
return { cwd, remove: () => rm(cwd, { recursive: true, force: true }) }
}
/** HEAD keeps the assertion on the route's decision, not on the byte stream. */
function head(url: string, headers: Record<string, string> = { host: '127.0.0.1:3080' }): IncomingMessage {
const request = fakeRequest(headers, url)
Object.assign(request, { method: 'HEAD' })
return request
}
it('applies the same browser-trust fence as /api, and refuses writes', async () => {
const { routes, dispose } = await mounted()
const untrusted = fakeResponse()
await filesRoute(routes).handler(head(`${FILES_PATH}/s-1/index.html`, { host: 'harness.example' }), untrusted.response)
expect(untrusted.state.status).toBe(403)
expect(untrusted.state.body).toBe('forbidden')
const written = fakeResponse()
const post = fakeRequest({ host: '127.0.0.1:3080' }, `${FILES_PATH}/s-1/index.html`)
Object.assign(post, { method: 'POST' })
await filesRoute(routes).handler(post, written.response)
expect(written.state.status).toBe(405)
expect(written.state.headers).toMatchObject({ allow: 'GET, HEAD' })
it('applies the same browser-trust fence as /api, refuses writes, and serves nothing else', async () => {
const { taps, dispose } = await mounted()
const origin = filesOrigin(taps)
// Rebound Host: refused before any filesystem work, exactly as on /api.
// node's fetch refuses to set Host (a forbidden header), so the spoof goes
// through the raw client — the same parse the server really performs.
expect(await statusWithHost(origin, `${FILES_PATH}/s-1/index.html`, 'harness.example')).toBe(403)
const written = await fetch(`${origin}${FILES_PATH}/s-1/index.html`, { method: 'POST' })
expect(written.status).toBe(405)
expect(written.headers.get('allow')).toBe('GET, HEAD')
// This origin is one route wide: no index, no SPA fallback, no API.
expect((await fetch(`${origin}/`)).status).toBe(404)
expect((await fetch(`${origin}${API_PATH}/session.list`, { method: 'POST' })).status).toBe(404)
await dispose()
})
it('confines reads to the directory the gateway names for that session', async () => {
const { cwd, remove } = await workspace()
const { routes, dispose } = await mounted(undefined, { 's-1': cwd })
const served = fakeResponse()
await filesRoute(routes).handler(head(`${FILES_PATH}/s-1/index.html`), served.response)
expect(served.state.status).toBe(200)
const { taps, dispose } = await mounted(undefined, { 's-1': cwd })
const origin = filesOrigin(taps)
const served = await fetch(`${origin}${FILES_PATH}/s-1/index.html`)
expect(served.status).toBe(200)
expect(await served.text()).toBe('<h1>ok</h1>')
// A served document keeps its own capabilities: the port is the boundary,
// so nothing here strips the document of its origin.
expect(served.headers.get('content-security-policy')).toBeNull()
// A session the gateway names no directory for has no workspace to confine
// against, so there is nothing to serve.
const unknown = fakeResponse()
await filesRoute(routes).handler(head(`${FILES_PATH}/s-absent/index.html`), unknown.response)
expect(unknown.state.status).toBe(404)
expect((await fetch(`${origin}${FILES_PATH}/s-absent/index.html`)).status).toBe(404)
await dispose()
await remove()
})

View File

@@ -59,27 +59,25 @@ function get(path: string, init?: RequestInit): Promise<Response> {
}
describe('workspace file reads', () => {
it('serves an active document into an opaque origin', async () => {
it('serves a produced document with its own capabilities intact', async () => {
const response = await get(`${FILES_PATH}/${SESSION}/index.html`)
expect(response.status).toBe(200)
expect(await response.text()).toBe('<h1>产物</h1>')
expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8')
// A workspace file is not necessarily agent-authored, and same-origin
// script here would pass the browser-trust fence into every RPC method.
expect(response.headers.get('content-security-policy')).toContain('sandbox')
expect(response.headers.get('content-security-policy')).not.toContain('allow-same-origin')
// No isolation header: the listener's own port is the origin boundary, so
// a preview keeps localStorage and cookies (see files-server).
expect(response.headers.get('content-security-policy')).toBeNull()
expect(response.headers.get('x-content-type-options')).toBe('nosniff')
expect(response.headers.get('cache-control')).toBe('no-store')
expect(response.headers.get('content-disposition')).toBe('inline')
})
it('sandboxes SVG too, and leaves inert types unrestricted', async () => {
it('types SVG as a standalone document rather than sniffable bytes', async () => {
const svg = await get(`${FILES_PATH}/${SESSION}/chart.svg`)
expect(svg.headers.get('content-type')).toBe('image/svg+xml')
expect(svg.headers.get('content-security-policy')).toContain('sandbox')
expect(svg.headers.get('x-content-type-options')).toBe('nosniff')
const text = await get(`${FILES_PATH}/${SESSION}/notes.txt`)
expect(text.headers.get('content-type')).toBe('text/plain; charset=utf-8')
expect(text.headers.get('content-security-policy')).toBeNull()
})
it('serves a workspace rooted at a filesystem root, whose realpath already ends in a separator', async () => {

View File

@@ -56,17 +56,6 @@ export interface IWorkspaces {
* @param path - absolute or host-resolvable path.
*/
openPath(path: string): Promise<void>
/**
* URL serving one file out of a session's workspace, for a UI that opens a
* produced file in the browser instead of on the Host machine.
* @param sessionId - the session whose cwd anchors the path.
* @param cwd - that session's working directory, or `undefined` when unknown.
* @param path - the path a tool reported (absolute, or relative to `cwd`).
* @returns the origin-relative URL, or `undefined` when the path lies
* outside the workspace — which this transport never serves, leaving
* {@link IWorkspaces.openPath} as the only way to reach it.
*/
fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined
/**
* Rename a Workspace.
* @param workspaceId - target workspace.

View File

@@ -5,7 +5,6 @@ import type {
DirectoryListing, IApiClient, RpcError,
SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts'
@@ -240,18 +239,6 @@ export class WorkspacesService implements IWorkspaces {
}
}
/**
* URL serving one file out of a session's workspace.
* @param sessionId - the session whose cwd anchors the path.
* @param cwd - that session's working directory, or `undefined` when unknown.
* @param path - the path a tool reported (absolute, or relative to `cwd`).
* @returns the origin-relative URL, or `undefined` for a path outside the workspace.
*/
fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined {
const segments = workspaceFileSegments(cwd, path)
if (segments === undefined) return undefined
return workspaceFileUrl(sessionId, segments)
}
/**
* Rename a Workspace.

View File

@@ -26,6 +26,7 @@ async function mount(): Promise<Bench> {
const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
const handle: ConnectionHandle = {
api,
fileUrl: () => undefined,
start: (sinks) => {
bench.sinks = sinks
return { stop: () => { bench.stopped += 1 } }

View File

@@ -20,6 +20,7 @@ async function mount(): Promise<Bench> {
const bench: Bench = { ctx, sinks: undefined }
const handle: ConnectionHandle = {
api,
fileUrl: () => undefined,
start: (sinks) => {
bench.sinks = sinks
return { stop: () => {} }

View File

@@ -276,21 +276,6 @@ describe('WorkspacesService', () => {
await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/)
})
it('addresses a workspace file by URL, and only inside the workspace', async () => {
const ctx = new Context()
const api = new FakeApiClient()
const sessions = new SessionsService(ctx, api)
const workspaces = new WorkspacesService(ctx, api, sessions)
const session = 's-1' as SessionId
// The URL is derived, not fetched: no wire call answers a link.
expect(workspaces.fileUrl(session, '/w/alpha', '/w/alpha/out/a b.html')).toBe('/f/s-1/out/a%20b.html')
expect(workspaces.fileUrl(session, '/w/alpha', 'out/index.html')).toBe('/f/s-1/out/index.html')
// Outside the workspace there is nothing this transport may serve, which
// is the signal a caller falls back to openPath on.
expect(workspaces.fileUrl(session, '/w/alpha', '/etc/hosts')).toBeUndefined()
expect(api.calls).toHaveLength(0)
})
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
const ctx = new Context()
const api = new FakeApiClient()

View File

@@ -25,6 +25,7 @@
"vitest": "^4.1.8"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
@@ -35,6 +36,7 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",

View File

@@ -0,0 +1,48 @@
/** Test-owned connection face: the transport members features read off `ctx.connection`. */
import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { ConnectionHandle, IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
/**
* Connection test double. Implements the same `ConnectionHandle` face features
* receive as `ctx.connection`, so a production face change breaks this double
* at compile time. The wire client is not modelled — a feature that needs one
* composes its own connection over a fake api client; this double exists for
* the transport facts features read synchronously, above all the
* workspace-file URL.
*/
export class TestConnection implements ConnectionHandle {
/**
* The workspace-file port the host would have published into the page.
* Unset — the default, and the keyless fixture lane's real state — makes
* {@link TestConnection.fileUrl} answer `undefined`, which is the signal a
* caller falls back to the Host opener on.
*/
filesPort: number | undefined
/** The wire client; unused by this double's consumers and absent by construction. */
readonly api: IApiClient = undefined as unknown as IApiClient
/**
* Stream-loop starter (inert).
* @returns a stop handle that does nothing.
*/
start(): { stop(): void } {
return { stop: () => {} }
}
/**
* Workspace-file URL, deriving exactly as production does so a feature test
* sees the real inside/outside-workspace split.
* @param sessionId - the Session whose cwd anchors the path.
* @param cwd - that Session's working directory.
* @param path - the path a tool reported.
* @returns the absolute URL on the workspace-file origin, or undefined when
* the path leaves the workspace or no port is published.
*/
fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined {
if (this.filesPort === undefined) return undefined
const segments = workspaceFileSegments(cwd, path)
if (segments === undefined) return undefined
return `http://localhost:${String(this.filesPort)}${workspaceFileUrl(sessionId, segments)}`
}
}

View File

@@ -29,11 +29,13 @@ import type {
} from '@deepseek-ai/dsh-client-ui-slots'
import { registerDomSnapshotSerializer } from './snapshot.ts'
import { TestSessions } from './sessions.ts'
import { TestConnection } from './connection.ts'
import { TestWorkspaces } from './workspaces.ts'
import type { Stabilizer } from './fixtures.ts'
export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts'
export { FixtureSession, TestSessions } from './sessions.ts'
export { TestConnection } from './connection.ts'
export { TestWorkspaces } from './workspaces.ts'
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
@@ -175,6 +177,8 @@ export class SlotTestRuntime {
readonly sessions: TestSessions
/** Workspaces double (list observable, recorded intent actions). */
readonly workspaces: TestWorkspaces
/** The transport double features read as `ctx.connection`. */
readonly connection: TestConnection
private readonly stabilizer: Stabilizer = async (fn) => {
await act(async () => { await fn() })
@@ -195,8 +199,10 @@ export class SlotTestRuntime {
this.root = new TestRoot(slots, this.stabilizer)
this.sessions = new TestSessions(this.stabilizer, ctx)
this.workspaces = new TestWorkspaces(this.stabilizer)
this.connection = new TestConnection()
ctx.provide('sessions', this.sessions)
ctx.provide('workspaces', this.workspaces)
ctx.provide('connection', this.connection)
// Capturing install: the production renderer does the rendering; the
// wrapper only takes the host face for storeOf (no machinery copied).
const renderer = createSlotRenderer()

View File

@@ -1,6 +1,5 @@
/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -99,21 +98,6 @@ export class TestWorkspaces implements IWorkspaces {
await (this.stubs.get('openPath')?.(path) as Promise<void> | undefined)
}
/**
* Workspace-file URL (recorded). Runs the production path derivation so a
* feature test sees the real in/outside-workspace split; stub to force either.
* @param sessionId - the session whose cwd anchors the path.
* @param cwd - that session's working directory.
* @param path - the path a tool reported.
* @returns the origin-relative URL, or undefined outside the workspace.
*/
fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined {
this.calls.push({ method: 'fileUrl', args: [sessionId, cwd, path] })
const stub = this.stubs.get('fileUrl')
if (stub !== undefined) return stub(sessionId, cwd, path) as string | undefined
const segments = workspaceFileSegments(cwd, path)
return segments === undefined ? undefined : workspaceFileUrl(sessionId, segments)
}
/**
* Directory picker (recorded). The default cancels (null); stub to select.

View File

@@ -549,10 +549,6 @@ describe('workspaces action face', () => {
expect(renamed.title).toBe('Renamed')
await ws.delete('w1' as WorkspaceId)
await ws.openPath('/proj/file.ts')
// fileUrl runs the production derivation, so a feature test sees the same
// inside/outside-workspace split the browser half decides on.
expect(ws.fileUrl('s1' as SessionId, '/proj', 'out/a.html')).toBe('/f/s1/out/a.html')
expect(ws.fileUrl('s1' as SessionId, '/proj', '/etc/hosts')).toBeUndefined()
const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId)
expect(moved.sessionIds).toEqual(['s1'])
// Default archive mirrors the production effect: the id joins the list
@@ -560,15 +556,13 @@ describe('workspaces action face', () => {
await ws.archiveSession('s1' as SessionId)
expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1'])
expect(ws.calls.map(c => c.method)).toEqual(
['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'fileUrl', 'fileUrl',
'insertSessionBefore', 'archiveSession'])
['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore', 'archiveSession'])
ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never))
ws.stub('pickDirectory', () => Promise.resolve('/picked'))
ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never))
ws.stub('delete', () => Promise.resolve())
ws.stub('openPath', () => Promise.resolve())
ws.stub('fileUrl', () => '/f/forced/a.html')
ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never))
ws.stub('archiveSession', () => Promise.resolve())
expect((await ws.create({ name: 'y' })).title).toBe('X')
@@ -576,7 +570,6 @@ describe('workspaces action face', () => {
expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S')
await ws.delete('w1' as WorkspaceId)
await ws.openPath('/other')
expect(ws.fileUrl('s1' as SessionId, '/proj', '/etc/hosts')).toBe('/f/forced/a.html')
expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([])
// The stub replaces the default set mutation: the set stays as-is.
await ws.archiveSession('s2' as SessionId)

View File

@@ -17,6 +17,9 @@
{
"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: ba55f0704500034b7afb37258064fe0801aaee91
README.zh.md: 4212908b355a81dfd5af8645ce5d4284a4555622
README.md: 8c2075d615eccad1bbc7f5de1255ea4add69fab8
README.zh.md: 634721b4248da75cbd4e81528340936a31ece28d

View File

@@ -14,7 +14,7 @@ Approvals take over the composer through the chain this package declares: `Appro
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file: one inside the session workspace opens in a new browser tab, served by the web transport's `/f` route, so a client that is not on the Host machine still sees it; one outside the workspace has no served URL and falls back to the Host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file: one inside the session workspace opens in a new browser tab on the transport's workspace-file origin (`ConnectionHandle.fileUrl`), so a client that is not on the Host machine still sees it; one outside the workspace has no served URL and falls back to the Host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).

View File

@@ -12,7 +12,7 @@
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow``ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px超出后滚动并以内联 JSON 展示 `content``source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击即打开文件:位于会话工作区之内的文件在新浏览器标签页打开,由 web 传输的 `/f` 路由提供,因此不在 Host 机器上的客户端也能看到;工作区之外的文件没有可服务的 URL回退到宿主操作系统的默认应用`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击即打开文件:位于会话工作区之内的文件在新浏览器标签页打开,位于传输层的工作区文件源上(`ConnectionHandle.fileUrl`,因此不在 Host 机器上的客户端也能看到;工作区之外的文件没有可服务的 URL回退到宿主操作系统的默认应用`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect``Mount temporary Plugin``Unmount temporary Plugin`mount 行保留 code 变体的可展开源码渲染。
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView``resultView` 对推导的唯一位置因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null落回通用路径。因此两个渲染点也都显示卡片的运行状态点它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`8面板为 16正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。

View File

@@ -39,6 +39,7 @@
"clsx": "^2.0.0"
},
"peerDependencies": {
"@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
@@ -50,6 +51,7 @@
"react": "^18.2.0"
},
"devDependencies": {
"@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",

View File

@@ -2,6 +2,7 @@
import type { Context } from 'cordis'
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -42,7 +43,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
/** Services required by the conversation plugin. */
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection']
// Static no-session sources for the composer-bar hooks compartment: module
// constants so the render side's per-source hook cache (observableHook) keeps
@@ -275,11 +276,12 @@ export function apply(ctx: Context): void {
},
openFile: (path) => {
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
// A file inside the workspace opens in a new tab, so a browser that
// is not on the Host machine can still see what the agent produced.
// Anything outside it has no served URL and falls back to the Host's
// own opener, which is loopback-only by the /api trust fence.
const url = workspaces.fileUrl(sessionId, cwd, path)
// A file inside the workspace opens in a new tab on the transport's
// workspace-file origin, so a browser that is not on the Host machine
// can still see what the agent produced. Anything outside it has no
// served URL and falls back to the Host's own opener, which is
// loopback-only by the /api trust fence.
const url = (ctx.get('connection') as ConnectionHandle).fileUrl(sessionId, cwd, path)
if (url !== undefined) {
window.open(url, '_blank', 'noopener,noreferrer')
return

View File

@@ -220,12 +220,15 @@ describe('conversation slot inject surface', () => {
it('openFile (chat view face) opens a workspace file in a tab and falls back to the host opener outside it', async () => {
const b = await bench()
// A host that publishes a workspace-file port: previews come from that
// origin, which is what keeps them off the API's.
b.runtime.connection.filesPort = 4321
const open = vi.spyOn(window, 'open').mockReturnValue(null)
const { injected } = b.chatViewSurface(ROOT)
// Inside the session cwd: served by this origin, so a browser anywhere on
// the network sees the file the agent produced.
// Inside the session cwd: served on the workspace-file origin, so a browser
// anywhere on the network sees the file the agent produced.
injected.openFile('src/a.ts')
expect(open).toHaveBeenCalledWith(`/f/${ROOT}/src/a.ts`, '_blank', 'noopener,noreferrer')
expect(open).toHaveBeenCalledWith(`http://localhost:4321/f/${ROOT}/src/a.ts`, '_blank', 'noopener,noreferrer')
expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false)
// Outside it there is no served URL, so the Host's own opener answers —
// resolved against the session cwd exactly as before.

View File

@@ -134,9 +134,11 @@ async function bench(snapshot: ConversationSnapshot) {
startSession: vi.fn(),
sendSession: vi.fn(),
openPath: vi.fn(async () => {}),
fileUrl: vi.fn((_sessionId: unknown, _cwd: string | undefined, path: string) => `/f/s-1/${path}`),
}
ctx.provide('workspaces', workspaces)
// The transport face the chat view reads its workspace-file URLs from.
const connection = { fileUrl: vi.fn((_s: unknown, _cwd: string | undefined, path: string) => `http://localhost:4321/f/s-1/${path}`) }
ctx.provide('connection', connection)
ctx.provide('layout', layout)
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
@@ -249,7 +251,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
view.getByText('notes/demo.txt').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(open).toHaveBeenCalledWith('/f/s-1/notes/demo.txt', '_blank', 'noopener,noreferrer')
expect(open).toHaveBeenCalledWith('http://localhost:4321/f/s-1/notes/demo.txt', '_blank', 'noopener,noreferrer')
})
open.mockRestore()
view.getByText('List notes').click()

View File

@@ -121,6 +121,7 @@ describe('keyed toolview hole through the real machinery', () => {
it('file-path clicks travel owner openFile → chat inject → the served workspace URL', async () => {
const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')])
b.runtime.connection.filesPort = 4321
const open = vi.spyOn(window, 'open').mockReturnValue(null)
const view = b.runtime.renderRoot()
view.getByText('src/a.ts').click()

View File

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