feat(web): open a produced file from the conversation

Serve one file at a time out of a Session's workspace under /f on the web
transport, and point the conversation's existing file-open affordance at it.
Clicking a write/edit/read row's path now opens that file in a browser tab —
including from a LAN client, where the Host's system opener is fenced to
loopback and answered nothing.

- /f/<sessionId>/<segments> in client-connection, behind the same
  browser-trust fence as /api; realpath confinement, streamed reads,
  GET/HEAD only, nosniff + no-store.
- Script-capable documents carry CSP sandbox: model-authored markup must not
  be same-origin with /api, where events.mux is a readable GET stream.
- ApiProxy.workspaceRootOf answers where a Session's files live without
  resuming an agent; the client program cannot reach the core services.
- The /f URL shape lives in dsh-host-apiproxy/api so both ends share one
  encoding (client bundles may not value-import another plugin).
This commit is contained in:
ZiyaZhang
2026-07-31 12:07:43 -07:00
parent 992fdc0cee
commit 00390ae851
35 changed files with 946 additions and 30 deletions

View File

@@ -1,5 +1,6 @@
/** Test-owned workspaces face: the renderer standard-kit observable plus recorded actions. */
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
import { workspaceFileSegments, workspaceFileUrl } from '@deepseek-ai/dsh-host-apiproxy/api'
import type {
DirectoryListing, IWorkspaces, SessionId, SnapshotStore, WorkspaceId, WorkspaceListState, WorkspaceView,
} from '@deepseek-ai/dsh-client-runtime/client'
@@ -98,6 +99,22 @@ export class TestWorkspaces implements IWorkspaces {
await (this.stubs.get('openPath')?.(path) as Promise<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.
* @returns the picked path, or null.

View File

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