api-proxy-host.spec.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205
  1. import { homedir } from 'node:os'
  2. import { afterEach, describe, expect, it } from 'vitest'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import AgentRegistry from '@deepseek-ai/dsh-agent'
  5. import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker'
  6. import type { DirectoryPickerCapability } from '@deepseek-ai/dsh-host-directory-picker'
  7. import type { RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  8. import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc'
  9. import { createApiProxy } from '../src/api-proxy.ts'
  10. let nextRpc = 1
  11. const contexts: Context[] = []
  12. afterEach(async () => {
  13. await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose()))
  14. })
  15. function request<P>(payload: P): RpcRequest<P> {
  16. return { rpcId: RpcId(`host-${String(nextRpc++)}`), payload }
  17. }
  18. function expectOk<T>(response: { readonly result: { readonly ok: true; readonly value: T } | { readonly ok: false } }): T {
  19. expect(response.result.ok).toBe(true)
  20. if (!response.result.ok) throw new Error('unreachable')
  21. return response.result.value
  22. }
  23. async function harness(
  24. picker: DirectoryPickerCapability = { kind: 'native', pick: async () => null },
  25. extras: {
  26. openPath?: (path: string, signal: AbortSignal) => Promise<void>
  27. canOpenPath?: () => boolean
  28. } = {},
  29. ) {
  30. const ctx = new Context()
  31. contexts.push(ctx)
  32. await ctx.plugin(AgentRegistry)
  33. ctx.provide('directoryPicker', { capability: () => picker } as never)
  34. const api = createApiProxy(ctx, {
  35. defaultModelSelection: () => ({ provider: 'test', model: 'test-model' }),
  36. cwd: '/tmp/dsh-apiproxy-host',
  37. ...extras.openPath === undefined ? {} : { openPath: extras.openPath },
  38. ...extras.canOpenPath === undefined ? {} : { canOpenPath: extras.canOpenPath },
  39. })
  40. return { api }
  41. }
  42. describe('host.pickDirectory', () => {
  43. it('returns a selected path or explicit cancellation from the native capability', async () => {
  44. const selected = await harness({ kind: 'native', pick: async () => '/tmp/project' })
  45. expect((await selected.api.host.pickDirectory(request({}), new AbortController().signal)).result)
  46. .toEqual({ ok: true, value: { path: '/tmp/project' } })
  47. const cancelled = await harness({ kind: 'native', pick: async () => null })
  48. expect((await cancelled.api.host.pickDirectory(request({}), new AbortController().signal)).result)
  49. .toEqual({ ok: true, value: { path: null } })
  50. })
  51. it('propagates abort into the native capability as a cancelled RPC error', async () => {
  52. const { api } = await harness({
  53. kind: 'native',
  54. pick: signal => new Promise((_resolve, reject) => {
  55. signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
  56. }),
  57. })
  58. const abort = new AbortController()
  59. const pending = api.host.pickDirectory(request({}), abort.signal)
  60. abort.abort()
  61. expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
  62. })
  63. it('folds a non-abort native-chooser failure into an internal error', async () => {
  64. const { api } = await harness({
  65. kind: 'native',
  66. pick: async () => { throw new Error('no chooser installed') },
  67. })
  68. const response = await api.host.pickDirectory(request({}), new AbortController().signal)
  69. expect(response.result).toMatchObject({ ok: false, error: { code: 'internal' } })
  70. })
  71. it('refuses the native RPC under a browse composition', async () => {
  72. const { api } = await harness(BROWSE_STUB)
  73. const response = await api.host.pickDirectory(request({}), new AbortController().signal)
  74. expect(response.result).toMatchObject({
  75. ok: false,
  76. error: { code: 'directory-picker-unavailable', details: { capability: 'browse' } },
  77. })
  78. })
  79. })
  80. const BROWSE_STUB: DirectoryPickerCapability = {
  81. kind: 'browse',
  82. list: async (path) => {
  83. if (path === '/denied') {
  84. throw new DirectoryPickerError('directory-unreadable', '/denied', 'cannot list /denied')
  85. }
  86. const target = path ?? '/home/user'
  87. return {
  88. path: target,
  89. home: '/home/user',
  90. crumbs: [{ name: '/', path: '/', hidden: false }],
  91. entries: [{ name: 'projects', path: `${target}/projects`, hidden: false }],
  92. truncated: false,
  93. }
  94. },
  95. createDirectory: async (path, name) => {
  96. if (name === 'taken') {
  97. throw new DirectoryPickerError('directory-exists', `${path}/${name}`, 'already exists')
  98. }
  99. if (name === 'unwritable') throw new Error('disk detached')
  100. return `${path}/${name}`
  101. },
  102. }
  103. describe('host.listDirectory / host.createDirectory', () => {
  104. it('serves listings and creation through the browse capability, defaulting to home', async () => {
  105. const { api } = await harness(BROWSE_STUB)
  106. const home = await api.host.listDirectory(request({}), new AbortController().signal)
  107. expect(home.result).toMatchObject({ ok: true, value: { path: '/home/user', home: '/home/user' } })
  108. const listed = await api.host.listDirectory(
  109. request({ path: '/home/user/projects' }),
  110. new AbortController().signal,
  111. )
  112. expect(listed.result).toMatchObject({ ok: true, value: { path: '/home/user/projects' } })
  113. const created = await api.host.createDirectory(request({ path: '/home/user', name: 'fresh' }))
  114. expect(created.result).toEqual({ ok: true, value: { path: '/home/user/fresh' } })
  115. })
  116. it('maps typed picker failures onto wire errors and folds unknown throws to internal', async () => {
  117. const { api } = await harness(BROWSE_STUB)
  118. expect((await api.host.listDirectory(
  119. request({ path: '/denied' }),
  120. new AbortController().signal,
  121. )).result).toMatchObject({
  122. ok: false,
  123. error: { code: 'directory-unreadable', details: { path: '/denied' } },
  124. })
  125. expect((await api.host.createDirectory(request({ path: '/home/user', name: 'taken' }))).result)
  126. .toMatchObject({ ok: false, error: { code: 'directory-exists' } })
  127. expect((await api.host.createDirectory(request({ path: '/home/user', name: 'unwritable' }))).result)
  128. .toMatchObject({ ok: false, error: { code: 'internal' } })
  129. })
  130. it('reports an aborted listing as cancelled', async () => {
  131. const { api } = await harness({
  132. kind: 'browse',
  133. list: (_path, signal) => new Promise((_resolve, reject) => {
  134. signal?.addEventListener('abort', () => { reject(new Error('scan aborted')) }, { once: true })
  135. }),
  136. createDirectory: async () => '/never',
  137. })
  138. const abort = new AbortController()
  139. const pending = api.host.listDirectory(request({}), abort.signal)
  140. abort.abort()
  141. expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
  142. })
  143. it('refuses the browse RPCs under a native composition', async () => {
  144. const { api } = await harness()
  145. expect((await api.host.listDirectory(request({}), new AbortController().signal)).result)
  146. .toMatchObject({
  147. ok: false,
  148. error: { code: 'directory-picker-unavailable', details: { capability: 'native' } },
  149. })
  150. expect((await api.host.createDirectory(request({ path: '/x', name: 'y' }))).result)
  151. .toMatchObject({
  152. ok: false,
  153. error: { code: 'directory-picker-unavailable', details: { capability: 'native' } },
  154. })
  155. })
  156. })
  157. describe('host.openPath', () => {
  158. it('describes whether the deployment can reach a native desktop', async () => {
  159. const visible = await harness(undefined, { canOpenPath: () => true })
  160. const headless = await harness(undefined, { canOpenPath: () => false })
  161. expect(expectOk(await visible.api.host.describe(request({}))).canOpenPath).toBe(true)
  162. expect(expectOk(await headless.api.host.describe(request({}))).canOpenPath).toBe(false)
  163. expect(expectOk(await visible.api.host.describe(request({}))).home).toBe(homedir())
  164. })
  165. it('opens through the injected native boundary', async () => {
  166. const opened: string[] = []
  167. const { api } = await harness(undefined, {
  168. openPath: async (path) => { opened.push(path) },
  169. })
  170. expect((await api.host.openPath(
  171. request({ path: '/tmp/a.txt' }),
  172. new AbortController().signal,
  173. )).result).toEqual({ ok: true, value: { opened: true } })
  174. expect(opened).toEqual(['/tmp/a.txt'])
  175. })
  176. it('propagates abort into the native boundary as a cancelled RPC error', async () => {
  177. const { api } = await harness(undefined, {
  178. openPath: (_path, signal) => new Promise((_resolve, reject) => {
  179. signal.addEventListener('abort', () => { reject(new Error('aborted')) }, { once: true })
  180. }),
  181. })
  182. const abort = new AbortController()
  183. const pending = api.host.openPath(request({ path: '/tmp/a.txt' }), abort.signal)
  184. abort.abort()
  185. expect((await pending).result).toMatchObject({ ok: false, error: { code: 'cancelled' } })
  186. })
  187. })