refactor(apiproxy)!: remove directory-picker RPCs

This commit is contained in:
imccyu
2026-08-27 02:20:39 +08:00
parent 011d53862d
commit 6e4087626d
20 changed files with 25 additions and 537 deletions
@@ -167,23 +167,9 @@ export class FakeApiClient implements IApiClient {
() => Promise.resolve(ok({
version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true,
}))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
() => Promise.resolve(ok({ path: null }))
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
() => Promise.resolve(ok({ opened: true as const }))
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
path: string
home: string
crumbs: { name: string; path: string; hidden: boolean }[]
entries: { name: string; path: string; hidden: boolean }[]
truncated: boolean
}>> =
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }))
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
() => Promise.resolve(ok({ path: '/home/fake/new' }))
private readonly followConns = new Map<SessionId, ValueStreamConn<SessionFollowFrame>[]>()
private readonly controlConns: ValueStreamConn<SessionControlFrame>[] = []
private readonly workspaceConns: ValueStreamConn<WorkspaceFollowFrame>[] = []
@@ -210,9 +196,6 @@ export class FakeApiClient implements IApiClient {
readonly host: IApiClient['host'] = {
describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: (payload: unknown) => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
listDirectory: (payload: unknown) => this.record('host.listDirectory', payload, this.onListDirectory(payload)),
createDirectory: (payload: unknown) => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)),
openPath: (payload: unknown) => this.record('host.openPath', payload, this.onOpenPath(payload)),
}
@@ -7,7 +7,6 @@
export type {
ApiProxy, HostApi,
DirectoryEntry, DirectoryListing,
ResponseValue,
SkillsApi, SkillEntry,
ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
@@ -3314,42 +3314,6 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld {
describe: request => ok(request, {
version: '0.0.0-fixture', cwd: '/tmp/fixture', attachedSessions, home: FIXTURE_HOME, canOpenPath: true,
}),
// Deterministic native pick: the keyless lanes drive the full
// pick-then-adopt path without an OS chooser (design-mock content,
// same tree the browse primitives serve).
pickDirectory: request => ok(request, { path: `${FIXTURE_HOME}/Documents/project` }),
listDirectory: (request) => {
const target = request.payload.path ?? FIXTURE_HOME
const children = childrenOf(target)
if (children === undefined) {
return err(request, { code: 'directory-unreadable', message: `cannot list ${target}: not in the fixture tree`, details: { path: target } })
}
return ok(request, {
path: target,
home: FIXTURE_HOME,
crumbs: crumbsOf(target),
entries: [...children].sort((a, b) => a.localeCompare(b))
.map(name => ({ name, path: target === '/' ? `/${name}` : `${target}/${name}`, hidden: name.startsWith('.') })),
// The fixture tree is tiny; no level ever reaches a backend bound.
truncated: false,
})
},
createDirectory: (request) => {
const parent = request.payload.path
const children = childrenOf(parent)
if (children === undefined) {
return err(request, { code: 'directory-create-failed', message: `missing parent ${parent}`, details: { path: parent } })
}
// Same root special case as listDirectory's entry paths: a plain join
// under '/' would mint '//name' and fork the tree's identity.
const target = parent === '/' ? `/${request.payload.name}` : `${parent}/${request.payload.name}`
if (children.includes(request.payload.name)) {
return err(request, { code: 'directory-exists', message: `${target} already exists`, details: { path: target } })
}
directoryTree.set(parent, [...children, request.payload.name])
directoryTree.set(target, [])
return ok(request, { path: target })
},
openPath: request => ok(request, { opened: true as const }),
},
agentPresets: {
@@ -3652,9 +3616,6 @@ export class FixtureApiClient extends AbstractApiClient {
): Promise<RpcResponse<unknown>> {
switch (method) {
case 'host.describe': return this.api.host.describe(request)
case 'host.pickDirectory': return this.api.host.pickDirectory(request, new AbortController().signal)
case 'host.listDirectory': return this.api.host.listDirectory(request, new AbortController().signal)
case 'host.createDirectory': return this.api.host.createDirectory(request)
case 'host.openPath': return this.api.host.openPath(request, new AbortController().signal)
case 'skill.list': return this.api.skills.list(request)
case 'agentPreset.openDocument': return this.api.agentPresets.openDocument(request, new AbortController().signal)
@@ -31,7 +31,6 @@ declare module '@deepseek-ai/cordis' {
// ---- Contract re-exports (browser-safe apiproxy channels + core types) ----
export type {
ApiProxy, HostApi,
DirectoryEntry, DirectoryListing,
SkillsApi, SkillEntry,
ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
MessageId, ModelReasoningEffort, ModelSelection,
@@ -50,30 +50,13 @@ export class FakeApiClient implements IApiClient {
() => Promise.resolve(ok({
version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true,
}))
onPickDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string | null }>> =
() => Promise.resolve(ok({ path: null }))
onOpenPath: (payload: unknown) => Promise<RpcResponse<{ opened: true }>> =
() => Promise.resolve(ok({ opened: true as const }))
onListDirectory: (payload: unknown) => Promise<RpcResponse<{
path: string
home: string
crumbs: { name: string; path: string; hidden: boolean }[]
entries: { name: string; path: string; hidden: boolean }[]
truncated: boolean
}>> =
() => Promise.resolve(ok({ path: '/home/fake', home: '/home/fake', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false }))
onCreateDirectory: (payload: unknown) => Promise<RpcResponse<{ path: string }>> =
() => Promise.resolve(ok({ path: '/home/fake/new' }))
private readonly generationConns: StreamConn[] = []
readonly host: IApiClient['host'] = {
describe: payload => this.record('host.describe', payload, this.onDescribe(payload)),
pickDirectory: payload => this.record('host.pickDirectory', payload, this.onPickDirectory(payload)),
listDirectory: payload => this.record('host.listDirectory', payload, this.onListDirectory(payload)),
createDirectory: payload => this.record('host.createDirectory', payload, this.onCreateDirectory(payload)),
openPath: payload => this.record('host.openPath', payload, this.onOpenPath(payload)),
}
@@ -174,7 +174,7 @@ describe('connection node half', () => {
it('requires the same browser session for every method on every trusted authority', async () => {
const { routes, connection, dispose } = await mounted({ trustedHosts: ['harness.example'] })
const methods = [
'host.pickDirectory', 'host.openPath',
'host.openPath',
'settings.describe', 'settings.update', 'credentials.describe', 'credentials.set',
'llm.discoverModels', 'llm.models', 'agentPreset.openDocument',
]
@@ -503,7 +503,7 @@ describe('connection node half over a real HTTP server', () => {
const methods = [
'settings.describe', 'settings.openDocument', 'settings.update', 'settings.replace', 'settings.mutate',
'credentials.describe', 'credentials.set', 'credentials.unset',
'host.pickDirectory', 'host.openPath',
'host.openPath',
'llm.discoverModels',
'agentPreset.openDocument',
'llm.providers', 'llm.models',
-75
View File
@@ -41,7 +41,6 @@ import type { SettingsDescriptor, SettingsNamespace, SettingsPathOp } from '@dee
import { credentialRef } from '@deepseek-ai/dsh-credentials'
import type { ScopeKey } from '@deepseek-ai/dsh-scope'
import type { RpcError, RpcRequest, RpcResponse } from './api/rpc.ts'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import { canOpenNativePath, openNativePath, openNativeTextFile } from './native-path-opener.ts'
/** Read live abort state across awaits without treating it as synchronously immutable. */
@@ -59,14 +58,6 @@ function err<T>(request: RpcRequest<unknown>, error: RpcError): RpcResponse<T> {
return { rpcId: request.rpcId, result: { ok: false, error } }
}
/** Map a browse-primitive failure onto the wire error vocabulary (unknown throws stay internal). */
function directoryError(error: unknown): RpcError {
if (error instanceof DirectoryPickerError) {
return { code: error.code, message: error.message, details: { path: error.path } }
}
return { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} }
}
/** Deployment metadata and Host integrations consumed by the API implementation. */
export interface ApiProxyDefaults {
/** Current deployment model selection reported by `host.describe`. */
@@ -290,72 +281,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
}))
},
async pickDirectory(request, signal) {
const capability = ctx.directoryPicker.capability()
if (capability.kind !== 'native') {
return err(request, {
code: 'directory-picker-unavailable',
message: `host.pickDirectory needs the native capability; the composed picker serves "${capability.kind}"`,
details: { capability: capability.kind },
})
}
try {
const path = await capability.pick(signal)
return ok(request, { path })
} catch (error: unknown) {
if (signal.aborted) {
return err(request, {
code: 'cancelled',
message: 'directory picker was aborted',
details: {},
})
}
return err(request, {
code: 'internal',
message: `directory picker failed: ${error instanceof Error ? error.message : String(error)}`,
details: {},
})
}
},
async listDirectory(request, signal) {
const capability = ctx.directoryPicker.capability()
if (capability.kind !== 'browse') {
return err(request, {
code: 'directory-picker-unavailable',
message: `host.listDirectory needs the browse capability; the composed picker serves "${capability.kind}"`,
details: { capability: capability.kind },
})
}
try {
// The carrier's signal follows the caller: a disconnect or timeout
// stops the backend's directory scan instead of outliving it.
return ok(request, await capability.list(request.payload.path, signal))
} catch (error: unknown) {
// An abort is the caller's own timeout/disconnect, not a server failure.
if (signal.aborted) {
return err(request, { code: 'cancelled', message: 'directory listing was aborted', details: {} })
}
return err(request, directoryError(error))
}
},
async createDirectory(request) {
const capability = ctx.directoryPicker.capability()
if (capability.kind !== 'browse') {
return err(request, {
code: 'directory-picker-unavailable',
message: `host.createDirectory needs the browse capability; the composed picker serves "${capability.kind}"`,
details: { capability: capability.kind },
})
}
try {
return ok(request, { path: await capability.createDirectory(request.payload.path, request.payload.name) })
} catch (error: unknown) {
return err(request, directoryError(error))
}
},
async openPath(request, signal) {
return openPath(request, request.payload.path, signal)
},
@@ -3,7 +3,6 @@
*/
import { z } from 'zod'
import type { DirectoryEntry } from './host.ts'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
@@ -21,49 +20,6 @@ export const hostDescribeValueSchema = z.object({
canOpenPath: z.boolean(),
}) satisfies z.ZodType<Wire<ResponseValue<'host.describe'>>>
/** host.pickDirectory request payload (empty object literal). */
export const hostPickDirectoryRequestSchema = z.object({}) satisfies z.ZodType<Wire<RequestPayload<'host.pickDirectory'>>>
/** host.pickDirectory response value; null means the user cancelled. */
export const hostPickDirectoryValueSchema = z.object({
path: z.string().nullable(),
}) satisfies z.ZodType<Wire<ResponseValue<'host.pickDirectory'>>>
/** Directory row shared by listing entries and breadcrumb crumbs. */
export const directoryEntrySchema = z.object({
name: z.string(),
path: z.string(),
hidden: z.boolean(),
}) satisfies z.ZodType<Wire<DirectoryEntry>>
/** host.listDirectory request payload; an absent path lists the home directory. */
export const hostListDirectoryRequestSchema = z.object({
path: z.string().optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'host.listDirectory'>>>
/** host.listDirectory response value. */
export const hostListDirectoryValueSchema = z.object({
path: z.string(),
home: z.string(),
crumbs: z.array(directoryEntrySchema),
entries: z.array(directoryEntrySchema),
truncated: z.boolean(),
}) satisfies z.ZodType<Wire<ResponseValue<'host.listDirectory'>>>
/** host.createDirectory request payload: name must be one plain path segment. */
export const hostCreateDirectoryRequestSchema = z.object({
path: z.string(),
name: z.string(),
}).refine(
payload => payload.name.trim() !== '' && payload.name !== '.' && payload.name !== '..'
&& !/[/\\]/.test(payload.name),
{ message: 'host.createDirectory requires a single non-blank path segment name' },
) satisfies z.ZodType<Wire<RequestPayload<'host.createDirectory'>>>
/** host.createDirectory response value: the created directory's absolute path. */
export const hostCreateDirectoryValueSchema = z.object({
path: z.string(),
}) satisfies z.ZodType<Wire<ResponseValue<'host.createDirectory'>>>
/** host.openPath request payload. */
export const hostOpenPathRequestSchema = z.object({
path: z.string().min(1),
-58
View File
@@ -5,33 +5,6 @@
import type { RpcRequest, RpcResponse } from './rpc.ts'
/** One directory row of a listing: a child entry or a breadcrumb ancestor. */
export interface DirectoryEntry {
/** Base name shown in a browser row (a root crumb carries its full path). */
name: string
/** Absolute host path — the client never joins path segments itself. */
path: string
/** Hidden by the host platform's convention (dot-prefixed on POSIX); the client owns whether to show it. */
hidden: boolean
}
/** host.listDirectory response value: one directory level plus its ancestry. */
export interface DirectoryListing {
/** Absolute path of the listed directory. */
path: string
/** The host account's home directory (breadcrumb "Home" rooting). */
home: string
/**
* Ancestor chain from the filesystem root to the listed directory
* inclusive; every crumb is a jump target (crumb `hidden` is always false).
*/
crumbs: DirectoryEntry[]
/** Direct child directories, name-sorted; symlinks to directories included. */
entries: DirectoryEntry[]
/** True when the backend cut `entries` at its complete-result bound (the name-sorted tail is absent). */
truncated: boolean
}
/** Host-level unary methods. */
export interface HostApi {
/**
@@ -54,37 +27,6 @@ export interface HostApi {
canOpenPath: boolean
}>>
/**
* Open the operating system's single-directory picker; cancellation returns
* null. Only served under the `native` capability.
*/
pickDirectory(
request: RpcRequest<{}>,
signal: AbortSignal,
): Promise<RpcResponse<{ path: string | null }>>
/**
* List one directory level for the in-app browser; an absent path lists the
* host account's home directory. Only served under the `browse` capability;
* unreadable or missing targets fail with `directory-unreadable`. The
* carrier's request signal follows the caller, stopping the backend's scan
* on disconnect or timeout.
*/
listDirectory(
request: RpcRequest<{ path?: string }>,
signal: AbortSignal,
): Promise<RpcResponse<DirectoryListing>>
/**
* Create one child directory under an existing parent (the browser's
* "New folder"). Only served under the `browse` capability; an existing
* child fails with `directory-exists`, every other filesystem failure with
* `directory-create-failed`.
*/
createDirectory(
request: RpcRequest<{ path: string; name: string }>,
): Promise<RpcResponse<{ path: string }>>
/**
* Open a filesystem path with the operating system's default application
* (Finder / Explorer / xdg-open hand-off). The browser carrier's
+1 -1
View File
@@ -29,7 +29,7 @@ export type {
ModelCatalog, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning,
ModelReasoningEffort, ModelSelection,
} from '@deepseek-ai/dsh-api-session-controller/types'
export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts'
export type { HostApi } from './host.ts'
export type { SkillsApi, SkillEntry } from './skills.ts'
export type { AgentPresetsApi } from './agent-presets.ts'
export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts'
@@ -18,9 +18,6 @@ import type { RpcResponse } from './rpc.ts'
*/
export interface RpcMethodMap {
'host.describe': HostApi['describe']
'host.pickDirectory': HostApi['pickDirectory']
'host.listDirectory': HostApi['listDirectory']
'host.createDirectory': HostApi['createDirectory']
'host.openPath': HostApi['openPath']
'skill.list': SkillsApi['list']
'agentPreset.openDocument': AgentPresetsApi['openDocument']
@@ -36,10 +36,6 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
z.object({ code: z.literal('cancelled'), message: z.string(), details: z.object({}) }),
z.object({ code: z.literal('session-not-found'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
z.object({ code: z.literal('invalid-time-zone'), message: z.string(), details: z.object({ value: z.string() }) }),
z.object({ code: z.literal('directory-unreadable'), message: z.string(), details: z.object({ path: z.string() }) }),
z.object({ code: z.literal('directory-exists'), message: z.string(), details: z.object({ path: z.string() }) }),
z.object({ code: z.literal('directory-create-failed'), message: z.string(), details: z.object({ path: z.string() }) }),
z.object({ code: z.literal('directory-picker-unavailable'), message: z.string(), details: z.object({ capability: z.string() }) }),
z.object({ code: z.literal('agent-preset-read-only'), message: z.string(), details: z.object({ agentPreset: z.string(), reason: z.string() }) }),
z.object({ code: z.literal('agent-preset-locked'), message: z.string(), details: z.object({ sessionId: z.string(), agentPreset: z.string() }) }),
z.object({ code: z.literal('agent-preset-not-found'), message: z.string(), details: z.object({ agentPreset: z.string(), available: z.array(z.string()) }) }),
-4
View File
@@ -31,10 +31,6 @@ export interface RpcErrorDetailsMap {
'cancelled': {}
'session-not-found': { sessionId: SessionId }
'invalid-time-zone': { value: string }
'directory-unreadable': { path: string }
'directory-exists': { path: string }
'directory-create-failed': { path: string }
'directory-picker-unavailable': { capability: string }
'agent-preset-read-only': { agentPreset: string; reason: string }
'agent-preset-locked': { sessionId: SessionId; agentPreset: string }
'agent-preset-not-found': { agentPreset: string; available: readonly string[] }
+8 -29
View File
@@ -13,8 +13,7 @@ import { RpcId } from '../api/rpc.ts'
import type { Wire } from '../api/rpc.schema.ts'
import { serverResponseSchema } from '../api/rpc.schema.ts'
import {
hostCreateDirectoryValueSchema, hostDescribeValueSchema,
hostListDirectoryValueSchema, hostOpenPathValueSchema, hostPickDirectoryValueSchema,
hostDescribeValueSchema, hostOpenPathValueSchema,
} from '../api/host.schema.ts'
import { skillListValueSchema } from '../api/skills.schema.ts'
import {
@@ -44,9 +43,6 @@ import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSc
export interface IApiClient {
host: {
describe(payload: RequestPayload<'host.describe'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.describe'>>>
pickDirectory(payload: RequestPayload<'host.pickDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.pickDirectory'>>>
listDirectory(payload: RequestPayload<'host.listDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.listDirectory'>>>
createDirectory(payload: RequestPayload<'host.createDirectory'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.createDirectory'>>>
openPath(payload: RequestPayload<'host.openPath'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'host.openPath'>>>
}
skills: {
@@ -80,9 +76,6 @@ export interface IApiClient {
*/
const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseValue<K>>> } = {
'host.describe': hostDescribeValueSchema,
'host.pickDirectory': hostPickDirectoryValueSchema,
'host.listDirectory': hostListDirectoryValueSchema,
'host.createDirectory': hostCreateDirectoryValueSchema,
'host.openPath': hostOpenPathValueSchema,
'skill.list': skillListValueSchema,
'agentPreset.openDocument': agentPresetOpenDocumentValueSchema,
@@ -102,9 +95,6 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
/** Default timeout for bounded unary calls (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
const DEFAULT_TIMEOUT_MS = 30_000
/** Whether a unary call uses the transport health deadline or only caller/connection cancellation. */
type UnaryTimeoutPolicy = 'default' | 'caller-signal-only'
/** URL base for in-process handler injection (fake authority, opencode precedent). */
const INTERNAL_BASE = 'http://dsh.internal'
@@ -122,7 +112,7 @@ export abstract class AbstractApiClient implements IApiClient {
private flushScheduled = false
private readonly envelopeListeners = new Set<(batch: readonly RpcMessage[]) => void>()
/** @param timeoutMs - timeout for bounded unary calls; user-paced calls do not use it. */
/** @param timeoutMs - timeout for unary calls. */
constructor(protected readonly timeoutMs: number = DEFAULT_TIMEOUT_MS) {}
/** Transport aspect: browser fetch, injected handler.fetch, IPC bridge, ... */
@@ -178,24 +168,21 @@ export abstract class AbstractApiClient implements IApiClient {
/**
* Shared POST leg of unary calls: JSON body,
* optional default timeout merged with the caller's external signal, non-2xx transport throw.
* default timeout merged with the caller's external signal, non-2xx transport throw.
*/
private async postJson(
path: string,
body: ClientRequest,
signal: AbortSignal | undefined,
timeoutPolicy: UnaryTimeoutPolicy = 'default',
): Promise<Response> {
const requestSignal = timeoutPolicy === 'default'
? signal === undefined
? AbortSignal.timeout(this.timeoutMs)
: AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal])
: signal
const requestSignal = signal === undefined
? AbortSignal.timeout(this.timeoutMs)
: AbortSignal.any([AbortSignal.timeout(this.timeoutMs), signal])
const response = await this.doFetch(new URL(path, this.resolveBase()), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
...requestSignal === undefined ? {} : { signal: requestSignal },
signal: requestSignal,
})
if (!response.ok) throw new Error(`transport failure for ${path}: HTTP ${response.status}`)
return response
@@ -210,11 +197,10 @@ export abstract class AbstractApiClient implements IApiClient {
method: K,
payload: RequestPayload<K>,
signal?: AbortSignal,
timeoutPolicy: UnaryTimeoutPolicy = 'default',
): Promise<RpcResponse<ResponseValue<K>>> {
const message: ClientRequest = { type: 'client-request', rpcId: this.mintRpcId(), method, payload }
this.onEnvelope(message)
const response = await this.postJson(`/api/${method}`, message, signal, timeoutPolicy)
const response = await this.postJson(`/api/${method}`, message, signal)
const full = serverResponseSchema.parse(await response.json())
this.onEnvelope(full)
if (full.rpcId !== message.rpcId) throw new Error(`rpcId mismatch for ${method}: sent ${message.rpcId}, got ${full.rpcId}`)
@@ -229,13 +215,6 @@ export abstract class AbstractApiClient implements IApiClient {
readonly host: IApiClient['host'] = {
describe: (payload, signal) => this.callUnary('host.describe', payload, signal),
// A native system dialog is user-paced and may legitimately stay open
// longer than the normal unary deadline. Caller/connection aborts remain.
pickDirectory: (payload, signal) => this.callUnary(
'host.pickDirectory', payload, signal, 'caller-signal-only',
),
listDirectory: (payload, signal) => this.callUnary('host.listDirectory', payload, signal),
createDirectory: (payload, signal) => this.callUnary('host.createDirectory', payload, signal),
openPath: (payload, signal) => this.callUnary('host.openPath', payload, signal),
}
+1 -6
View File
@@ -15,9 +15,7 @@ import { RpcId } from '../api/rpc.ts'
import type { Wire } from '../api/rpc.schema.ts'
import { clientRequestSchema } from '../api/rpc.schema.ts'
import {
hostCreateDirectoryRequestSchema, hostDescribeRequestSchema,
hostListDirectoryRequestSchema, hostOpenPathRequestSchema,
hostPickDirectoryRequestSchema,
hostDescribeRequestSchema, hostOpenPathRequestSchema,
} from '../api/host.schema.ts'
import { skillListRequestSchema } from '../api/skills.schema.ts'
import {
@@ -50,9 +48,6 @@ type UnaryRoutes = {
const UNARY_ROUTES: UnaryRoutes = {
'host.describe': { schema: hostDescribeRequestSchema, invoke: (api, r) => api.host.describe(r) },
'host.pickDirectory': { schema: hostPickDirectoryRequestSchema, invoke: (api, r, signal) => api.host.pickDirectory(r, signal) },
'host.listDirectory': { schema: hostListDirectoryRequestSchema, invoke: (api, r, signal) => api.host.listDirectory(r, signal) },
'host.createDirectory': { schema: hostCreateDirectoryRequestSchema, invoke: (api, r) => api.host.createDirectory(r) },
'host.openPath': { schema: hostOpenPathRequestSchema, invoke: (api, r, signal) => api.host.openPath(r, signal) },
'skill.list': { schema: skillListRequestSchema, invoke: (api, r) => api.skills.list(r) },
'agentPreset.openDocument': { schema: agentPresetOpenDocumentRequestSchema, invoke: (api, r, signal) => api.agentPresets.openDocument(r, signal) },
+1
View File
@@ -15,6 +15,7 @@ import { Context, Service } from '@deepseek-ai/cordis'
import z from '@deepseek-ai/schemastery'
import type {} from '@deepseek-ai/dsh-agent-default-model'
import type {} from '@deepseek-ai/dsh-api-session-controller'
import type {} from '@deepseek-ai/dsh-host-directory-picker'
import type { ApiProxy } from './api/index.ts'
import { createApiProxy } from './api-proxy.ts'
import {
@@ -2,8 +2,6 @@ import { homedir } from 'node:os'
import { afterEach, describe, expect, it } from 'vitest'
import { Context } from '@deepseek-ai/cordis'
import AgentRegistry from '@deepseek-ai/dsh-agent'
import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker'
import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
import { createApiProxy } from '../src/api-proxy.ts'
@@ -26,7 +24,6 @@ function expectOk<T>(response: { readonly result: { readonly ok: true; readonly
}
async function harness(
picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
extras: {
openPath?: (path: string, signal: AbortSignal) => Promise<void>
canOpenPath?: () => boolean
@@ -35,7 +32,6 @@ async function harness(
const ctx = new Context()
contexts.push(ctx)
await ctx.plugin(AgentRegistry)
ctx.provide('directoryPicker', { capability: () => picker } as never)
const api = createApiProxy(ctx, {
defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
cwd: '/tmp/dsh-apiproxy-host',
@@ -45,135 +41,10 @@ async function harness(
return { api }
}
describe('host.pickDirectory', () => {
it('returns a selected path or explicit cancellation from the native capability', async () => {
const selected = await harness({ kind: 'native', 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({ kind: 'native', 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 capability as a cancelled RPC error', async () => {
const { api } = await harness({
kind: 'native',
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 native-chooser failure into an internal error', async () => {
const { api } = await harness({
kind: 'native',
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 native RPC under a browse composition', async () => {
const { api } = await harness(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' } },
})
})
})
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 }],
truncated: 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(BROWSE_STUB)
const home = await api.host.listDirectory(request({}), new AbortController().signal)
expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } })
const listed = await api.host.listDirectory(
request({ path: '/home/user/projects' }),
new AbortController().signal,
)
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 wire errors and folds unknown throws to internal', async () => {
const { api } = await harness(BROWSE_STUB)
expect((await api.host.listDirectory(
request({ path: '/denied' }),
new AbortController().signal,
)).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('reports an aborted listing as cancelled', async () => {
const { api } = await harness({
kind: 'browse',
list: (_path, signal) => new Promise((_resolve, reject) => {
signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true })
}),
createDirectory: async () => '/never',
})
const abort = new AbortController()
const pending = api.host.listDirectory(request({}), abort.signal)
abort.abort()
expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
})
it('refuses the browse RPCs under a native composition', async () => {
const { api } = await harness()
expect((await api.host.listDirectory(request({}), new AbortController().signal)).result)
.toMatchObject({
ok: false,
error: { code: 'directory-picker-unavailable', details: { capability: 'native' } },
})
expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result)
.toMatchObject({
ok: false,
error: { code: 'directory-picker-unavailable', details: { capability: 'native' } },
})
})
})
describe('host.openPath', () => {
it('describes whether the deployment can reach a native desktop', async () => {
const visible = await harness(undefined, { canOpenPath: () => true })
const headless = await harness(undefined, { canOpenPath: () => false })
const visible = await harness({ canOpenPath: () => true })
const headless = await harness({ canOpenPath: () => false })
expect(expectOk(await visible.api.host.describe(request({}))).canOpenPath).toBe(true)
expect(expectOk(await headless.api.host.describe(request({}))).canOpenPath).toBe(false)
expect(expectOk(await visible.api.host.describe(request({}))).home).toBe(homedir())
@@ -181,7 +52,7 @@ describe('host.openPath', () => {
it('opens through the injected native boundary', async () => {
const opened: string[] = []
const { api } = await harness(undefined, {
const { api } = await harness({
openPath: async (path) => { opened.push(path) },
})
expect((await api.host.openPath(
@@ -192,7 +63,7 @@ describe('host.openPath', () => {
})
it('propagates abort into the native boundary as a cancelled RPC error', async () => {
const { api } = await harness(undefined, {
const { api } = await harness({
openPath: (_path, signal) => new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
}),
@@ -29,9 +29,6 @@ function scriptedApi(overrides: {
describe: r => ok(r, {
version: '0-test', cwd: '/t', attachedSessions: 0, home: '/h', canOpenPath: true,
}),
pickDirectory: r => ok(r, { path: null }),
listDirectory: r => ok(r, { path: '/t', home: '/t', crumbs: [], entries: [], truncated: false }),
createDirectory: r => ok(r, { path: '/t/new' }),
openPath: r => ok(r, { opened: true as const }),
...overrides.host,
},
@@ -18,15 +18,6 @@ function fakeApi(overrides: Partial<{ crashOn: string }> = {}): ApiProxy {
},
}
},
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: [], truncated: false } } }
},
async createDirectory(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { path: '/w/new' } } }
},
async openPath(request) {
return { rpcId: request.rpcId, result: { ok: true, value: { opened: true as const } } }
},
@@ -124,29 +115,6 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
.toEqual({ ok: true, value: { opened: true } })
})
it('round-trips the native picker without the default unary timeout', async () => {
const api = fakeApi()
api.host.pickDirectory = async (request) => {
await new Promise(resolve => setTimeout(resolve, 15))
return { rpcId: request.rpcId, result: { ok: true, value: { path: '/tmp/project' } } }
}
const response = await client(api, 1).host.pickDirectory({})
expect(response.result).toEqual({ ok: true, value: { path: '/tmp/project' } })
})
it('round-trips the browse listing and creation calls through the wire form', async () => {
const c = client()
const listed = await c.host.listDirectory({ path: '/w' })
expect(listed.result).toEqual({
ok: true,
value: { path: '/w', home: '/w', crumbs: [{ name: '/', path: '/', hidden: false }], entries: [], truncated: false },
})
const home = await c.host.listDirectory({})
expect(home.result).toMatchObject({ ok: true, value: { home: '/w' } })
const created = await c.host.createDirectory({ path: '/w', name: 'fresh' })
expect(created.result).toEqual({ ok: true, value: { path: '/w/new' } })
})
it('round-trips host.openPath through the wire form', async () => {
const api = fakeApi()
let opened: string | undefined
@@ -165,41 +133,10 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
expect(skills.result).toEqual({ ok: true, value: { skills: [{ name: 'commit-helper', description: 'Git commits', modelInvocable: true }] } })
})
it('lets host.pickDirectory finish after the 30-second default unary deadline', async () => {
vi.useFakeTimers()
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout').mockImplementation((milliseconds) => {
const controller = new AbortController()
setTimeout(() => {
controller.abort(new DOMException('The operation was aborted due to timeout', 'TimeoutError'))
}, milliseconds)
return controller.signal
})
try {
const api = fakeApi()
api.host.pickDirectory = async (request) => {
await new Promise(resolve => setTimeout(resolve, 30_001))
return { rpcId: request.rpcId, result: { ok: true, value: { path: '/tmp/slow' } } }
}
const execution = client(api).host.pickDirectory({})
const assertion = expect(execution).resolves.toMatchObject({
result: { ok: true, value: { path: '/tmp/slow' } },
})
await Promise.all([
vi.advanceTimersByTimeAsync(30_001),
assertion,
])
expect(timeoutSpy).not.toHaveBeenCalled()
} finally {
timeoutSpy.mockRestore()
vi.useRealTimers()
}
})
it('keeps caller and connection aborts on a deadline-exempt unary', async () => {
it('keeps caller and connection aborts on a signal-taking unary', async () => {
const api = fakeApi()
const started = Promise.withResolvers<AbortSignal>()
api.host.pickDirectory = async (request, signal) => {
api.host.openPath = async (request, signal) => {
started.resolve(signal)
if (!signal.aborted) {
await new Promise<void>((resolve) => {
@@ -212,7 +149,7 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
}
}
const controller = new AbortController()
const execution = client(api).host.pickDirectory({}, controller.signal)
const execution = client(api).host.openPath({ path: '/tmp/a.txt' }, controller.signal)
const handlerSignal = await started.promise
controller.abort(new Error('connection closed'))
@@ -221,9 +158,9 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
expect(handlerSignal.aborted).toBe(true)
})
it('propagates the carrier Request signal into host.pickDirectory', async () => {
it('propagates the carrier Request signal into host.openPath', async () => {
const api = fakeApi()
api.host.pickDirectory = async (request, signal) => {
api.host.openPath = async (request, signal) => {
if (!signal.aborted) {
await new Promise<void>((resolve) => {
signal.addEventListener('abort', () => { resolve() }, { once: true })
@@ -236,8 +173,8 @@ describe('unary round trip (handler ⇄ client, no network)', () => {
}
const handler = toFetchHandler(api)
const controller = new AbortController()
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-picker', method: 'host.pickDirectory', payload: {} })
const pending = handler.fetch(new Request('http://x/api/host.pickDirectory', {
const body = JSON.stringify({ type: 'client-request', rpcId: 'r-opener', method: 'host.openPath', payload: { path: '/tmp/a.txt' } })
const pending = handler.fetch(new Request('http://x/api/host.openPath', {
method: 'POST', headers: { 'content-type': 'application/json' }, body, signal: controller.signal,
}))
controller.abort()
@@ -5,11 +5,7 @@ import {
rpcResultSchema, serverResponseSchema,
} from '../src/api/rpc.schema.ts'
import { z } from 'zod'
import {
hostCreateDirectoryRequestSchema, hostCreateDirectoryValueSchema,
hostDescribeRequestSchema, hostDescribeValueSchema,
hostListDirectoryRequestSchema, hostListDirectoryValueSchema,
} from '../src/api/host.schema.ts'
import { hostDescribeRequestSchema, hostDescribeValueSchema } from '../src/api/host.schema.ts'
import { skillEntrySchema, skillListRequestSchema, skillListValueSchema } from '../src/api/skills.schema.ts'
import { agentPresetOpenDocumentValueSchema } from '../src/api/agent-presets.schema.ts'
@@ -36,10 +32,6 @@ describe('rpcErrorSchema', () => {
expect(rpcErrorSchema.parse({ code: 'cancelled', message: 'm', details: {} }).code).toBe('cancelled')
expect(rpcErrorSchema.parse({ code: 'session-not-found', message: 'm', details: { sessionId: 's' } }).code).toBe('session-not-found')
expect(rpcErrorSchema.parse({ code: 'invalid-time-zone', message: 'm', details: { value: 'CST' } }).code).toBe('invalid-time-zone')
expect(rpcErrorSchema.parse({ code: 'directory-unreadable', message: 'm', details: { path: '/x' } }).code).toBe('directory-unreadable')
expect(rpcErrorSchema.parse({ code: 'directory-exists', message: 'm', details: { path: '/x' } }).code).toBe('directory-exists')
expect(rpcErrorSchema.parse({ code: 'directory-create-failed', message: 'm', details: { path: '/x' } }).code).toBe('directory-create-failed')
expect(rpcErrorSchema.parse({ code: 'directory-picker-unavailable', message: 'm', details: { capability: 'none' } }).code).toBe('directory-picker-unavailable')
expect(rpcErrorSchema.parse({ code: 'agent-preset-read-only', message: 'm', details: { agentPreset: 'p', reason: 'system' } }).code).toBe('agent-preset-read-only')
expect(rpcErrorSchema.parse({ code: 'agent-preset-locked', message: 'm', details: { sessionId: 's', agentPreset: 'p' } }).code).toBe('agent-preset-locked')
expect(rpcErrorSchema.parse({ code: 'agent-preset-not-found', message: 'm', details: { agentPreset: 'p', available: [] } }).code).toBe('agent-preset-not-found')
@@ -55,7 +47,6 @@ describe('rpcErrorSchema', () => {
it('rejects a known code with missing details', () => {
expect(() => rpcErrorSchema.parse({ code: 'agent-busy', message: 'm', details: {} })).toThrow()
expect(() => rpcErrorSchema.parse({ code: 'directory-unreadable', message: 'm', details: {} })).toThrow()
expect(() => rpcErrorSchema.parse({ code: 'internal', message: 'm' })).toThrow()
expect(() => rpcErrorSchema.parse({ code: 'nope', message: 'm', details: {} })).toThrow()
})
@@ -109,26 +100,6 @@ describe('host domain schemas', () => {
version: '1', cwd: '/x', attachedSessions: 0, canOpenPath: true,
})).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 }],
truncated: false,
})
expect(listing.entries[0]?.hidden).toBe(true)
// The flag is part of the wire value, not an optional decoration.
expect(() => hostListDirectoryValueSchema.parse({ path: '/x', home: '/x', crumbs: [], entries: [] })).toThrow()
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' })
})
})
describe('skills domain schemas', () => {