diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml
index 584f3c899d..e8510d24e5 100644
--- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml
+++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md
-2026-07-31-web-workspace-file-links.md: a4bd8a2fecc2f1cb29d61a575b1ae0d31e5ebdd3
-2026-07-31-web-workspace-file-links.zh.md: 1d001710b2548343cb811fd6cca282cf956e95d9
+2026-07-31-web-workspace-file-links.md: 8eeb96517aa905e77e50ce36f0efb704353c0821
+2026-07-31-web-workspace-file-links.zh.md: 63d746b0a9aabaf78ba5653e16705bd662a54126
diff --git a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md
index a4bd8a2fec..8eeb96517a 100644
--- a/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md
+++ b/.agents/notes/implemented/feature/2026-07-31-web-workspace-file-links.md
@@ -20,9 +20,9 @@ The parts were nearly all present, pointed at the wrong target. `ToolRow` alread
**The URL shape lives in `dsh-host-apiproxy/api`, with the other browser-importable contract surfaces.** Both ends must agree on one encoding, but a client bundle may not value-import another plugin's package: the purity gate in `packages/client/tsdown.client.ts` allows only platform modules and the `INLINE_SAFE` wire layers, of which apiproxy is one. Putting `api/files.ts` there is what lets the browser half build a URL and the serving half parse it from a single source, and it needed no new package edge — both sides already depend on apiproxy.
-**Script-capable documents are served into an opaque origin.** `.html`/`.htm`/`.xhtml`/`.svg` carry `Content-Security-Policy: sandbox allow-scripts allow-popups allow-modals allow-forms`. The decision was briefly taken the other way, on the premise that these files are agent-authored and their author already holds this user's shell, so a browser sandbox would sit behind a trust boundary already crossed. Review falsified the premise: a workspace file need not be agent-authored at all — a read row makes every file in a cloned repository openable — and a same-origin active document was measured driving `/api/settings.describe` to a `200` with full data, so the loopback-pinned settings and credential plane was reachable from a page nobody in this session wrote. The cost is real and stays: a preview has no `localStorage` or cookies, so a generated page that remembers a theme will not. Restoring that without reopening the hole needs a separate origin, which is a different change; `host.openPath` remains the full-capability way to open the same file.
+**Workspace files get their own port, and therefore their own origin.** The isolation question was worked three ways before landing here. A sandbox header came first, on the reasoning that `/api/events.mux` is a readable same-origin `GET` stream. It was then dropped on the premise that these files are agent-authored, so a browser boundary would sit behind one already crossed — a premise review falsified: a read row makes every file in a cloned repository openable, and a same-origin active document was measured driving `/api/settings.describe` to a `200` with full data, reaching the loopback-pinned settings and credential plane from a page nobody in this session wrote. Restoring the sandbox closed that, and measuring what it cost decided the final shape: under `CSP: sandbox` the report's own artifact throws `SecurityError` on load, and because an uncaught exception aborts the rest of its ``
+ const head = html.indexOf('
')
+ if (head !== -1) return `${html.slice(0, head + 6)}${script}${html.slice(head + 6)}`
+ /* v8 ignore next -- headless fixture pages may lack ; prepending keeps read-before-shell ordering. */
+ return `${script}${html}`
+}
diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts
index 59bab263ea..f0a60bbfb9 100644
--- a/packages/client/connection/src/index.ts
+++ b/packages/client/connection/src/index.ts
@@ -4,14 +4,13 @@ import z from 'schemastery'
// Activates the httpServer Context merge used below.
import type { WebRoute } from '@deepseek-ai/dsh-host-webserver'
import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy'
-import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api'
// The merge-free types subpath: pulling the session package's root into this
// client-registered program would merge the host `sessions` service over the
// browser runtime's own.
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import { API_PATH } from './api-path.ts'
import { bridge } from './http-bridge.ts'
-import { handleWorkspaceFile } from './workspace-files.ts'
+import { injectFilesPort, listenForWorkspaceFiles } from './files-server.ts'
import { assertTrustedAuthority, isTrustedApiRequest } from './api-request-trust.ts'
export { API_PATH } from './api-path.ts'
@@ -74,8 +73,10 @@ const PRIVILEGED_METHODS = new Set([
* additionally pass it with an empty trust list, which pins them to loopback.
* @param ctx - Host plugin context.
* @param config - resolved plugin config (schema defaults applied).
+ * @returns a promise settling once the workspace-file listener is bound and
+ * its port published — the page must never render before it can address one.
*/
-export function apply(ctx: Context, config?: ConnectionConfig): void {
+export async function apply(ctx: Context, config?: ConnectionConfig): Promise {
// The Loader resolves schema defaults; hand-built test contexts may pass none.
const trustedHosts = config?.trustedHosts ?? []
// Config boundary: a malformed entry fails the load loudly here rather than
@@ -108,23 +109,19 @@ export function apply(ctx: Context, config?: ConnectionConfig): void {
// would merge their host-side Context declarations into the browser lane.
const cwdFor = (sessionId: string): Promise =>
ctx.apiProxy.workspaceRootOf(sessionId as SessionId)
- const filesRoute: WebRoute = {
- kind: 'prefix',
- path: FILES_PATH,
- handler: async (req, res) => {
- if (!isTrustedApiRequest(req, trustedHosts)) {
- res.writeHead(403)
- res.end('forbidden')
- return
- }
- if (req.method !== 'GET' && req.method !== 'HEAD') {
- // RFC 9110 §15.5.6: a 405 names the methods the resource does support.
- res.writeHead(405, { allow: 'GET, HEAD' })
- res.end()
- return
- }
- await handleWorkspaceFile(req, res, { cwdFor })
- },
- }
- ctx.effect(() => ctx.httpServer.register(filesRoute), 'client-connection: /f route')
+ // Workspace files get their own port, and therefore their own origin: an
+ // active document served beside `/api` would reach every method through the
+ // fence below. The listen is awaited inside the effect so the port is known
+ // before the index tap that publishes it can run.
+ await ctx.effect(async () => {
+ const files = await listenForWorkspaceFiles(
+ ctx.httpServer.host, trustedHosts, { cwdFor },
+ (error) => { ctx.logger.error(error) },
+ )
+ const untap = ctx.httpServer.tapIndex(html => injectFilesPort(html, files.port))
+ return async () => {
+ untap()
+ await files.close()
+ }
+ }, 'client-connection: /f listener')
}
diff --git a/packages/client/connection/src/workspace-files.ts b/packages/client/connection/src/workspace-files.ts
index e173c33a40..c934b14516 100644
--- a/packages/client/connection/src/workspace-files.ts
+++ b/packages/client/connection/src/workspace-files.ts
@@ -10,13 +10,11 @@
* owns the browser-trust fence ([api-request-trust](./api-request-trust.ts)) —
* this module is reached only by requests that already passed it.
*
- * Script-capable documents are served into an opaque origin. A workspace file
- * is not necessarily agent-authored — a read row makes every file in a cloned
- * repository openable — so an active document served same-origin with `/api`
- * reaches the whole RPC surface, the loopback-pinned settings and credential
- * methods included. The sandbox costs a preview its `localStorage` and
- * cookies; restoring those without reopening that hole needs a separate
- * origin, not a weaker header.
+ * Isolation is the listener's, not this module's: these responses carry no
+ * sandbox header because they are served from their own port, and therefore
+ * their own origin ([files-server](./files-server.ts)). A served document
+ * keeps `localStorage`, cookies, and its own `fetch`, while the API stays
+ * cross-origin to it.
*/
import { createReadStream } from 'node:fs'
@@ -59,17 +57,6 @@ const MIME: Record = {
const DEFAULT_MIME = 'text/plain; charset=utf-8'
-/** Extensions whose top-level navigation can execute script, and so need the sandbox. */
-const SCRIPTABLE = new Set(['.html', '.htm', '.xhtml', '.svg'])
-
-/**
- * The opaque origin an active workspace document runs in. Without it the
- * document is same-origin with `/api` and its script passes the browser-trust
- * fence, which admits every method — including the ones pinned to loopback
- * precisely because they mutate settings and credentials.
- */
-const SANDBOX_CSP = 'sandbox allow-scripts allow-popups allow-modals allow-forms'
-
/** How the route learns which directory a session may serve from. */
export interface WorkspaceFileDeps {
/**
@@ -160,7 +147,6 @@ export async function handleWorkspaceFile(
// Workspace files change under the agent's hands; a cached preview would
// show the previous turn's output after the next edit.
'cache-control': 'no-store',
- ...SCRIPTABLE.has(ext) ? { 'content-security-policy': SANDBOX_CSP } : {},
})
if (req.method === 'HEAD') {
res.end()
diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts
index 6892dc7721..4b323182bb 100644
--- a/packages/client/connection/tests/client-apply.spec.ts
+++ b/packages/client/connection/tests/client-apply.spec.ts
@@ -8,10 +8,11 @@ import { apply, type ConnectionHandle } from '../src/client/index.ts'
import { FixtureApiClient } from '../src/client/fixture.ts'
import { WebApiClient } from '../src/client/web-api-client.ts'
-type Win = { location?: { search: string } }
+type Win = { location?: { search: string; protocol?: string; hostname?: string }; __DSH_FILES_PORT__?: number }
afterEach(() => {
delete (globalThis as Win).location
+ delete (globalThis as Win).__DSH_FILES_PORT__
})
async function mount(): Promise {
@@ -62,4 +63,28 @@ describe('connection client apply', () => {
}
expect(seen.some(u => u.includes('/api/'))).toBe(true)
})
+
+ it('addresses a workspace file on the port the host published, and only inside the workspace', async () => {
+ const win = globalThis as Win
+ win.location = { search: '', protocol: 'http:', hostname: '192.168.1.5' }
+ win.__DSH_FILES_PORT__ = 4321
+ const handle = await mount()
+ const session = 's-1' as never
+ // Same hostname the page was reached by — a LAN client must reach previews
+ // too — and the published port, which is what makes it another origin.
+ expect(handle.fileUrl(session, '/w/alpha', '/w/alpha/out/a b.html'))
+ .toBe('http://192.168.1.5:4321/f/s-1/out/a%20b.html')
+ // Outside the workspace there is nothing this transport may serve, which
+ // is the signal a caller falls back to openPath on.
+ expect(handle.fileUrl(session, '/w/alpha', '/etc/hosts')).toBeUndefined()
+ })
+
+ it('serves no file URL on a page no host published a port into', async () => {
+ const win = globalThis as Win
+ win.location = { search: '?fixture', protocol: 'http:', hostname: '127.0.0.1' }
+ const handle = await mount()
+ // The keyless fixture lane: no workspace-file origin exists, so the row
+ // falls back to the Host opener instead of opening a dead tab.
+ expect(handle.fileUrl('s-1' as never, '/w', 'a.txt')).toBeUndefined()
+ })
})
diff --git a/packages/client/connection/tests/files-server.spec.ts b/packages/client/connection/tests/files-server.spec.ts
new file mode 100644
index 0000000000..4a2618709b
--- /dev/null
+++ b/packages/client/connection/tests/files-server.spec.ts
@@ -0,0 +1,44 @@
+/** The workspace-file listener's own failure and publication paths. */
+import { describe, expect, it } from 'vitest'
+import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api'
+import { injectFilesPort, listenForWorkspaceFiles } from '../src/files-server.ts'
+
+describe('workspace-file listener', () => {
+ it('answers 400 and reports the failure when the directory lookup throws', async () => {
+ const seen: Error[] = []
+ const files = await listenForWorkspaceFiles(
+ '127.0.0.1', [],
+ { cwdFor: () => Promise.reject(new Error('store unavailable')) },
+ (error) => { seen.push(error) },
+ )
+ try {
+ // A lookup failure is the host's problem, not a miss: it must not become
+ // an unhandled rejection, and it must not be reported as "not found".
+ const response = await fetch(`http://127.0.0.1:${String(files.port)}${FILES_PATH}/s-1/a.txt`)
+ expect(response.status).toBe(400)
+ expect(seen.map(error => error.message)).toEqual(['store unavailable'])
+ } finally {
+ await files.close()
+ }
+ })
+
+ it('closes idempotently and stops answering', async () => {
+ const files = await listenForWorkspaceFiles(
+ '127.0.0.1', [], { cwdFor: async () => undefined }, () => {},
+ )
+ const origin = `http://127.0.0.1:${String(files.port)}`
+ expect((await fetch(`${origin}${FILES_PATH}/s-1/a.txt`)).status).toBe(404)
+ await files.close()
+ await files.close()
+ await expect(fetch(`${origin}${FILES_PATH}/s-1/a.txt`)).rejects.toThrow()
+ })
+})
+
+describe('injectFilesPort', () => {
+ it('publishes the port as the first script in head', () => {
+ const html = injectFilesPort('x', 4321)
+ expect(html).toContain('')
+ // Ahead of anything the shell might read it from.
+ expect(html.indexOf('__DSH_FILES_PORT__')).toBeLessThan(html.indexOf(''))
+ })
+})
diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts
index 8ab1fbce8e..2561a0846f 100644
--- a/packages/client/connection/tests/node-half.spec.ts
+++ b/packages/client/connection/tests/node-half.spec.ts
@@ -15,14 +15,21 @@ import { FILES_PATH } from '@deepseek-ai/dsh-host-apiproxy/api'
import { API_PATH, apply, inject } from '../src/index.ts'
/** Structural httpServer fake: the plugin only touches register(). */
-function fakeHttpServer(routes: WebRoute[]): Pick {
+function fakeHttpServer(
+ routes: WebRoute[],
+ taps: ((html: string) => string)[] = [],
+): Pick {
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 = {}): ApiProxy {
async function mounted(
config?: { trustedHosts?: string[] },
workspaces: Record = {},
-): Promise<{ routes: WebRoute[]; dispose: () => Promise }> {
+): Promise<{ routes: WebRoute[]; taps: ((html: string) => string)[]; dispose: () => Promise }> {
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 {
+ 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), '')
+ 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 }> {
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 = { 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('ok
')
+ // A served document keeps its own capabilities: the port is the boundary,
+ // so nothing here strips the document of its origin.
+ expect(served.headers.get('content-security-policy')).toBeNull()
// A session the gateway names no directory for has no workspace to confine
// against, so there is nothing to serve.
- const unknown = fakeResponse()
- await filesRoute(routes).handler(head(`${FILES_PATH}/s-absent/index.html`), unknown.response)
- expect(unknown.state.status).toBe(404)
+ expect((await fetch(`${origin}${FILES_PATH}/s-absent/index.html`)).status).toBe(404)
await dispose()
await remove()
})
diff --git a/packages/client/connection/tests/workspace-files.spec.ts b/packages/client/connection/tests/workspace-files.spec.ts
index 6aee38e4d9..8e33a6751b 100644
--- a/packages/client/connection/tests/workspace-files.spec.ts
+++ b/packages/client/connection/tests/workspace-files.spec.ts
@@ -59,27 +59,25 @@ function get(path: string, init?: RequestInit): Promise {
}
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('产物
')
expect(response.headers.get('content-type')).toBe('text/html; charset=utf-8')
- // A workspace file is not necessarily agent-authored, and same-origin
- // script here would pass the browser-trust fence into every RPC method.
- expect(response.headers.get('content-security-policy')).toContain('sandbox')
- expect(response.headers.get('content-security-policy')).not.toContain('allow-same-origin')
+ // No isolation header: the listener's own port is the origin boundary, so
+ // a preview keeps localStorage and cookies (see files-server).
+ expect(response.headers.get('content-security-policy')).toBeNull()
expect(response.headers.get('x-content-type-options')).toBe('nosniff')
expect(response.headers.get('cache-control')).toBe('no-store')
expect(response.headers.get('content-disposition')).toBe('inline')
})
- it('sandboxes SVG too, and leaves inert types unrestricted', async () => {
+ it('types SVG as a standalone document rather than sniffable bytes', async () => {
const svg = await get(`${FILES_PATH}/${SESSION}/chart.svg`)
expect(svg.headers.get('content-type')).toBe('image/svg+xml')
- expect(svg.headers.get('content-security-policy')).toContain('sandbox')
+ expect(svg.headers.get('x-content-type-options')).toBe('nosniff')
const text = await get(`${FILES_PATH}/${SESSION}/notes.txt`)
expect(text.headers.get('content-type')).toBe('text/plain; charset=utf-8')
- expect(text.headers.get('content-security-policy')).toBeNull()
})
it('serves a workspace rooted at a filesystem root, whose realpath already ends in a separator', async () => {
diff --git a/packages/client/runtime/src/client/contract/workspaces.ts b/packages/client/runtime/src/client/contract/workspaces.ts
index dbc0f3b30f..3e64ef3717 100644
--- a/packages/client/runtime/src/client/contract/workspaces.ts
+++ b/packages/client/runtime/src/client/contract/workspaces.ts
@@ -56,17 +56,6 @@ export interface IWorkspaces {
* @param path - absolute or host-resolvable path.
*/
openPath(path: string): Promise
- /**
- * URL serving one file out of a session's workspace, for a UI that opens a
- * produced file in the browser instead of on the Host machine.
- * @param sessionId - the session whose cwd anchors the path.
- * @param cwd - that session's working directory, or `undefined` when unknown.
- * @param path - the path a tool reported (absolute, or relative to `cwd`).
- * @returns the origin-relative URL, or `undefined` when the path lies
- * outside the workspace — which this transport never serves, leaving
- * {@link IWorkspaces.openPath} as the only way to reach it.
- */
- fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined
/**
* Rename a Workspace.
* @param workspaceId - target workspace.
diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts
index 837a7daa03..c0eb46fcf9 100644
--- a/packages/client/runtime/src/client/workspaces/service.ts
+++ b/packages/client/runtime/src/client/workspaces/service.ts
@@ -5,7 +5,6 @@ import type {
DirectoryListing, IApiClient, RpcError,
SessionId, WorkspaceId, WorkspaceView,
} from '@deepseek-ai/dsh-client-connection/client'
-import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { SnapshotStore } from '../contract/store.ts'
import { createSnapshotStore } from '../contract/store.ts'
import type { SessionsPort, SessionsPortList } from '../contract/sessions-port.ts'
@@ -240,18 +239,6 @@ export class WorkspacesService implements IWorkspaces {
}
}
- /**
- * URL serving one file out of a session's workspace.
- * @param sessionId - the session whose cwd anchors the path.
- * @param cwd - that session's working directory, or `undefined` when unknown.
- * @param path - the path a tool reported (absolute, or relative to `cwd`).
- * @returns the origin-relative URL, or `undefined` for a path outside the workspace.
- */
- fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined {
- const segments = workspaceFileSegments(cwd, path)
- if (segments === undefined) return undefined
- return workspaceFileUrl(sessionId, segments)
- }
/**
* Rename a Workspace.
diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts
index d389efe319..a5827173a8 100644
--- a/packages/client/runtime/tests/client-apply.spec.ts
+++ b/packages/client/runtime/tests/client-apply.spec.ts
@@ -26,6 +26,7 @@ async function mount(): Promise {
const bench: Bench = { ctx, api, sinks: undefined, stopped: 0 }
const handle: ConnectionHandle = {
api,
+ fileUrl: () => undefined,
start: (sinks) => {
bench.sinks = sinks
return { stop: () => { bench.stopped += 1 } }
diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts
index fd7858d60c..a35983d890 100644
--- a/packages/client/runtime/tests/wire-events.spec.ts
+++ b/packages/client/runtime/tests/wire-events.spec.ts
@@ -20,6 +20,7 @@ async function mount(): Promise {
const bench: Bench = { ctx, sinks: undefined }
const handle: ConnectionHandle = {
api,
+ fileUrl: () => undefined,
start: (sinks) => {
bench.sinks = sinks
return { stop: () => {} }
diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts
index 3d9cef547f..4323d7ffce 100644
--- a/packages/client/runtime/tests/workspaces-service.spec.ts
+++ b/packages/client/runtime/tests/workspaces-service.spec.ts
@@ -276,21 +276,6 @@ describe('WorkspacesService', () => {
await expect(workspaces.openPath('/missing')).rejects.toThrow(/path open failed/)
})
- it('addresses a workspace file by URL, and only inside the workspace', async () => {
- const ctx = new Context()
- const api = new FakeApiClient()
- const sessions = new SessionsService(ctx, api)
- const workspaces = new WorkspacesService(ctx, api, sessions)
- const session = 's-1' as SessionId
- // The URL is derived, not fetched: no wire call answers a link.
- expect(workspaces.fileUrl(session, '/w/alpha', '/w/alpha/out/a b.html')).toBe('/f/s-1/out/a%20b.html')
- expect(workspaces.fileUrl(session, '/w/alpha', 'out/index.html')).toBe('/f/s-1/out/index.html')
- // Outside the workspace there is nothing this transport may serve, which
- // is the signal a caller falls back to openPath on.
- expect(workspaces.fileUrl(session, '/w/alpha', '/etc/hosts')).toBeUndefined()
- expect(api.calls).toHaveLength(0)
- })
-
it('deletes a Workspace or preserves it when the Host rejects deletion', async () => {
const ctx = new Context()
const api = new FakeApiClient()
diff --git a/packages/client/test-runtime/package.json b/packages/client/test-runtime/package.json
index e892d9cd52..6d7093a148 100644
--- a/packages/client/test-runtime/package.json
+++ b/packages/client/test-runtime/package.json
@@ -25,6 +25,7 @@
"vitest": "^4.1.8"
},
"peerDependencies": {
+ "@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
"@deepseek-ai/dsh-client-web-react": "^0.0.1",
@@ -35,6 +36,7 @@
"react-dom": "^18.2.0"
},
"devDependencies": {
+ "@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-client-web-react": "workspace:^",
diff --git a/packages/client/test-runtime/src/connection.ts b/packages/client/test-runtime/src/connection.ts
new file mode 100644
index 0000000000..5df5d5a053
--- /dev/null
+++ b/packages/client/test-runtime/src/connection.ts
@@ -0,0 +1,48 @@
+/** Test-owned connection face: the transport members features read off `ctx.connection`. */
+import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api'
+import type { ConnectionHandle, IApiClient, SessionId } from '@deepseek-ai/dsh-client-connection/client'
+
+/**
+ * Connection test double. Implements the same `ConnectionHandle` face features
+ * receive as `ctx.connection`, so a production face change breaks this double
+ * at compile time. The wire client is not modelled — a feature that needs one
+ * composes its own connection over a fake api client; this double exists for
+ * the transport facts features read synchronously, above all the
+ * workspace-file URL.
+ */
+export class TestConnection implements ConnectionHandle {
+ /**
+ * The workspace-file port the host would have published into the page.
+ * Unset — the default, and the keyless fixture lane's real state — makes
+ * {@link TestConnection.fileUrl} answer `undefined`, which is the signal a
+ * caller falls back to the Host opener on.
+ */
+ filesPort: number | undefined
+
+ /** The wire client; unused by this double's consumers and absent by construction. */
+ readonly api: IApiClient = undefined as unknown as IApiClient
+
+ /**
+ * Stream-loop starter (inert).
+ * @returns a stop handle that does nothing.
+ */
+ start(): { stop(): void } {
+ return { stop: () => {} }
+ }
+
+ /**
+ * Workspace-file URL, deriving exactly as production does so a feature test
+ * sees the real inside/outside-workspace split.
+ * @param sessionId - the Session whose cwd anchors the path.
+ * @param cwd - that Session's working directory.
+ * @param path - the path a tool reported.
+ * @returns the absolute URL on the workspace-file origin, or undefined when
+ * the path leaves the workspace or no port is published.
+ */
+ fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined {
+ if (this.filesPort === undefined) return undefined
+ const segments = workspaceFileSegments(cwd, path)
+ if (segments === undefined) return undefined
+ return `http://localhost:${String(this.filesPort)}${workspaceFileUrl(sessionId, segments)}`
+ }
+}
diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts
index 5ef5350434..cdbdca75a2 100644
--- a/packages/client/test-runtime/src/index.ts
+++ b/packages/client/test-runtime/src/index.ts
@@ -29,11 +29,13 @@ import type {
} from '@deepseek-ai/dsh-client-ui-slots'
import { registerDomSnapshotSerializer } from './snapshot.ts'
import { TestSessions } from './sessions.ts'
+import { TestConnection } from './connection.ts'
import { TestWorkspaces } from './workspaces.ts'
import type { Stabilizer } from './fixtures.ts'
export { domSnapshotSerializer, registerDomSnapshotSerializer } from './snapshot.ts'
export { FixtureSession, TestSessions } from './sessions.ts'
+export { TestConnection } from './connection.ts'
export { TestWorkspaces } from './workspaces.ts'
export { conversationSnapshot, workspaceListState } from './fixtures.ts'
export type { SessionBehaviorOverrides, SessionFixture, Stabilizer } from './fixtures.ts'
@@ -175,6 +177,8 @@ export class SlotTestRuntime {
readonly sessions: TestSessions
/** Workspaces double (list observable, recorded intent actions). */
readonly workspaces: TestWorkspaces
+ /** The transport double features read as `ctx.connection`. */
+ readonly connection: TestConnection
private readonly stabilizer: Stabilizer = async (fn) => {
await act(async () => { await fn() })
@@ -195,8 +199,10 @@ export class SlotTestRuntime {
this.root = new TestRoot(slots, this.stabilizer)
this.sessions = new TestSessions(this.stabilizer, ctx)
this.workspaces = new TestWorkspaces(this.stabilizer)
+ this.connection = new TestConnection()
ctx.provide('sessions', this.sessions)
ctx.provide('workspaces', this.workspaces)
+ ctx.provide('connection', this.connection)
// Capturing install: the production renderer does the rendering; the
// wrapper only takes the host face for storeOf (no machinery copied).
const renderer = createSlotRenderer()
diff --git a/packages/client/test-runtime/src/workspaces.ts b/packages/client/test-runtime/src/workspaces.ts
index 01e7db4c3d..95f6574405 100644
--- a/packages/client/test-runtime/src/workspaces.ts
+++ b/packages/client/test-runtime/src/workspaces.ts
@@ -1,6 +1,5 @@
/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
-import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -99,21 +98,6 @@ export class TestWorkspaces implements IWorkspaces {
await (this.stubs.get('openPath')?.(path) as Promise | undefined)
}
- /**
- * Workspace-file URL (recorded). Runs the production path derivation so a
- * feature test sees the real in/outside-workspace split; stub to force either.
- * @param sessionId - the session whose cwd anchors the path.
- * @param cwd - that session's working directory.
- * @param path - the path a tool reported.
- * @returns the origin-relative URL, or undefined outside the workspace.
- */
- fileUrl(sessionId: SessionId, cwd: string | undefined, path: string): string | undefined {
- this.calls.push({ method: 'fileUrl', args: [sessionId, cwd, path] })
- const stub = this.stubs.get('fileUrl')
- if (stub !== undefined) return stub(sessionId, cwd, path) as string | undefined
- const segments = workspaceFileSegments(cwd, path)
- return segments === undefined ? undefined : workspaceFileUrl(sessionId, segments)
- }
/**
* Directory picker (recorded). The default cancels (null); stub to select.
diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx
index a9c4b0c9ca..3675671f26 100644
--- a/packages/client/test-runtime/tests/runtime.spec.tsx
+++ b/packages/client/test-runtime/tests/runtime.spec.tsx
@@ -549,10 +549,6 @@ describe('workspaces action face', () => {
expect(renamed.title).toBe('Renamed')
await ws.delete('w1' as WorkspaceId)
await ws.openPath('/proj/file.ts')
- // fileUrl runs the production derivation, so a feature test sees the same
- // inside/outside-workspace split the browser half decides on.
- expect(ws.fileUrl('s1' as SessionId, '/proj', 'out/a.html')).toBe('/f/s1/out/a.html')
- expect(ws.fileUrl('s1' as SessionId, '/proj', '/etc/hosts')).toBeUndefined()
const moved = await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId, 's2' as SessionId)
expect(moved.sessionIds).toEqual(['s1'])
// Default archive mirrors the production effect: the id joins the list
@@ -560,15 +556,13 @@ describe('workspaces action face', () => {
await ws.archiveSession('s1' as SessionId)
expect(ws.list.getSnapshot().archivedSessionIds).toEqual(['s1'])
expect(ws.calls.map(c => c.method)).toEqual(
- ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'fileUrl', 'fileUrl',
- 'insertSessionBefore', 'archiveSession'])
+ ['create', 'create', 'pickDirectory', 'rename', 'delete', 'openPath', 'insertSessionBefore', 'archiveSession'])
ws.stub('create', () => Promise.resolve({ workspaceId: 'ws-x', title: 'X', path: '/x', sessionIds: [] } as never))
ws.stub('pickDirectory', () => Promise.resolve('/picked'))
ws.stub('rename', () => Promise.resolve({ workspaceId: 'w1', title: 'S', path: '/s', sessionIds: [] } as never))
ws.stub('delete', () => Promise.resolve())
ws.stub('openPath', () => Promise.resolve())
- ws.stub('fileUrl', () => '/f/forced/a.html')
ws.stub('insertSessionBefore', () => Promise.resolve({ workspaceId: 'w1', title: '', path: '', sessionIds: [] } as never))
ws.stub('archiveSession', () => Promise.resolve())
expect((await ws.create({ name: 'y' })).title).toBe('X')
@@ -576,7 +570,6 @@ describe('workspaces action face', () => {
expect((await ws.rename('w1' as WorkspaceId, 'z')).title).toBe('S')
await ws.delete('w1' as WorkspaceId)
await ws.openPath('/other')
- expect(ws.fileUrl('s1' as SessionId, '/proj', '/etc/hosts')).toBe('/f/forced/a.html')
expect((await ws.insertSessionBefore('w1' as WorkspaceId, 's1' as SessionId)).sessionIds).toEqual([])
// The stub replaces the default set mutation: the set stays as-is.
await ws.archiveSession('s2' as SessionId)
diff --git a/packages/client/test-runtime/tsconfig.json b/packages/client/test-runtime/tsconfig.json
index 6a758c66f9..681bff474c 100644
--- a/packages/client/test-runtime/tsconfig.json
+++ b/packages/client/test-runtime/tsconfig.json
@@ -17,6 +17,9 @@
{
"path": "../web-react"
},
+ {
+ "path": "../connection"
+ },
{
"path": "../runtime"
},
diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml
index 2a2857839d..446c2084b1 100644
--- a/packages/client/ui-conversation/README.i18n.yaml
+++ b/packages/client/ui-conversation/README.i18n.yaml
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-conversation/README.md
-README.md: ba55f0704500034b7afb37258064fe0801aaee91
-README.zh.md: 4212908b355a81dfd5af8645ce5d4284a4555622
+README.md: 8c2075d615eccad1bbc7f5de1255ea4add69fab8
+README.zh.md: 634721b4248da75cbd4e81528340936a31ece28d
diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md
index ba55f07045..8c2075d615 100644
--- a/packages/client/ui-conversation/README.md
+++ b/packages/client/ui-conversation/README.md
@@ -14,7 +14,7 @@ Approvals take over the composer through the chain this package declares: `Appro
Logged non-user messages render as a default-collapsed `上下文注入` disclosure. It shares the Tool calls header geometry and interaction with `ToolRow` through the package-internal `DisclosureRow`, while retaining context semantics: the expanded body follows its content height up to a 141px scrolling cap, shows inline JSON for both `content` and `source`, and synthesizes no tool state, summary, or keyed toolview dispatch ([decision](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md)).
-Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file: one inside the session workspace opens in a new browser tab, served by the web transport's `/f` route, so a client that is not on the Host machine still sees it; one outside the workspace has no served URL and falls back to the Host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
+Generic tool rows classify the built-in bash, read, search, write, edit, and run_code names into dedicated visual variants. The filesystem variants render the edit icon and a path summary; that path is a hover-underline link that opens the file: one inside the session workspace opens in a new browser tab on the transport's workspace-file origin (`ConnectionHandle.fileUrl`), so a client that is not on the Host machine still sees it; one outside the workspace has no served URL and falls back to the Host OS default application (`host.openPath`, relative paths resolve against the session cwd). Tool rows are not whole-row click targets and do not open the details panel. The code variant summarizes with the model-authored `description` and expands to the program itself; its logged sub-dispatches render as always-visible nested rows through the SAME keyed toolview hole (custom registrations and the GenericToolCard fallback apply to sub-rows unchanged). Cordis lifecycle tools reuse those generic variants while presenting `Inspect`, `Mount temporary Plugin`, and `Unmount temporary Plugin` with a shared Cordis accent; mount keeps the code variant's expandable source rendering.
A tool call declaring the `terminal` render intent renders its command output inline, at both conversation render sites, through ui-primitives' `TerminalBlock`. `contract/terminal-card-model.ts` is the single derivation from the snapshot's `callView`/`resultView` pair, so the sites cannot disagree about a command, its cwd, or its exit status; it yields null — the generic path — for any other card tag, including one this client version does not know. Both sites therefore also show the card's run-state dot, which is the same `StateDot` semantic a tool row's leading icon carries, so a row and its own card always agree about one command's state. A multi-line command gets one prompt row per line, with the dot marking the call once on the first row — the exit status is the whole call's, so a dot per line would claim a per-line outcome bash does not report. The keyed `BashRow` carries the card resident below its summary row; since tool rows are no longer details-panel click targets, the card's copy and expand controls are the row's only interactions. The render-site fallback row keeps the card behind its existing expand control. Rows cap at `CHAT_TERMINAL_MAX_LINES` (8) against the panel's 16, which is what keeps a summary surface bounded — the panel stays the single-call reading surface. Inline output is licensed per render intent — the terminal and web cards, each with its own bound; a generic tool's content remains panel-only ([decision](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md)).
diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md
index 4212908b35..634721b424 100644
--- a/packages/client/ui-conversation/README.zh.md
+++ b/packages/client/ui-conversation/README.zh.md
@@ -12,7 +12,7 @@
已记录的非用户消息渲染为默认折叠的 `上下文注入` 展开项。它通过包内部的 `DisclosureRow` 与 `ToolRow` 共享 Tool calls 标题栏的几何与交互,同时保留上下文语义:展开内容区的高度会随内容自适应,最大为 141px,超出后滚动,并以内联 JSON 展示 `content` 和 `source`,且不会合成工具状态、摘要或键控 toolview 分发([决策](../../../.agents/notes/implemented/feature/2026-07-30-web-context-injection-disclosure.md))。
-通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击即打开文件:位于会话工作区之内的文件在新浏览器标签页打开,由 web 传输的 `/f` 路由提供,因此不在 Host 机器上的客户端也能看到;工作区之外的文件没有可服务的 URL,回退到宿主操作系统的默认应用(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
+通用工具行把内置的 bash、read、search、write、edit 和 run_code 名称归入专用视觉变体。文件系统变体会渲染 edit 图标和路径摘要;该路径是悬停下划线链接,点击即打开文件:位于会话工作区之内的文件在新浏览器标签页打开,位于传输层的工作区文件源上(`ConnectionHandle.fileUrl`),因此不在 Host 机器上的客户端也能看到;工作区之外的文件没有可服务的 URL,回退到宿主操作系统的默认应用(`host.openPath`,相对路径相对会话 cwd 解析)。工具行不再是整行点击目标,也不会打开 details 面板。code 变体以模型撰写的 `description` 作摘要,展开后显示程序本身;其已记录的子调用经由同一个键控 toolview 空位渲染为始终可见的嵌套行(自定义注册和 GenericToolCard fallback 原样适用于子行)。Cordis 生命周期工具复用这些通用变体,同时以统一的 Cordis 强调色呈现 `Inspect`、`Mount temporary Plugin` 和 `Unmount temporary Plugin`;mount 行保留 code 变体的可展开源码渲染。
声明 `terminal` 渲染意图的工具调用,会在两个对话渲染点上都通过 ui-primitives 的 `TerminalBlock` 内联渲染其命令输出。`contract/terminal-card-model.ts` 是从快照的 `callView`/`resultView` 对推导的唯一位置,因此两个渲染点不可能在命令、cwd 或退出状态上产生分歧;对任何其他 card 标签——包括当前客户端版本不认识的标签——它返回 null,落回通用路径。因此两个渲染点也都显示卡片的运行状态点,它与工具行行首图标承载同一套 `StateDot` 语义,所以一行与其自身的卡片对同一条命令的状态总是一致。多行命令的每一行各占一个提示行,状态点只在第一行为整次调用标记一次——退出状态属于整次调用,因此每行一枚就会声称一个 bash 并不报告的逐行结果。键控的 `BashRow` 把卡片常驻在摘要行下方;由于工具行已不再是详情面板的点击目标,卡片的复制与展开控件就是该行唯一的交互。渲染点兜底行则保持其既有的展开控件。行的上限是 `CHAT_TERMINAL_MAX_LINES`(8),面板为 16,正是这一点让摘要面保持有界——面板仍是单次调用的阅读面。内联输出按渲染意图开放——终端卡片与 web 卡片,各有自己的上限;通用工具的内容仍然只在面板中呈现([决策](../../../.agents/notes/implemented/feature/2026-07-28-web-terminal-card.md))。
diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json
index 88c09b5550..55036600c6 100644
--- a/packages/client/ui-conversation/package.json
+++ b/packages/client/ui-conversation/package.json
@@ -39,6 +39,7 @@
"clsx": "^2.0.0"
},
"peerDependencies": {
+ "@deepseek-ai/dsh-client-connection": "^0.0.1",
"@deepseek-ai/dsh-client-locale": "^0.0.1",
"@deepseek-ai/dsh-client-runtime": "^0.0.1",
"@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
@@ -50,6 +51,7 @@
"react": "^18.2.0"
},
"devDependencies": {
+ "@deepseek-ai/dsh-client-connection": "workspace:^",
"@deepseek-ai/dsh-client-locale": "workspace:^",
"@deepseek-ai/dsh-client-runtime": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts
index 71f05267b3..a65ce1d7a4 100644
--- a/packages/client/ui-conversation/src/client/apply.ts
+++ b/packages/client/ui-conversation/src/client/apply.ts
@@ -2,6 +2,7 @@
import type { Context } from 'cordis'
import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots'
import type { ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
+import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
import type {} from '@deepseek-ai/dsh-client-ui-layout/client'
// Type-only: pulls the locale plugin's Context merge (ctx.locale).
import type {} from '@deepseek-ai/dsh-client-locale/client'
@@ -42,7 +43,7 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
}
/** Services required by the conversation plugin. */
-export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale']
+export const inject = ['slots', 'layout', 'sessions', 'workspaces', 'locale', 'connection']
// Static no-session sources for the composer-bar hooks compartment: module
// constants so the render side's per-source hook cache (observableHook) keeps
@@ -275,11 +276,12 @@ export function apply(ctx: Context): void {
},
openFile: (path) => {
const cwd = sessions.list.getSnapshot().byId[sessionId]?.cwd
- // A file inside the workspace opens in a new tab, so a browser that
- // is not on the Host machine can still see what the agent produced.
- // Anything outside it has no served URL and falls back to the Host's
- // own opener, which is loopback-only by the /api trust fence.
- const url = workspaces.fileUrl(sessionId, cwd, path)
+ // A file inside the workspace opens in a new tab on the transport's
+ // workspace-file origin, so a browser that is not on the Host machine
+ // can still see what the agent produced. Anything outside it has no
+ // served URL and falls back to the Host's own opener, which is
+ // loopback-only by the /api trust fence.
+ const url = (ctx.get('connection') as ConnectionHandle).fileUrl(sessionId, cwd, path)
if (url !== undefined) {
window.open(url, '_blank', 'noopener,noreferrer')
return
diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx
index 6427f6750c..b9dbe0d6ad 100644
--- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx
+++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx
@@ -220,12 +220,15 @@ describe('conversation slot inject surface', () => {
it('openFile (chat view face) opens a workspace file in a tab and falls back to the host opener outside it', async () => {
const b = await bench()
+ // A host that publishes a workspace-file port: previews come from that
+ // origin, which is what keeps them off the API's.
+ b.runtime.connection.filesPort = 4321
const open = vi.spyOn(window, 'open').mockReturnValue(null)
const { injected } = b.chatViewSurface(ROOT)
- // Inside the session cwd: served by this origin, so a browser anywhere on
- // the network sees the file the agent produced.
+ // Inside the session cwd: served on the workspace-file origin, so a browser
+ // anywhere on the network sees the file the agent produced.
injected.openFile('src/a.ts')
- expect(open).toHaveBeenCalledWith(`/f/${ROOT}/src/a.ts`, '_blank', 'noopener,noreferrer')
+ expect(open).toHaveBeenCalledWith(`http://localhost:4321/f/${ROOT}/src/a.ts`, '_blank', 'noopener,noreferrer')
expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false)
// Outside it there is no served URL, so the Host's own opener answers —
// resolved against the session cwd exactly as before.
diff --git a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx
index 51e31c9750..2763702c0b 100644
--- a/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-code-subcalls.spec.tsx
@@ -134,9 +134,11 @@ async function bench(snapshot: ConversationSnapshot) {
startSession: vi.fn(),
sendSession: vi.fn(),
openPath: vi.fn(async () => {}),
- fileUrl: vi.fn((_sessionId: unknown, _cwd: string | undefined, path: string) => `/f/s-1/${path}`),
}
ctx.provide('workspaces', workspaces)
+ // The transport face the chat view reads its workspace-file URLs from.
+ const connection = { fileUrl: vi.fn((_s: unknown, _cwd: string | undefined, path: string) => `http://localhost:4321/f/s-1/${path}`) }
+ ctx.provide('connection', connection)
ctx.provide('layout', layout)
const locale = new LocaleService(ctx)
ctx.provide('locale', locale)
@@ -249,7 +251,7 @@ describe('run_code sub-calls through the real chat machinery', () => {
view.getByText('notes/demo.txt').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
await vi.waitFor(() => {
- expect(open).toHaveBeenCalledWith('/f/s-1/notes/demo.txt', '_blank', 'noopener,noreferrer')
+ expect(open).toHaveBeenCalledWith('http://localhost:4321/f/s-1/notes/demo.txt', '_blank', 'noopener,noreferrer')
})
open.mockRestore()
view.getByText('List notes').click()
diff --git a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
index 6cb46e7ea0..84cdd53eeb 100644
--- a/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
+++ b/packages/client/ui-conversation/tests/chat-toolview-slot.spec.tsx
@@ -121,6 +121,7 @@ describe('keyed toolview hole through the real machinery', () => {
it('file-path clicks travel owner openFile → chat inject → the served workspace URL', async () => {
const b = await bench([toolResult(3, 'c1', 'read', '{"path":"src/a.ts"}')])
+ b.runtime.connection.filesPort = 4321
const open = vi.spyOn(window, 'open').mockReturnValue(null)
const view = b.runtime.renderRoot()
view.getByText('src/a.ts').click()
diff --git a/packages/client/ui-conversation/tsconfig.json b/packages/client/ui-conversation/tsconfig.json
index 04b265bdd5..33d45124b4 100644
--- a/packages/client/ui-conversation/tsconfig.json
+++ b/packages/client/ui-conversation/tsconfig.json
@@ -20,6 +20,9 @@
{
"path": "../web-react"
},
+ {
+ "path": "../connection"
+ },
{
"path": "../runtime"
},
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 7073f04e4b..5fb7df52ad 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -1183,6 +1183,9 @@ importers:
specifier: ^4.1.8
version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@25.9.3)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0))
devDependencies:
+ '@deepseek-ai/dsh-client-connection':
+ specifier: workspace:^
+ version: link:../connection
'@deepseek-ai/dsh-client-runtime':
specifier: workspace:^
version: link:../runtime
@@ -1263,6 +1266,9 @@ importers:
specifier: ^2.0.0
version: 2.1.1
devDependencies:
+ '@deepseek-ai/dsh-client-connection':
+ specifier: workspace:^
+ version: link:../connection
'@deepseek-ai/dsh-client-locale':
specifier: workspace:^
version: link:../locale