provider.spec.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197
  1. import { Context, Service } from '@deepseek-ai/cordis'
  2. import { fileURLToPath } from 'node:url'
  3. import { FsError, FsTargetKey, FsVersion, type FsTarget } from '@deepseek-ai/dsh-fs'
  4. import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox'
  5. import { RemoteOperationError } from '@deepseek-ai/dsh-ssh/protocol'
  6. import { describe, expect, it, onTestFinished, vi } from 'vitest'
  7. import { z } from 'zod'
  8. import { SshFileSystem } from '../src/index.ts'
  9. type Dispatch = (method: string, params: unknown, signal?: AbortSignal) => Promise<unknown>
  10. const target: FsTarget = { targetKey: FsTargetKey('/remote/work/file.txt'), displayPath: 'file.txt' }
  11. const streamId = '00000000-0000-4000-8000-000000000001'
  12. async function setup() {
  13. const dispatch = vi.fn<Dispatch>()
  14. class WireConnection extends Service {
  15. constructor(ctx: Context) { super(ctx, 'ssh') }
  16. async request<T>(method: string, params: unknown, result: z.ZodType<T>, signal?: AbortSignal): Promise<T> {
  17. return result.parse(await dispatch(method, params, signal))
  18. }
  19. }
  20. class Policy extends Service {
  21. readonly defaultMode = 'read-only'
  22. constructor(ctx: Context) { super(ctx, 'sandboxPolicy') }
  23. resolve(): SandboxExecutionPolicy { return { mode: 'read-only', workspaceRoot: '/remote/work' } }
  24. }
  25. const ctx = new Context()
  26. const fibers = [await ctx.plugin(WireConnection), await ctx.plugin(Policy), await ctx.plugin(SshFileSystem)]
  27. onTestFinished(async () => { for (const fiber of fibers.reverse()) await fiber.dispose() })
  28. return { fs: ctx.fs, dispatch }
  29. }
  30. describe('SSH filesystem provider', () => {
  31. it.each([
  32. ['literal%20name.ts', 'literal%2520name.ts'],
  33. ['back\\slash.ts', 'back%5Cslash.ts'],
  34. ['line\nfeed.ts', 'line%0Afeed.ts'],
  35. ])('preserves the POSIX filename %j in a file URL', async (name, encoded) => {
  36. const { fs } = await setup()
  37. const path = `/remote/work/${name}`
  38. const url = fs.fileUrl({ targetKey: FsTargetKey(path), displayPath: path })
  39. expect(url).toBe(`file:///remote/work/${encoded}`)
  40. expect(fileURLToPath(url)).toBe(path)
  41. })
  42. it('keeps remote canonical paths and sends relative spelling to the remote resolver', async () => {
  43. const { fs, dispatch } = await setup()
  44. dispatch.mockResolvedValue({ targetKey: '/remote/physical/file #?.txt', displayPath: 'link/../file #?.txt' })
  45. const signal = new AbortController().signal
  46. const resolved = await fs.resolve('link/../file #?.txt', { cwd: '/remote/work', signal })
  47. expect(dispatch).toHaveBeenCalledWith('fs.resolve', { path: 'link/../file #?.txt', cwd: '/remote/work' }, signal)
  48. expect(fs.processPath(resolved)).toBe('/remote/physical/file #?.txt')
  49. expect(fs.fileUrl(resolved)).toBe('file:///remote/physical/file%20%23%3F.txt')
  50. expect(fs.processPathFromHostPath('/host/private/bootstrap.js')).toBeUndefined()
  51. await fs.resolve('file.txt')
  52. expect(dispatch).toHaveBeenLastCalledWith('fs.resolve', { path: 'file.txt', cwd: undefined }, undefined)
  53. expect(fs.sandboxMode).toBe('read-only')
  54. })
  55. it('compares POSIX canonical identities without accepting a sibling prefix', async () => {
  56. const { fs } = await setup()
  57. const makeTarget = (path: string): FsTarget => ({ targetKey: FsTargetKey(path), displayPath: path })
  58. const root = makeTarget('/remote/work')
  59. expect(fs.contains(root, root)).toBe(true)
  60. expect(fs.contains(root, target)).toBe(true)
  61. expect(fs.contains(root, makeTarget('/remote/work-other/file'))).toBe(false)
  62. expect(fs.contains(root, makeTarget('/remote'))).toBe(false)
  63. expect(fs.contains(root, makeTarget('/outside/file'))).toBe(false)
  64. })
  65. it('preserves metadata, final symlinks, directory entries and missing observations', async () => {
  66. const { fs, dispatch } = await setup()
  67. const info = { version: 'v1', type: 'file', size: 7 }
  68. const link = { version: 'link-v1', type: 'symlink', size: 8 }
  69. const entries = [{ name: 'file.txt', type: 'file', target, version: 'v1', size: 7 }]
  70. dispatch.mockResolvedValueOnce(info).mockResolvedValueOnce(null).mockResolvedValueOnce(link).mockResolvedValueOnce(null)
  71. .mockResolvedValueOnce(entries)
  72. expect(await fs.stat(target)).toEqual(info)
  73. expect(await fs.stat(target)).toBeUndefined()
  74. expect(await fs.lstat('link', { cwd: '/remote/work' })).toEqual(link)
  75. expect(await fs.lstat('missing')).toBeUndefined()
  76. expect(await fs.listDir(target)).toEqual(entries)
  77. expect(dispatch.mock.calls[2]).toEqual(['fs.lstat', { path: 'link', cwd: '/remote/work' }, undefined])
  78. })
  79. it('decodes binary responses and retains caller-owned read limits', async () => {
  80. const { fs, dispatch } = await setup()
  81. const bytes = Buffer.from([0, 255, 128, 10])
  82. dispatch.mockResolvedValueOnce('remote text').mockResolvedValueOnce(bytes.toString('base64'))
  83. .mockResolvedValueOnce(bytes.subarray(1, 3).toString('base64'))
  84. expect(await fs.readText(target)).toBe('remote text')
  85. expect(await fs.readBytes(target, undefined, 64)).toEqual(bytes)
  86. expect(await fs.readByteRange(target, { offset: 1, length: 2 })).toEqual(bytes.subarray(1, 3))
  87. expect(dispatch.mock.calls[1]).toEqual(['fs.readBytes', { target, maxBytes: 64 }, undefined])
  88. expect(dispatch.mock.calls[2]).toEqual(['fs.readRange', { target, offset: 1, length: 2 }, undefined])
  89. })
  90. it('forwards mutation guards and explicit per-call policy without normalizing remote roots', async () => {
  91. const { fs, dispatch } = await setup()
  92. const written = { operation: 'update', version: 'v2', before: 'old', after: 'new' }
  93. const edited = { version: 'v3', before: 'new', after: 'next' }
  94. dispatch.mockResolvedValueOnce(written).mockResolvedValueOnce(edited)
  95. const signal = new AbortController().signal
  96. const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: '/remote/link/..' }
  97. const expected = { kind: 'replaceIfVersion' as const, version: FsVersion('v1') }
  98. const edit = { oldString: 'new', newString: 'next', replaceAll: false }
  99. expect(await fs.writeText(target, 'new', expected, signal, policy)).toEqual(written)
  100. expect(dispatch).toHaveBeenLastCalledWith('fs.write', { target, content: 'new', expected, policy }, signal)
  101. expect(await fs.editText(target, edit, { version: FsVersion('v2') }, signal, policy)).toEqual(edited)
  102. expect(dispatch).toHaveBeenLastCalledWith('fs.edit', { target, edit, expected: { version: 'v2' }, policy }, signal)
  103. })
  104. it('resolves deployment policy for mutations without an explicit policy', async () => {
  105. const { fs, dispatch } = await setup()
  106. dispatch.mockResolvedValueOnce({ operation: 'create', version: 'v1', before: null, after: 'new' })
  107. .mockResolvedValueOnce({ version: 'v2', before: 'new', after: 'next' })
  108. await fs.writeText(target, 'new', { kind: 'createIfAbsent' })
  109. await fs.editText(target, { oldString: 'new', newString: 'next', replaceAll: true })
  110. for (const [, params] of dispatch.mock.calls) expect(params).toMatchObject({ policy: { mode: 'read-only', workspaceRoot: '/remote/work' } })
  111. })
  112. it('pulls text through completion without an unnecessary close request', async () => {
  113. const { fs, dispatch } = await setup()
  114. dispatch.mockResolvedValueOnce(streamId).mockResolvedValueOnce({ done: false, value: '' })
  115. .mockResolvedValueOnce({ done: false, value: 'first' }).mockResolvedValueOnce({ done: true, value: 'last' })
  116. const chunks: string[] = []
  117. for await (const chunk of await fs.streamText(target)) chunks.push(chunk)
  118. expect(chunks).toEqual(['first', 'last'])
  119. expect(dispatch.mock.calls.map(([method]) => method)).toEqual(['fs.stream', 'fs.next', 'fs.next', 'fs.next'])
  120. })
  121. it.each([false, true])('closes the remote iterator after an early consumer stop (close fails: %s)', async (closeFails) => {
  122. const { fs, dispatch } = await setup()
  123. dispatch.mockResolvedValueOnce(streamId).mockResolvedValueOnce({ done: false, value: 'first' })
  124. if (closeFails) dispatch.mockRejectedValueOnce(new Error('connection lost'))
  125. else dispatch.mockResolvedValueOnce(null)
  126. for await (const chunk of await fs.streamText(target)) { expect(chunk).toBe('first'); break }
  127. expect(dispatch).toHaveBeenLastCalledWith('fs.streamClose', { id: streamId }, undefined)
  128. })
  129. it('closes the remote iterator when its signal aborts between pulls', async () => {
  130. const { fs, dispatch } = await setup()
  131. dispatch.mockResolvedValueOnce(streamId).mockResolvedValueOnce({ done: false, value: 'first' }).mockResolvedValueOnce(null)
  132. const controller = new AbortController()
  133. const iterator = (await fs.streamText(target, controller.signal))[Symbol.asyncIterator]()
  134. expect(await iterator.next()).toEqual({ done: false, value: 'first' })
  135. controller.abort(new Error('cancel text stream'))
  136. await expect(iterator.next()).rejects.toThrow('cancel text stream')
  137. expect(dispatch).toHaveBeenLastCalledWith('fs.streamClose', { id: streamId }, undefined)
  138. })
  139. it('closes a remote iterator after a malformed pull response', async () => {
  140. const { fs, dispatch } = await setup()
  141. dispatch.mockResolvedValueOnce(streamId).mockResolvedValueOnce({ done: false, value: 1 }).mockResolvedValueOnce(null)
  142. const iterator = (await fs.streamText(target))[Symbol.asyncIterator]()
  143. await expect(iterator.next()).rejects.toMatchObject({ code: 'FS_IO_ERROR' })
  144. expect(dispatch).toHaveBeenLastCalledWith('fs.streamClose', { id: streamId }, undefined)
  145. })
  146. it.each(['FS_STALE_VERSION', 'FS_SANDBOX_DENIED', 'FS_NOT_OBSERVED'] as const)('preserves remote %s failures', async (code) => {
  147. const { fs, dispatch } = await setup()
  148. const cause = new RemoteOperationError('remote mutation rejected', code)
  149. dispatch.mockRejectedValueOnce(cause)
  150. await expect(fs.writeText(target, 'new')).rejects.toMatchObject({ code, message: cause.message, cause })
  151. expect(dispatch).toHaveBeenCalledTimes(1)
  152. })
  153. it.each([undefined, 'OTHER_ERROR', 'FS_UNKNOWN_REMOTE_CODE'])('maps an unrecognized remote error code %s to I/O failure', async (code) => {
  154. const { fs, dispatch } = await setup()
  155. dispatch.mockRejectedValueOnce(new RemoteOperationError('unrecognized failure', code))
  156. await expect(fs.readText(target)).rejects.toMatchObject({ code: 'FS_IO_ERROR' })
  157. })
  158. it('reports a cancelled remote request without retrying its mutation', async () => {
  159. const { fs, dispatch } = await setup()
  160. const controller = new AbortController()
  161. dispatch.mockImplementationOnce(async () => { controller.abort(); throw new Error('request cancelled') })
  162. await expect(fs.writeText(target, 'new', undefined, controller.signal)).rejects.toMatchObject({ code: 'FS_ABORTED' })
  163. expect(dispatch).toHaveBeenCalledTimes(1)
  164. })
  165. it('preserves a primitive AbortSignal reason as a filesystem cancellation', async () => {
  166. const { fs, dispatch } = await setup()
  167. const signal = AbortSignal.abort('caller cancelled')
  168. dispatch.mockImplementationOnce(async (_method, _params, cancellation) => { cancellation?.throwIfAborted() })
  169. await expect(fs.readText(target, signal)).rejects.toMatchObject({ code: 'FS_ABORTED', message: 'caller cancelled' })
  170. expect(dispatch).toHaveBeenCalledTimes(1)
  171. })
  172. it.each([null, { targetKey: 'relative', displayPath: 'file' }, { targetKey: '/remote/file', displayPath: 1 }])(
  173. 'rejects malformed target observations from the wire', async (raw) => {
  174. const { fs, dispatch } = await setup()
  175. dispatch.mockResolvedValueOnce(raw)
  176. await expect(fs.resolve('file')).rejects.toBeInstanceOf(FsError)
  177. },
  178. )
  179. })