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

@@ -62,7 +62,11 @@ function stubAgent(session: Session): Agent {
async function harness(
workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
extras: { openPath?: (path: string, signal: AbortSignal) => Promise<void> } = {},
extras: {
openPath?: (path: string, signal: AbortSignal) => Promise<void>
/** Store contents behind the gateway, or 'absent' for a composition with no persistence at all. */
persisted?: { id: SessionId; cwd?: string }[] | 'absent'
} = {},
) {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -73,7 +77,10 @@ async function harness(
const storageDomain = new DomainFacility(ctx, { backend: 'memory', routes: {} })
ctx.storage.mount('domain', storageDomain)
ctx.provide('storageDomain', storageDomain)
ctx.provide('sessionPersistence', { list: () => Promise.resolve([]) } as never)
if (extras.persisted !== 'absent') {
const persisted = extras.persisted ?? []
ctx.provide('sessionPersistence', { list: () => Promise.resolve(persisted) } as never)
}
await ctx.plugin(WorkspaceRegistry)
const factory: AgentFactory = {
@@ -244,6 +251,27 @@ describe('host.openPath', () => {
})
})
describe('workspaceRootOf', () => {
it('answers from the live agent, then the store, and names nothing for an unknown session', async () => {
const { api, workspaceRoot } = await harness(undefined, undefined, {
persisted: [{ id: 's-cold' as SessionId, cwd: '/w/cold' }],
})
const created = await api.sessions.create(request({ cwd: workspaceRoot }))
const sessionId = (created.result as { ok: true; value: { sessionId: SessionId } }).value.sessionId
// Live: the agent's own header, no store read involved.
await expect(api.workspaceRootOf(sessionId)).resolves.toBe(workspaceRoot)
// Not live: the store answers, and the lookup never resumes an agent —
// this harness's factory throws on resume, so a resuming lookup would fail.
await expect(api.workspaceRootOf('s-cold' as SessionId)).resolves.toBe('/w/cold')
await expect(api.workspaceRootOf('s-absent' as SessionId)).resolves.toBeUndefined()
})
it('names nothing at all when the host keeps no session store', async () => {
const { api } = await harness(undefined, undefined, { persisted: 'absent' })
await expect(api.workspaceRootOf('s-any' as SessionId)).resolves.toBeUndefined()
})
})
describe('workspace.create', () => {
it('serializes concurrent names and rejects the duplicate', async () => {
const { api, workspaceRoot } = await harness()

View File

@@ -108,6 +108,8 @@ function scriptedApi(overrides: {
},
events: { mux: () => empty<MuxFrame>(), host: () => empty<HostFrame>(), ...overrides.events },
respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })),
// No wire face, so the handler map never reaches it.
workspaceRootOf: () => Promise.resolve(undefined),
}
}

View File

@@ -233,6 +233,8 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
async respond(message: ClientResponse): Promise<RpcReceipt> {
return message.rpcId === 'known' ? { accepted: true } : { accepted: false, reason: 'not-pending' }
},
// No wire face, so the carrier never reaches it.
workspaceRootOf: () => Promise.resolve(undefined),
}
}

View File

@@ -0,0 +1,74 @@
/** The /f URL shape: one encoding decision, asserted from both ends. */
import { describe, expect, it } from 'vitest'
import {
FILES_PATH, parseWorkspaceFilePath, workspaceFileSegments, workspaceFileUrl,
} from '../src/api/files.ts'
describe('workspaceFileSegments', () => {
it('keeps a relative path as its own segments', () => {
expect(workspaceFileSegments('/w', 'out/index.html')).toEqual(['out', 'index.html'])
expect(workspaceFileSegments(undefined, 'index.html')).toEqual(['index.html'])
expect(workspaceFileSegments('/w', './a/./b.txt')).toEqual(['a', 'b.txt'])
})
it('strips the cwd prefix from an absolute path inside the workspace', () => {
expect(workspaceFileSegments('/w', '/w/a/b.html')).toEqual(['a', 'b.html'])
// A trailing separator on the cwd must not shift the split.
expect(workspaceFileSegments('/w/', '/w/a.html')).toEqual(['a.html'])
})
it('reads Windows paths on either separator', () => {
expect(workspaceFileSegments('C:\\w', 'C:\\w\\a\\b.html')).toEqual(['a', 'b.html'])
expect(workspaceFileSegments('C:/w', 'C:\\w\\a.html')).toEqual(['a.html'])
})
it('refuses everything the route would not serve', () => {
// Absolute, but not under this workspace.
expect(workspaceFileSegments('/w', '/etc/hosts')).toBeUndefined()
// A sibling directory sharing the cwd's name prefix is not inside it.
expect(workspaceFileSegments('/w', '/workspace-other/a')).toBeUndefined()
// Absolute with no cwd to anchor against.
expect(workspaceFileSegments(undefined, '/w/a.html')).toBeUndefined()
expect(workspaceFileSegments('', '/w/a.html')).toBeUndefined()
// Traversal, in either spelling.
expect(workspaceFileSegments('/w', '../secret')).toBeUndefined()
expect(workspaceFileSegments('/w', 'a/../../secret')).toBeUndefined()
// The workspace directory itself is not a file.
expect(workspaceFileSegments('/w', '/w')).toBeUndefined()
expect(workspaceFileSegments('/w', '.')).toBeUndefined()
})
})
describe('workspaceFileUrl', () => {
it('percent-encodes each segment but keeps the separators structural', () => {
expect(workspaceFileUrl('s-1', ['out', 'a b.html'])).toBe(`${FILES_PATH}/s-1/out/a%20b.html`)
expect(workspaceFileUrl('s/1', ['a#b.html'])).toBe(`${FILES_PATH}/s%2F1/a%23b.html`)
})
})
describe('parseWorkspaceFilePath', () => {
it('round-trips what the browser half builds', () => {
const url = workspaceFileUrl('s-1', ['out', 'a b.html'])
expect(parseWorkspaceFilePath(url)).toEqual({ sessionId: 's-1', segments: ['out', 'a b.html'] })
})
it('refuses malformed, prefix-foreign, and traversal pathnames', () => {
expect(parseWorkspaceFilePath('/api/session.list')).toBeUndefined()
expect(parseWorkspaceFilePath(FILES_PATH)).toBeUndefined()
// Session named but no file below it.
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1`)).toBeUndefined()
expect(parseWorkspaceFilePath(`${FILES_PATH}//a.html`)).toBeUndefined()
// Traversal is refused at parse time, before any filesystem call.
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/../etc/hosts`)).toBeUndefined()
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a/./b`)).toBeUndefined()
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a//b`)).toBeUndefined()
// A separator smuggled through percent-encoding stays one segment's problem.
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%2F..%2Fb`)).toBeUndefined()
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%5Cb`)).toBeUndefined()
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%00b`)).toBeUndefined()
// Malformed percent-escapes are uninterpretable, not a miss to resolve.
expect(parseWorkspaceFilePath(`${FILES_PATH}/s-1/a%zz`)).toBeUndefined()
expect(parseWorkspaceFilePath(`${FILES_PATH}/%zz/a.html`)).toBeUndefined()
expect(parseWorkspaceFilePath(`${FILES_PATH}//`)).toBeUndefined()
})
})