feat(host): directory-picker capability seam with dialog and browse backends

The web GUI's folder picking was hardwired to one interaction: a native
OS chooser compiled into the gateway, unusable for remote deployments
and swappable only by editing apiproxy source.

Directory picking becomes a three-package capability seam in
packages/host: ctx.directoryPicker returns a discriminated capability —
dialog (the extracted native chooser; host-display only) or browse
(new: one-level listing + child creation over Node stdlib, hidden flags
host-stamped, symlinks followed, ancestry crumbs; remote-capable). The
gateway injects the seam, advertises the kind via
host.describe.directoryPicker, serves host.listDirectory /
host.createDirectory under browse, and answers
directory-picker-unavailable across kinds. cordis.yml is the swap
point; apps/cli keeps dialog mounted, so behavior is unchanged until
the in-app browser PR flips the default. The connection fixture serves
a deterministic browse tree; WorkspacesService gains the browse calls
the browser UI will drive. Decision record:
.agents/notes/implemented/architecture/2026-07-28-directory-picker-capability-seam.md
This commit is contained in:
creatixchu
2026-07-28 15:44:53 +08:00
parent d1ce22e7ad
commit 7fd2abd828
73 changed files with 1536 additions and 49 deletions

View File

@@ -10,6 +10,8 @@ import type { Session } from '@deepseek-ai/dsh-session'
import Storage from '@deepseek-ai/dsh-storage'
import { DomainFacility } from '@deepseek-ai/dsh-storage-domain'
import UserInteractionService from '@deepseek-ai/dsh-user-interaction'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker'
import WorkspaceRegistry from '@deepseek-ai/dsh-workspace'
import type { HostFrame, WorkspaceId } from '@deepseek-ai/dsh-host-apiproxy/api'
import type { RpcRequest, RpcResponse } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
@@ -57,7 +59,7 @@ function stubAgent(session: Session): Agent {
/** Compose the API over real Session, Agent, Storage, Domain, and Workspace services. */
async function harness(
workspaceRoot = realpathSync(mkdtempSync(join(tmpdir(), 'dsh-apiproxy-workspace-'))),
pickDirectory?: (signal: AbortSignal) => Promise<string | null>,
picker: DirectoryPickerCapability = { kind: 'dialog', pick: async () => null },
) {
const ctx = new Context()
await ctx.plugin(SessionStore)
@@ -92,36 +94,114 @@ async function harness(
},
}
ctx.agents.setFactory(factory)
// Structural picker fake: the gateway only reads capability(); a stable
// object per harness mirrors the seam's stability contract.
ctx.provide('directoryPicker', { capability: () => picker } as never)
const api = createApiProxy(ctx, {
provider: 'test',
model: 'test-model',
cwd: workspaceRoot,
workspaceRoot,
...pickDirectory === undefined ? {} : { pickDirectory },
})
return { api, ctx, storageDomain, workspaceRoot }
}
describe('host.pickDirectory', () => {
it('returns a selected path or explicit cancellation from the injected native boundary', async () => {
const selected = await harness(undefined, async () => '/tmp/project')
it('returns a selected path or explicit cancellation from the dialog capability', async () => {
const selected = await harness(undefined, { kind: 'dialog', pick: async () => '/tmp/project' })
expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result)
.toEqual({ ok: true, value: { path: '/tmp/project' } })
const cancelled = await harness(undefined, async () => null)
const cancelled = await harness(undefined, { kind: 'dialog', pick: async () => null })
expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result)
.toEqual({ ok: true, value: { path: null } })
})
it('propagates abort into the native boundary as a cancelled RPC error', async () => {
const { api } = await harness(undefined, signal => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
}))
it('propagates abort into the dialog capability as a cancelled RPC error', async () => {
const { api } = await harness(undefined, {
kind: 'dialog',
pick: signal => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
}),
})
const abort = new AbortController()
const pending = api.host.pickDirectory(request({}), abort.signal)
abort.abort()
expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
})
it('folds a non-abort dialog failure into an internal error', async () => {
const { api } = await harness(undefined, { kind: 'dialog', pick: async () => { throw new Error('no chooser installed') } })
const response = await api.host.pickDirectory(request({}), new AbortController().signal)
expect(response.result).toMatchObject({ ok: false, error: { code: 'internal' } })
})
it('refuses the dialog RPC under a browse composition', async () => {
const { api } = await harness(undefined, BROWSE_STUB)
const response = await api.host.pickDirectory(request({}), new AbortController().signal)
expect(response.result).toMatchObject({
ok: false,
error: { code: 'directory-picker-unavailable', details: { capability: 'browse' } },
})
})
})
/** Canned browse capability: one listing, one created path, typed failures on demand. */
const BROWSE_STUB: DirectoryPickerCapability = {
kind: 'browse',
list: async (path) => {
if (path === '/denied') throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied')
const target = path ?? '/home/user'
return {
path: target,
home: '/home/user',
crumbs: [{ name: '/', path: '/', hidden: false }],
entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }],
}
},
createDirectory: async (path, name) => {
if (name === 'taken') throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists')
if (name === 'unwritable') throw new Error('disk detached')
return `${path}/${name}`
},
}
describe('host.listDirectory / host.createDirectory', () => {
it('serves listings and creation through the browse capability, defaulting to home', async () => {
const { api } = await harness(undefined, BROWSE_STUB)
const home = await api.host.listDirectory(request({}))
expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } })
const listed = await api.host.listDirectory(request({ path: '/home/user/projects' }))
expect(listed.result).toMatchObject({ ok: true, value: { path: '/home/user/projects' } })
const created = await api.host.createDirectory(request({ path: '/home/user', name: 'fresh' }))
expect(created.result).toEqual({ ok: true, value: { path: '/home/user/fresh' } })
})
it('maps typed picker failures onto the wire error codes and folds unknown throws to internal', async () => {
const { api } = await harness(undefined, BROWSE_STUB)
expect((await api.host.listDirectory(request({ path: '/denied' }))).result).toMatchObject({
ok: false, error: { code: 'directory-unreadable', details: { path: '/denied' } },
})
expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result).toMatchObject({
ok: false, error: { code: 'directory-exists' },
})
expect((await api.host.createDirectory(request({ path: '/home/user', name: 'unwritable' }))).result).toMatchObject({
ok: false, error: { code: 'internal' },
})
})
it('refuses the browse RPCs under a dialog composition and advertises the kind in describe', async () => {
const { api } = await harness()
expect((await api.host.describe(request({}))).result).toMatchObject({ ok: true, value: { directoryPicker: 'dialog' } })
expect((await api.host.listDirectory(request({}))).result).toMatchObject({
ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'dialog' } },
})
expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result).toMatchObject({
ok: false, error: { code: 'directory-picker-unavailable', details: { capability: 'dialog' } },
})
const browse = await harness(undefined, BROWSE_STUB)
expect((await browse.api.host.describe(request({}))).result).toMatchObject({ ok: true, value: { directoryPicker: 'browse' } })
})
})
describe('workspace.create', () => {

View File

@@ -48,8 +48,10 @@ function scriptedApi(overrides: {
...overrides.sessions,
},
host: {
describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0 }),
describe: r => ok(r, { version: '0-test', cwd: '/t', attachedSessions: 0, directoryPicker: 'browse' as const }),
pickDirectory: r => ok(r, { path: null }),
listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [] }),
createDirectory: r => ok(r, { path: '/t/new' }),
...overrides.host,
},
workspace: {

View File

@@ -75,11 +75,17 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra
},
host: {
async describe(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0 } } }
return { rpcId: request.rpcId, result: { ok: true, value: { version: 'v', cwd: '/w', attachedSessions: 0, directoryPicker: 'dialog' as const } } }
},
async pickDirectory(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { path: null } } }
},
async listDirectory(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [] } } }
},
async createDirectory(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w/new' } } }
},
},
workspace: {
async list(request) {

View File

@@ -1,139 +0,0 @@
type ExecFileCallback = (
error: (Error & { code?: string | number }) | null,
stdout: string,
stderr: string,
) => void
type ExecFileMock = (
command: string,
args: readonly string[],
options: { encoding: string; signal: AbortSignal; windowsHide: boolean },
callback: ExecFileCallback,
) => void
const { execFileMock } = vi.hoisted(() => ({ execFileMock: vi.fn<ExecFileMock>() }))
vi.mock('node:child_process', () => ({ execFile: execFileMock }))
import { describe, expect, it, vi } from 'vitest'
import { pickNativeDirectory, type DirectoryPickerRunner } from '../src/native-directory-picker.ts'
function failure(code: string | number, stderr = ''): Error {
return Object.assign(new Error(`command failed: ${String(code)}`), { code, stderr })
}
const signal = () => new AbortController().signal
describe('native directory picker', () => {
it('uses the macOS folder chooser and maps user cancellation to null', async () => {
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/Users/test/project/\n', stderr: '' }))
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBe('/Users/test/project/')
expect(run).toHaveBeenCalledWith('osascript', expect.arrayContaining(['POSIX path of selectedFolder']), expect.any(AbortSignal))
run.mockRejectedValueOnce(failure(1, 'execution error: User canceled. (-128)'))
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).resolves.toBeNull()
run.mockRejectedValueOnce(failure(2, 'permission denied'))
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toThrow('command failed')
})
it.each([
['a primitive error', 'failed'],
['an invalid code type', { code: true }],
['a missing stderr property', { code: 1 }],
['a non-string stderr property', { code: 1, stderr: 42 }],
])('does not mistake %s for macOS cancellation', async (_label, reason) => {
const run = vi.fn<DirectoryPickerRunner>(async () => { throw reason })
await expect(pickNativeDirectory(signal(), { platform: 'darwin', run })).rejects.toBe(reason)
})
it('uses the Windows STA folder dialog and maps empty output to cancellation', async () => {
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: 'C:\\work\\project\r\n', stderr: '' }))
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBe('C:\\work\\project')
expect(run).toHaveBeenCalledWith(
'powershell.exe',
expect.arrayContaining(['-NoProfile', '-STA', '-Command']),
expect.any(AbortSignal),
)
expect(run.mock.calls[0]?.[1].at(-1)).toContain("$ErrorActionPreference = 'Stop'")
run.mockResolvedValueOnce({ stdout: '', stderr: '' })
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).resolves.toBeNull()
run.mockRejectedValueOnce(failure(1, 'Add-Type failed'))
await expect(pickNativeDirectory(signal(), { platform: 'win32', run })).rejects.toThrow('command failed')
})
it('runs the default command adapter without a shell and preserves command failures', async () => {
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(null, 'C:\\work\\default\r\n', '')
})
await expect(pickNativeDirectory(signal(), { platform: 'win32' })).resolves.toBe('C:\\work\\default')
const [command, args, options] = execFileMock.mock.calls[0]!
expect(command).toBe('powershell.exe')
expect(args).toEqual(expect.arrayContaining(['-NoProfile', '-STA', '-Command']))
expect(options.encoding).toBe('utf8')
expect(options.windowsHide).toBe(true)
expect(options.signal).toBeInstanceOf(AbortSignal)
const commandError = Object.assign(new Error('powershell failed'), { code: 7 })
execFileMock.mockImplementationOnce((_command, _args, _options, callback) => {
callback(commandError, 'partial output', 'failure details')
})
await expect(pickNativeDirectory(signal(), { platform: 'win32' })).rejects.toMatchObject({
message: 'powershell failed', cause: commandError, code: 7,
stdout: 'partial output', stderr: 'failure details',
})
})
it('uses the current process platform when no platform override is supplied', async () => {
const run = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/default/platform\n', stderr: '' }))
await expect(pickNativeDirectory(signal(), { run })).resolves.toBe('/default/platform')
})
it('uses Zenity on Linux and falls back to KDialog only when Zenity is missing', async () => {
const run = vi.fn<DirectoryPickerRunner>()
.mockRejectedValueOnce(failure('ENOENT'))
.mockResolvedValueOnce({ stdout: '/home/test/project\n', stderr: '' })
await expect(pickNativeDirectory(signal(), { platform: 'linux', run })).resolves.toBe('/home/test/project')
expect(run.mock.calls.map(call => call[0])).toEqual(['zenity', 'kdialog'])
const zenity = vi.fn<DirectoryPickerRunner>(async () => ({ stdout: '/home/test/direct\n', stderr: '' }))
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: zenity }))
.resolves.toBe('/home/test/direct')
expect(zenity).toHaveBeenCalledOnce()
})
it('maps Linux cancellation to null and reports a missing desktop picker', async () => {
const cancelled = vi.fn<DirectoryPickerRunner>(async () => { throw failure(1) })
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: cancelled })).resolves.toBeNull()
const missing = vi.fn<DirectoryPickerRunner>(async () => { throw failure('ENOENT') })
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: missing }))
.rejects.toThrow('install zenity or kdialog')
const kdialogCancelled = vi.fn<DirectoryPickerRunner>()
.mockRejectedValueOnce(failure('ENOENT'))
.mockRejectedValueOnce(failure(1))
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: kdialogCancelled }))
.resolves.toBeNull()
const zenityFailed = vi.fn<DirectoryPickerRunner>(async () => { throw failure(2) })
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: zenityFailed }))
.rejects.toThrow('command failed')
const kdialogFailed = vi.fn<DirectoryPickerRunner>()
.mockRejectedValueOnce(failure('ENOENT'))
.mockRejectedValueOnce(failure(2))
await expect(pickNativeDirectory(signal(), { platform: 'linux', run: kdialogFailed }))
.rejects.toThrow('command failed')
})
it('does not convert caller aborts into user cancellation', async () => {
const abort = new AbortController()
abort.abort(new Error('closed'))
const run = vi.fn<DirectoryPickerRunner>(async () => { throw failure('ABORT_ERR') })
await expect(pickNativeDirectory(abort.signal, { platform: 'linux', run })).rejects.toThrow('command failed')
})
it('reports unsupported platforms', async () => {
await expect(pickNativeDirectory(signal(), { platform: 'aix' })).rejects.toThrow('unsupported on aix')
})
})

View File

@@ -12,7 +12,11 @@ import {
sessionModelsValueSchema, sessionPromptRequestSchema, sessionPromptValueSchema,
sessionSelectModelRequestSchema, sessionSelectModelValueSchema, sessionSummarySchema,
} from '../src/api/sessions.schema.ts'
import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
import {
hostCreateDirectoryRequestSchema, hostCreateDirectoryValueSchema,
hostDescribeRequestSchema, hostDescribeValueSchema,
hostListDirectoryRequestSchema, hostListDirectoryValueSchema,
} from '../src/api/host.schema.ts'
import {
workspaceCreateRequestSchema, workspaceCreateValueSchema, workspaceIdSchema,
workspaceDeleteRequestSchema, workspaceDeleteValueSchema,
@@ -204,9 +208,27 @@ describe('sessions domain schemas', () => {
describe('host domain schemas', () => {
it('validates describe request/value', () => {
expect(hostDescribeRequestSchema.parse({})).toEqual({})
const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2 })
const value = hostDescribeValueSchema.parse({ version: '1', cwd: '/x', provider: 'p', model: 'm', attachedSessions: 2, directoryPicker: 'dialog' })
expect(value.attachedSessions).toBe(2)
expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0 }).provider).toBeUndefined()
expect(hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'browse' }).provider).toBeUndefined()
expect(() => hostDescribeValueSchema.parse({ version: '1', cwd: '/x', attachedSessions: 0, directoryPicker: 'other' })).toThrow()
})
it('validates the browse listing/creation payloads', () => {
expect(hostListDirectoryRequestSchema.parse({})).toEqual({})
expect(hostListDirectoryRequestSchema.parse({ path: '/x' })).toEqual({ path: '/x' })
const listing = hostListDirectoryValueSchema.parse({
path: '/home/u/p',
home: '/home/u',
crumbs: [{ name: '/', path: '/', hidden: false }, { name: 'p', path: '/home/u/p', hidden: false }],
entries: [{ name: '.dot', path: '/home/u/p/.dot', hidden: true }],
})
expect(listing.entries[0]?.hidden).toBe(true)
expect(hostCreateDirectoryRequestSchema.parse({ path: '/x', name: 'new' })).toEqual({ path: '/x', name: 'new' })
for (const name of ['', ' ', '.', '..', 'a/b', 'a\\b']) {
expect(() => hostCreateDirectoryRequestSchema.parse({ path: '/x', name })).toThrow()
}
expect(hostCreateDirectoryValueSchema.parse({ path: '/x/new' })).toEqual({ path: '/x/new' })
})
})