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:
@@ -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()
|
||||
})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user