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
@@ -275,6 +275,15 @@ 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)
if (url !== undefined) {
window.open(url, '_blank', 'noopener,noreferrer')
return
}
void workspaces.openPath(resolveToolPath(cwd, path)).catch(() => {
// Host/OS open failures stay silent in the chat row; the native
// app surfaces its own error dialog when the path is unusable.
@@ -218,13 +218,22 @@ describe('conversation slot inject surface', () => {
await b.runtime.dispose()
})
it('openFile (chat view face) resolves against session cwd and calls workspaces.openPath', async () => {
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()
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.
injected.openFile('src/a.ts')
expect(open).toHaveBeenCalledWith(`/f/${ROOT}/src/a.ts`, '_blank', 'noopener,noreferrer')
expect(b.runtime.workspaces.calls.some(c => c.method === 'openPath')).toBe(false)
// Outside it there is no served URL, so the Host's own opener answers —
// resolved against the session cwd exactly as before.
injected.openFile('/etc/hosts')
await vi.waitFor(() => {
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/proj/src/a.ts'] })
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['/etc/hosts'] })
})
open.mockRestore()
await b.runtime.dispose()
})
@@ -134,6 +134,7 @@ 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)
ctx.provide('layout', layout)
@@ -243,12 +244,14 @@ describe('run_code sub-calls through the real chat machinery', () => {
subCall(12, parent, 2, 'bash', { command: 'ls notes', description: 'List notes' }, 'demo.txt'),
]]])
const b = await bench(snapshotWith([codeResult(10, parent)], dispatches))
const open = vi.spyOn(window, 'open').mockReturnValue(null)
const view = mountApp(b.slots)
view.getByText('notes/demo.txt').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(b.workspaces.openPath).toHaveBeenCalledWith('notes/demo.txt')
expect(open).toHaveBeenCalledWith('/f/s-1/notes/demo.txt', '_blank', 'noopener,noreferrer')
})
open.mockRestore()
view.getByText('List notes').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
})
@@ -119,14 +119,16 @@ describe('keyed toolview hole through the real machinery', () => {
await b.runtime.dispose()
})
it('file-path clicks travel owner openFile → chat inject → workspaces.openPath', async () => {
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"}')])
const open = vi.spyOn(window, 'open').mockReturnValue(null)
const view = b.runtime.renderRoot()
view.getByText('src/a.ts').click()
expect(b.layout.openDetails).not.toHaveBeenCalled()
await vi.waitFor(() => {
expect(b.runtime.workspaces.calls).toContainEqual({ method: 'openPath', args: ['src/a.ts'] })
expect(open).toHaveBeenCalledWith(expect.stringContaining('/src/a.ts'), '_blank', 'noopener,noreferrer')
})
open.mockRestore()
await b.runtime.dispose()
})