filesystem.spec.ts 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851
  1. import { Buffer } from 'node:buffer'
  2. import { dirname, posix } from 'node:path'
  3. import { Context } from '@deepseek-ai/cordis'
  4. import {
  5. CommandExitError,
  6. FileNotFoundError,
  7. FileType,
  8. type EntryInfo,
  9. type Sandbox,
  10. } from '@deepseek-ai/dsh-e2b'
  11. import type E2BRuntime from '@deepseek-ai/dsh-e2b'
  12. import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
  13. import E2BFileSystem from '@deepseek-ai/dsh-fs-e2b'
  14. import { describe, expect, it, vi } from 'vitest'
  15. interface RemoteNode {
  16. type: FileType
  17. data: Uint8Array
  18. mode: number
  19. modified: number
  20. metadata?: Record<string, string>
  21. symlinkTarget?: string
  22. }
  23. function bytes(value: string | readonly number[]): Uint8Array {
  24. return typeof value === 'string' ? new TextEncoder().encode(value) : Uint8Array.from(value)
  25. }
  26. function commandError(exitCode: number, stderr = ''): CommandExitError {
  27. return new CommandExitError({ exitCode, stdout: '', stderr, error: stderr })
  28. }
  29. class FakeRemote {
  30. readonly nodes = new Map<string, RemoteNode>()
  31. readonly writes: Array<{ path: string; data: string; metadata?: Record<string, string> }> = []
  32. readonly writeParentModes: number[] = []
  33. readonly renames: Array<{ from: string; to: string }> = []
  34. readonly links: Array<{ from: string; to: string }> = []
  35. readonly removals: string[] = []
  36. readonly commands: string[] = []
  37. readonly reads: Array<{ path: string; format: 'bytes' | 'stream' }> = []
  38. streamChunks: Uint8Array[] | undefined
  39. streamKeepOpen = false
  40. readonly streamCancel = vi.fn()
  41. nextCommandError: unknown
  42. nextMakeDirResult: boolean | undefined
  43. nextInfoError: unknown
  44. nextListError: unknown
  45. nextReadError: unknown
  46. nextRenameError: unknown
  47. nextRemoveError: unknown
  48. canonicalOutput: string | undefined
  49. abortAfterRename: AbortController | undefined
  50. competitorBeforeLink:
  51. | { path: string; kind: 'file'; data: string }
  52. | { path: string; kind: 'directory' }
  53. | undefined
  54. guardedLinkOutput: string | undefined
  55. disappearOnInfo = new Set<string>()
  56. private clock = 1
  57. constructor() {
  58. this.dir('/')
  59. this.dir('/workspace')
  60. }
  61. dir(path: string): void {
  62. this.nodes.set(path, { type: FileType.DIR, data: bytes(''), mode: 0o755, modified: this.clock++ })
  63. }
  64. file(path: string, data: string | readonly number[], mode = 0o644): void {
  65. this.nodes.set(path, { type: FileType.FILE, data: bytes(data), mode, modified: this.clock++ })
  66. }
  67. other(path: string): void {
  68. this.nodes.set(path, { type: 'other' as FileType, data: bytes(''), mode: 0o600, modified: this.clock++ })
  69. }
  70. symlink(path: string, target: string): void {
  71. this.nodes.set(path, {
  72. type: FileType.FILE,
  73. data: bytes(''),
  74. mode: 0o777,
  75. modified: this.clock++,
  76. symlinkTarget: target,
  77. })
  78. }
  79. mutate(path: string, data: string): void {
  80. const node = this.required(path)
  81. node.data = bytes(data)
  82. node.modified = this.clock++
  83. }
  84. private required(path: string): RemoteNode {
  85. const node = this.nodes.get(path)
  86. if (node === undefined) throw new FileNotFoundError(`missing: ${path}`)
  87. return node
  88. }
  89. private followed(path: string): { path: string; node: RemoteNode; link?: RemoteNode } {
  90. const node = this.required(path)
  91. if (node.symlinkTarget === undefined) return { path, node }
  92. return { path: node.symlinkTarget, node: this.required(node.symlinkTarget), link: node }
  93. }
  94. private info(path: string): EntryInfo {
  95. if (this.disappearOnInfo.delete(path)) throw new FileNotFoundError(`missing: ${path}`)
  96. return this.rawInfo(path)
  97. }
  98. private rawInfo(path: string): EntryInfo {
  99. const followed = this.followed(path)
  100. const node = followed.node
  101. return {
  102. name: posix.basename(path),
  103. path,
  104. type: node.type,
  105. size: node.data.byteLength,
  106. mode: node.mode,
  107. permissions: 'rw-------',
  108. owner: 'user',
  109. group: 'user',
  110. modifiedTime: new Date(node.modified),
  111. ...(node.metadata !== undefined ? { metadata: { ...node.metadata } } : {}),
  112. ...(followed.link?.symlinkTarget !== undefined ? { symlinkTarget: followed.link.symlinkTarget } : {}),
  113. }
  114. }
  115. private checkAbort(options: { signal?: AbortSignal } | undefined): void {
  116. if (options?.signal?.aborted === true) throw new DOMException('aborted', 'AbortError')
  117. }
  118. readonly sandbox = {
  119. sandboxId: 'fake',
  120. files: {
  121. makeDir: async (path: string, options?: { signal?: AbortSignal }): Promise<boolean> => {
  122. this.checkAbort(options)
  123. if (this.nextMakeDirResult !== undefined) {
  124. const result = this.nextMakeDirResult
  125. this.nextMakeDirResult = undefined
  126. return result
  127. }
  128. if (this.nodes.has(path)) return false
  129. this.dir(path)
  130. return true
  131. },
  132. getInfo: async (path: string, options?: { signal?: AbortSignal }): Promise<EntryInfo> => {
  133. this.checkAbort(options)
  134. if (this.nextInfoError !== undefined) {
  135. const error = this.nextInfoError
  136. this.nextInfoError = undefined
  137. throw error
  138. }
  139. return this.info(path)
  140. },
  141. read: async (path: string, options: { format: 'bytes' | 'stream'; signal?: AbortSignal }): Promise<Uint8Array | ReadableStream<Uint8Array> | string> => {
  142. this.checkAbort(options)
  143. this.reads.push({ path, format: options.format })
  144. if (this.nextReadError !== undefined) {
  145. const error = this.nextReadError
  146. this.nextReadError = undefined
  147. throw error
  148. }
  149. const data = this.followed(path).node.data
  150. if (options.format === 'bytes') return data.slice()
  151. // Pinned-SDK fidelity: a content-length-0 response returns '' even in stream format.
  152. if (data.length === 0 && this.streamChunks === undefined) return ''
  153. const chunks = this.streamChunks ?? [data.slice()]
  154. return new ReadableStream<Uint8Array>({
  155. start: (controller) => {
  156. for (const chunk of chunks) controller.enqueue(chunk)
  157. if (!this.streamKeepOpen) controller.close()
  158. // SDK fidelity: an abort of the request signal fails the open stream.
  159. options.signal?.addEventListener('abort', () => { controller.error(new DOMException('aborted', 'AbortError')) }, { once: true })
  160. },
  161. cancel: () => { this.streamCancel() },
  162. })
  163. },
  164. list: async (path: string, options?: { depth?: number; signal?: AbortSignal }): Promise<EntryInfo[]> => {
  165. this.checkAbort(options)
  166. if (this.nextListError !== undefined) {
  167. const error = this.nextListError
  168. this.nextListError = undefined
  169. throw error
  170. }
  171. this.required(path)
  172. return [...this.nodes.keys()]
  173. .filter(candidate => candidate !== path && dirname(candidate) === path)
  174. .map(candidate => this.rawInfo(candidate))
  175. },
  176. write: async (path: string, data: string, options?: { metadata?: Record<string, string>; signal?: AbortSignal }): Promise<object> => {
  177. this.checkAbort(options)
  178. const parent = dirname(path)
  179. if (!this.nodes.has(parent)) this.dir(parent)
  180. this.writeParentModes.push(this.required(parent).mode)
  181. this.nodes.set(path, {
  182. type: FileType.FILE,
  183. data: bytes(data),
  184. mode: 0o644,
  185. modified: this.clock++,
  186. ...(options?.metadata !== undefined ? { metadata: { ...options.metadata } } : {}),
  187. })
  188. this.writes.push({ path, data, ...(options?.metadata !== undefined ? { metadata: options.metadata } : {}) })
  189. return {}
  190. },
  191. rename: async (from: string, to: string, options?: { signal?: AbortSignal }): Promise<EntryInfo> => {
  192. this.checkAbort(options)
  193. if (this.nextRenameError !== undefined) {
  194. const error = this.nextRenameError
  195. this.nextRenameError = undefined
  196. throw error
  197. }
  198. const node = this.required(from)
  199. this.nodes.delete(from)
  200. this.nodes.set(to, node)
  201. this.renames.push({ from, to })
  202. this.abortAfterRename?.abort('after commit')
  203. this.checkAbort(options)
  204. return this.info(to)
  205. },
  206. remove: async (path: string): Promise<void> => {
  207. this.removals.push(path)
  208. if (this.nextRemoveError !== undefined) {
  209. const error = this.nextRemoveError
  210. this.nextRemoveError = undefined
  211. throw error
  212. }
  213. for (const candidate of this.nodes.keys()) {
  214. if (candidate === path || candidate.startsWith(`${path}/`)) this.nodes.delete(candidate)
  215. }
  216. },
  217. },
  218. commands: {
  219. run: async (
  220. command: string,
  221. options?: { envs?: Record<string, string>; signal?: AbortSignal },
  222. ): Promise<{ exitCode: number; stdout: string; stderr: string }> => {
  223. this.checkAbort(options)
  224. const home = options?.envs?.HOME
  225. expect(home).toMatch(/^\/\.dsh-e2b-control-/)
  226. expect(options?.envs).toEqual({ HOME: home })
  227. this.commands.push(command)
  228. if (this.nextCommandError !== undefined) {
  229. const error = this.nextCommandError
  230. this.nextCommandError = undefined
  231. throw error
  232. }
  233. const realpathPrefix = 'set -o pipefail; realpath -mz -- '
  234. const realpathSuffix = ' | base64 -w0'
  235. if (command.startsWith(realpathPrefix) && command.endsWith(realpathSuffix)) {
  236. const quoted = command.slice(realpathPrefix.length, -realpathSuffix.length)
  237. const input = quoted.slice(1, -1).replaceAll(String.raw`'"'"'`, '\'')
  238. const node = this.nodes.get(input)
  239. const canonical = `${node?.symlinkTarget ?? input}\0`
  240. return {
  241. exitCode: 0,
  242. stdout: this.canonicalOutput ?? Buffer.from(canonical).toString('base64'),
  243. stderr: '',
  244. }
  245. }
  246. const chmod = /^chmod ([0-7]+) -- '([^']+)'$/.exec(command)
  247. if (chmod !== null) this.required(chmod[2]!).mode = Number.parseInt(chmod[1]!, 8)
  248. const guardedLink = new RegExp(
  249. "^if ln -T -- '([^']+)' '([^']+)'; then printf created; "
  250. + "elif test -e '[^']+' \\|\\| test -L '[^']+'; then printf exists; else exit 1; fi$",
  251. ).exec(command)
  252. if (guardedLink !== null) {
  253. const from = guardedLink[1]!
  254. const to = guardedLink[2]!
  255. if (this.guardedLinkOutput !== undefined) {
  256. const stdout = this.guardedLinkOutput
  257. this.guardedLinkOutput = undefined
  258. return { exitCode: 0, stdout, stderr: '' }
  259. }
  260. if (this.competitorBeforeLink?.path === to) {
  261. if (this.competitorBeforeLink.kind === 'directory') this.dir(to)
  262. else this.file(to, this.competitorBeforeLink.data)
  263. this.competitorBeforeLink = undefined
  264. }
  265. if (this.nodes.has(to)) return { exitCode: 0, stdout: 'exists', stderr: '' }
  266. this.nodes.set(to, this.required(from))
  267. this.links.push({ from, to })
  268. this.abortAfterRename?.abort('after commit')
  269. return { exitCode: 0, stdout: 'created', stderr: '' }
  270. }
  271. const move = /^mv -f -- '([^']+)' '([^']+)'$/.exec(command)
  272. if (move !== null) {
  273. if (this.nextRenameError !== undefined) {
  274. const error = this.nextRenameError
  275. this.nextRenameError = undefined
  276. throw error
  277. }
  278. const node = this.required(move[1]!)
  279. this.nodes.delete(move[1]!)
  280. this.nodes.set(move[2]!, node)
  281. this.renames.push({ from: move[1]!, to: move[2]! })
  282. this.abortAfterRename?.abort('after commit')
  283. }
  284. return { exitCode: 0, stdout: '', stderr: '' }
  285. },
  286. },
  287. } as unknown as Sandbox
  288. }
  289. async function setup(remote = new FakeRemote()): Promise<{ ctx: Context; fs: E2BFileSystem; remote: FakeRemote }> {
  290. const ctx = new Context()
  291. const runtime = {
  292. cwd: '/workspace',
  293. runtimeRoot: '/workspace/.dsh-e2b',
  294. getSandbox: async () => remote.sandbox,
  295. } as unknown as E2BRuntime
  296. ctx.provide('e2b', runtime)
  297. await ctx.plugin(E2BFileSystem)
  298. return { ctx, fs: ctx.fs as E2BFileSystem, remote }
  299. }
  300. async function expectCode(promise: Promise<unknown>, code: string): Promise<void> {
  301. await expect(promise).rejects.toMatchObject({ code })
  302. }
  303. describe('E2BFileSystem identity, metadata, and reads', () => {
  304. it('resolves remote paths, reports symlinks, and lists direct children in stable order', async () => {
  305. const remote = new FakeRemote()
  306. remote.file('/workspace/z.txt', 'z')
  307. remote.file('/workspace/a.txt', 'a')
  308. remote.dir('/workspace/dir')
  309. remote.other('/workspace/special')
  310. remote.file('/workspace/dir/nested.txt', 'nested')
  311. remote.symlink('/workspace/link.txt', '/workspace/a.txt')
  312. const { fs } = await setup(remote)
  313. const link = await fs.resolve('link.txt')
  314. expect(link).toEqual({ targetKey: '/workspace/a.txt', displayPath: '/workspace/link.txt' })
  315. await expect(fs.lstat('link.txt')).resolves.toMatchObject({ type: 'symlink', size: 1 })
  316. await expect(fs.lstat('a.txt')).resolves.toMatchObject({ type: 'file', size: 1 })
  317. await expect(fs.lstat('dir')).resolves.toEqual(expect.objectContaining({ type: 'directory' }))
  318. await expect(fs.lstat('special')).resolves.toEqual(expect.objectContaining({ type: 'other' }))
  319. await expect(fs.lstat('missing')).resolves.toBeUndefined()
  320. await expect(fs.stat(link)).resolves.toMatchObject({ type: 'file', size: 1 })
  321. const directory = await fs.resolve('.')
  322. const listed = await fs.listDir(directory)
  323. expect(listed.map(entry => entry.name)).toEqual(['a.txt', 'dir', 'link.txt', 'special', 'z.txt'])
  324. expect(listed.find(entry => entry.name === 'dir')).toMatchObject({ type: 'directory' })
  325. expect(listed.find(entry => entry.name === 'link.txt')).toMatchObject({
  326. type: 'file',
  327. target: { targetKey: '/workspace/a.txt', displayPath: '/workspace/link.txt' },
  328. })
  329. expect(listed.some(entry => entry.name === 'nested.txt')).toBe(false)
  330. })
  331. it('projects canonical process paths, file URLs, and containment', async () => {
  332. const remote = new FakeRemote()
  333. remote.dir('/workspace/nested')
  334. remote.file('/workspace/nested/multibyte # file.ts', 'text')
  335. remote.file('/outside.ts', 'outside')
  336. const { fs } = await setup(remote)
  337. const workspace = await fs.resolve('/workspace')
  338. const nested = await fs.resolve('/workspace/nested/multibyte # file.ts')
  339. const outside = await fs.resolve('/outside.ts')
  340. expect(fs.processPath(nested)).toBe('/workspace/nested/multibyte # file.ts')
  341. expect(fs.processPathFromHostPath('/Users/alice/.dsh/attachments/object')).toBeUndefined()
  342. expect(fs.fileUrl(nested)).toBe('file:///workspace/nested/multibyte%20%23%20file.ts')
  343. expect(fs.contains(workspace, workspace)).toBe(true)
  344. expect(fs.contains(workspace, nested)).toBe(true)
  345. expect(fs.contains(nested, workspace)).toBe(false)
  346. expect(fs.contains(workspace, outside)).toBe(false)
  347. expect(() => fs.fileUrl({ targetKey: FsTargetKey('relative'), displayPath: 'relative' }))
  348. .toThrow('expected an absolute process path')
  349. })
  350. it('preserves newline and multibyte canonical paths through strict ASCII framing', async () => {
  351. const remote = new FakeRemote()
  352. const path = '/workspace/你好\nfile.ts'
  353. remote.file(path, 'text')
  354. const { fs } = await setup(remote)
  355. await expect(fs.resolve(path)).resolves.toEqual({ targetKey: path, displayPath: path })
  356. })
  357. it.each([
  358. ['invalid base64', '!!!!'],
  359. ['missing terminator', Buffer.from('/workspace/file').toString('base64')],
  360. ['multiple records', Buffer.from('/workspace/file\0/other\0').toString('base64')],
  361. ['invalid UTF-8', Buffer.from([47, 0xff, 0]).toString('base64')],
  362. ['relative path', Buffer.from('workspace/file\0').toString('base64')],
  363. ])('rejects %s from canonical path transport', async (_label, output) => {
  364. const remote = new FakeRemote()
  365. remote.canonicalOutput = output
  366. const { fs } = await setup(remote)
  367. await expectCode(fs.resolve('file'), 'FS_IO_ERROR')
  368. })
  369. it('reads whole and streamed UTF-8 across chunk boundaries', async () => {
  370. const remote = new FakeRemote()
  371. remote.file('/workspace/text.txt', 'A€B')
  372. remote.streamChunks = [bytes([65, 0xe2]), bytes([0x82, 0xac, 66])]
  373. const { fs } = await setup(remote)
  374. const target = await fs.resolve('text.txt')
  375. await expect(fs.readText(target)).resolves.toBe('A€B')
  376. let streamed = ''
  377. for await (const chunk of await fs.streamText(target)) streamed += chunk
  378. expect(streamed).toBe('A€B')
  379. remote.streamChunks = [bytes([0xe2]), bytes([0x82, 0xac])]
  380. let initiallyBuffered = ''
  381. for await (const chunk of await fs.streamText(target)) initiallyBuffered += chunk
  382. expect(initiallyBuffered).toBe('€')
  383. })
  384. it('streams an empty file even though the pinned SDK returns a non-stream value', async () => {
  385. const remote = new FakeRemote()
  386. remote.file('/workspace/empty.txt', '')
  387. const { fs } = await setup(remote)
  388. let streamed = ''
  389. for await (const chunk of await fs.streamText(await fs.resolve('empty.txt'))) streamed += chunk
  390. expect(streamed).toBe('')
  391. })
  392. it('cancels a remote stream when its consumer stops early', async () => {
  393. const remote = new FakeRemote()
  394. remote.file('/workspace/text.txt', 'ab')
  395. remote.streamChunks = [bytes('a'), bytes('b')]
  396. remote.streamKeepOpen = true
  397. const { fs } = await setup(remote)
  398. const stream = await fs.streamText(await fs.resolve('text.txt'))
  399. for await (const chunk of stream) {
  400. expect(chunk).toBe('a')
  401. break
  402. }
  403. expect(remote.streamCancel).toHaveBeenCalledOnce()
  404. })
  405. it('matches local binary sampling while edits still reject any NUL byte', async () => {
  406. const remote = new FakeRemote()
  407. remote.file('/workspace/late-nul.txt', `${'a'.repeat(8192)}\0tail`)
  408. const { fs } = await setup(remote)
  409. const target = await fs.resolve('late-nul.txt')
  410. await expect(fs.readText(target)).resolves.toContain('\0tail')
  411. remote.streamChunks = [bytes('a'.repeat(8192)), bytes([0, 116])]
  412. let streamed = ''
  413. for await (const chunk of await fs.streamText(target)) streamed += chunk
  414. expect(streamed).toBe(`${'a'.repeat(8192)}\0t`)
  415. await expectCode(fs.editText(target, { oldString: 'tail', newString: 'end', replaceAll: false }), 'FS_NOT_TEXT')
  416. })
  417. it('maps binary, invalid UTF-8, missing, and non-regular read failures', async () => {
  418. const remote = new FakeRemote()
  419. remote.file('/workspace/binary', [0, 1])
  420. remote.file('/workspace/invalid', [0xff])
  421. remote.dir('/workspace/directory')
  422. const { fs } = await setup(remote)
  423. await expectCode(fs.readText(await fs.resolve('binary')), 'FS_NOT_TEXT')
  424. await expectCode(fs.readText(await fs.resolve('invalid')), 'FS_NOT_TEXT')
  425. await expectCode(fs.readText(await fs.resolve('missing')), 'FS_NOT_FOUND')
  426. await expectCode(fs.readText(await fs.resolve('directory')), 'FS_NOT_REGULAR_FILE')
  427. remote.streamChunks = [bytes([0xff])]
  428. const invalid = await fs.streamText(await fs.resolve('invalid'))
  429. await expect((async () => { for await (const _chunk of invalid) void _chunk })()).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
  430. remote.streamChunks = [bytes([0])]
  431. const binary = await fs.streamText(await fs.resolve('binary'))
  432. await expect((async () => { for await (const _chunk of binary) void _chunk })()).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
  433. remote.streamChunks = [bytes([0xe2])]
  434. const incomplete = await fs.streamText(await fs.resolve('invalid'))
  435. await expect((async () => { for await (const _chunk of incomplete) void _chunk })()).rejects.toMatchObject({ code: 'FS_NOT_TEXT' })
  436. const raced = await fs.resolve('invalid')
  437. remote.nextReadError = new FileNotFoundError('gone after stat')
  438. await expectCode(fs.streamText(raced), 'FS_NOT_FOUND')
  439. })
  440. it('readBytes returns raw content, enforces the byte cap, and maps failures', async () => {
  441. const remote = new FakeRemote()
  442. remote.file('/workspace/img.bin', [0x89, 0, 0xff, 0x47])
  443. remote.dir('/workspace/directory')
  444. const { fs } = await setup(remote)
  445. const target = await fs.resolve('img.bin')
  446. expect(Array.from(await fs.readBytes(target, undefined, 4))).toEqual([0x89, 0, 0xff, 0x47])
  447. expect(remote.reads).toEqual([{ path: '/workspace/img.bin', format: 'stream' }])
  448. remote.reads.length = 0
  449. await expectCode(fs.readBytes(target, undefined, 3), 'FS_TOO_LARGE')
  450. expect(remote.reads).toEqual([])
  451. await expectCode(fs.readBytes(await fs.resolve('missing'), undefined, 4), 'FS_NOT_FOUND')
  452. await expectCode(fs.readBytes(await fs.resolve('directory'), undefined, 4), 'FS_NOT_REGULAR_FILE')
  453. const live = new AbortController()
  454. expect((await fs.readBytes(target, live.signal, 4)).byteLength).toBe(4)
  455. remote.nextReadError = new DOMException('aborted', 'AbortError')
  456. await expectCode(fs.readBytes(target, undefined, 4), 'FS_ABORTED')
  457. })
  458. it('readBytes bounds a post-stat grower mid-stream and reads an empty file through the SDK quirk', async () => {
  459. const remote = new FakeRemote()
  460. remote.file('/workspace/grow.bin', [1, 1, 1, 1])
  461. remote.file('/workspace/empty.bin', '')
  462. const { fs } = await setup(remote)
  463. remote.streamChunks = [bytes([1, 1, 1]), bytes([1, 2, 2])]
  464. remote.streamKeepOpen = true
  465. await expectCode(fs.readBytes(await fs.resolve('grow.bin'), undefined, 4), 'FS_TOO_LARGE')
  466. expect(remote.streamCancel).toHaveBeenCalledOnce()
  467. remote.streamChunks = undefined
  468. remote.streamKeepOpen = false
  469. expect((await fs.readBytes(await fs.resolve('empty.bin'), undefined, 4)).byteLength).toBe(0)
  470. })
  471. it('readByteRange skips to the offset, keeps the window, and cancels the stream there', async () => {
  472. const remote = new FakeRemote()
  473. remote.file('/workspace/ramp.bin', [1, 2, 3, 4, 5, 6, 7, 8, 9])
  474. const { fs } = await setup(remote)
  475. const target = await fs.resolve('ramp.bin')
  476. remote.streamChunks = [bytes([1, 2, 3]), bytes([4, 5, 6]), bytes([7, 8, 9])]
  477. remote.streamKeepOpen = true
  478. expect(Array.from(await fs.readByteRange(target, { offset: 4, length: 3 }))).toEqual([5, 6, 7])
  479. expect(remote.reads).toEqual([{ path: '/workspace/ramp.bin', format: 'stream' }])
  480. expect(remote.streamCancel).toHaveBeenCalledOnce()
  481. })
  482. it('readByteRange shortens at the end, empties past it, and skips the read for length 0', async () => {
  483. const remote = new FakeRemote()
  484. remote.file('/workspace/ramp.bin', [1, 2, 3, 4, 5, 6, 7, 8, 9])
  485. remote.dir('/workspace/directory')
  486. const { fs } = await setup(remote)
  487. const target = await fs.resolve('ramp.bin')
  488. remote.streamChunks = [bytes([1, 2, 3]), bytes([4, 5, 6]), bytes([7, 8, 9])]
  489. expect(Array.from(await fs.readByteRange(target, { offset: 7, length: 10 }))).toEqual([8, 9])
  490. expect(remote.streamCancel).not.toHaveBeenCalled()
  491. expect((await fs.readByteRange(target, { offset: 9, length: 2 })).byteLength).toBe(0)
  492. remote.reads.length = 0
  493. expect((await fs.readByteRange(target, { offset: 0, length: 0 })).byteLength).toBe(0)
  494. expect(remote.reads).toEqual([])
  495. await expectCode(fs.readByteRange(await fs.resolve('missing'), { offset: 0, length: 1 }), 'FS_NOT_FOUND')
  496. await expectCode(fs.readByteRange(await fs.resolve('directory'), { offset: 0, length: 1 }), 'FS_NOT_REGULAR_FILE')
  497. })
  498. it('readByteRange maps a failing open, an abort mid-stream, and tolerates a failing cancel', async () => {
  499. const remote = new FakeRemote()
  500. remote.file('/workspace/ramp.bin', [1, 2, 3, 4])
  501. const { fs } = await setup(remote)
  502. const target = await fs.resolve('ramp.bin')
  503. remote.nextReadError = new DOMException('aborted', 'AbortError')
  504. await expectCode(fs.readByteRange(target, { offset: 0, length: 2 }), 'FS_ABORTED')
  505. // The window wants more than the one chunk delivered; the abort fails the open stream.
  506. remote.streamChunks = [bytes([1])]
  507. remote.streamKeepOpen = true
  508. const controller = new AbortController()
  509. const pending = fs.readByteRange(target, { offset: 0, length: 4 }, controller.signal)
  510. await new Promise<void>((resolve) => { setTimeout(resolve, 0) })
  511. controller.abort()
  512. await expectCode(pending, 'FS_ABORTED')
  513. remote.streamChunks = [bytes([1, 2, 3, 4])]
  514. remote.streamCancel.mockRejectedValueOnce(new Error('cancel failed'))
  515. expect(Array.from(await fs.readByteRange(target, { offset: 1, length: 2 }))).toEqual([2, 3])
  516. })
  517. it('honors aborts before and during remote reads', async () => {
  518. const remote = new FakeRemote()
  519. remote.file('/workspace/a', 'a')
  520. const { fs } = await setup(remote)
  521. await expectCode(fs.resolve('a', { signal: AbortSignal.abort() }), 'FS_ABORTED')
  522. await expectCode(fs.lstat('a', undefined, AbortSignal.abort()), 'FS_ABORTED')
  523. await expectCode(fs.stat(await fs.resolve('a'), AbortSignal.abort()), 'FS_ABORTED')
  524. remote.nextReadError = new DOMException('aborted', 'AbortError')
  525. await expectCode(fs.readText(await fs.resolve('a')), 'FS_ABORTED')
  526. })
  527. it('rejects empty paths and directory-listing type errors', async () => {
  528. const remote = new FakeRemote()
  529. remote.file('/workspace/file', 'x')
  530. const { fs } = await setup(remote)
  531. await expectCode(fs.resolve(' '), 'FS_NOT_FOUND')
  532. await expectCode(fs.lstat(''), 'FS_NOT_FOUND')
  533. await expectCode(fs.listDir(await fs.resolve('missing')), 'FS_NOT_FOUND')
  534. await expectCode(fs.listDir(await fs.resolve('/workspace/file')), 'FS_NOT_DIRECTORY')
  535. remote.nextListError = new Error('listing transport failed')
  536. await expectCode(fs.listDir(await fs.resolve('/workspace')), 'FS_IO_ERROR')
  537. })
  538. })
  539. describe('E2BFileSystem atomic writes and edits', () => {
  540. it('creates owner-only files and returns metadata after the committed move', async () => {
  541. const { fs, remote } = await setup()
  542. const target = await fs.resolve('new.txt')
  543. const outcome = await fs.writeText(target, 'one\r\ntwo\rthree', { kind: 'createIfAbsent' })
  544. expect(outcome).toMatchObject({ operation: 'create', before: null, after: 'one\ntwo\rthree' })
  545. expect(remote.nodes.get('/workspace/new.txt')?.mode).toBe(0o600)
  546. expect(remote.nodes.get('/workspace/new.txt')?.metadata?.['dsh-version']).toBeDefined()
  547. expect(remote.writeParentModes).toEqual([0o700])
  548. expect(remote.links).toHaveLength(1)
  549. const stagingDirectory = posix.dirname(remote.writes[0]!.path)
  550. expect(posix.dirname(stagingDirectory)).toBe('/workspace')
  551. expect(remote.removals).toContain(stagingDirectory)
  552. await expect(fs.stat(target)).resolves.toMatchObject({ version: outcome.version, size: 14 })
  553. })
  554. it('preserves replacement mode, normalizes only CRLF for diffs, and changes version on external writes', async () => {
  555. const remote = new FakeRemote()
  556. remote.file('/workspace/file.txt', 'old\r\nline\rlone', 0o640)
  557. const { fs } = await setup(remote)
  558. const target = await fs.resolve('file.txt')
  559. const before = (await fs.stat(target))!.version
  560. const outcome = await fs.writeText(target, 'new', { kind: 'replaceIfVersion', version: before })
  561. expect(outcome).toMatchObject({ operation: 'update', before: 'old\nline\rlone', after: 'new' })
  562. expect(remote.nodes.get('/workspace/file.txt')?.mode).toBe(0o640)
  563. const committed = outcome.version
  564. remote.mutate('/workspace/file.txt', 'external')
  565. expect((await fs.stat(target))!.version).not.toBe(committed)
  566. })
  567. it('returns null as the overwrite diff basis for binary or invalid prior content', async () => {
  568. const remote = new FakeRemote()
  569. remote.file('/workspace/file.txt', [0xff])
  570. const { fs } = await setup(remote)
  571. const target = await fs.resolve('file.txt')
  572. await expect(fs.writeText(target, 'valid')).resolves.toMatchObject({ before: null, after: 'valid' })
  573. })
  574. it('fails an overwrite when reading its text diff basis fails for another reason', async () => {
  575. const remote = new FakeRemote()
  576. remote.file('/workspace/file.txt', 'prior')
  577. const { fs } = await setup(remote)
  578. const target = await fs.resolve('file.txt')
  579. remote.nextReadError = new Error('read transport failed')
  580. await expectCode(fs.writeText(target, 'replacement'), 'FS_IO_ERROR')
  581. expect(new TextDecoder().decode(remote.nodes.get('/workspace/file.txt')?.data)).toBe('prior')
  582. })
  583. it('enforces create and version intents before publication', async () => {
  584. const remote = new FakeRemote()
  585. remote.file('/workspace/file.txt', 'v1')
  586. const { fs } = await setup(remote)
  587. const target = await fs.resolve('file.txt')
  588. const version = (await fs.stat(target))!.version
  589. await expectCode(fs.writeText(target, 'blind', { kind: 'createIfAbsent' }), 'FS_NOT_OBSERVED')
  590. remote.mutate('/workspace/file.txt', 'v2')
  591. await expectCode(fs.writeText(target, 'stale', { kind: 'replaceIfVersion', version }), 'FS_STALE_VERSION')
  592. await expectCode(fs.writeText(await fs.resolve('missing'), 'stale', { kind: 'replaceIfVersion', version }), 'FS_STALE_VERSION')
  593. remote.dir('/workspace/dir')
  594. await expectCode(fs.writeText(await fs.resolve('dir'), 'x'), 'FS_NOT_REGULAR_FILE')
  595. })
  596. it('preserves a competitor created after the guarded-create probe', async () => {
  597. const remote = new FakeRemote()
  598. remote.competitorBeforeLink = { path: '/workspace/race.txt', kind: 'file', data: 'competitor' }
  599. const { fs } = await setup(remote)
  600. await expectCode(
  601. fs.writeText(await fs.resolve('race.txt'), 'ours', { kind: 'createIfAbsent' }),
  602. 'FS_NOT_OBSERVED',
  603. )
  604. expect(new TextDecoder().decode(remote.nodes.get('/workspace/race.txt')?.data)).toBe('competitor')
  605. expect(remote.links).toHaveLength(0)
  606. expect(remote.removals).toHaveLength(1)
  607. })
  608. it('preserves a competing directory during guarded-create publication', async () => {
  609. const remote = new FakeRemote()
  610. remote.competitorBeforeLink = { path: '/workspace/race-dir', kind: 'directory' }
  611. const { fs } = await setup(remote)
  612. await expectCode(
  613. fs.writeText(await fs.resolve('race-dir'), 'ours', { kind: 'createIfAbsent' }),
  614. 'FS_NOT_OBSERVED',
  615. )
  616. expect(remote.nodes.get('/workspace/race-dir')?.type).toBe(FileType.DIR)
  617. expect(remote.nodes.has('/workspace/race-dir/content')).toBe(false)
  618. expect(remote.links).toHaveLength(0)
  619. expect(remote.removals).toHaveLength(1)
  620. })
  621. it('rejects an invalid guarded-create publication response before claiming success', async () => {
  622. const remote = new FakeRemote()
  623. remote.guardedLinkOutput = 'unexpected'
  624. const { fs } = await setup(remote)
  625. await expectCode(
  626. fs.writeText(await fs.resolve('invalid.txt'), 'ours', { kind: 'createIfAbsent' }),
  627. 'FS_IO_ERROR',
  628. )
  629. expect(remote.nodes.has('/workspace/invalid.txt')).toBe(false)
  630. expect(remote.removals).toHaveLength(1)
  631. })
  632. it('does not turn an abort observed after a successful move into a failed write', async () => {
  633. const remote = new FakeRemote()
  634. const controller = new AbortController()
  635. remote.abortAfterRename = controller
  636. const { fs } = await setup(remote)
  637. await expect(fs.writeText(await fs.resolve('committed'), 'yes', undefined, controller.signal))
  638. .resolves.toMatchObject({ operation: 'create' })
  639. expect(controller.signal.aborted).toBe(true)
  640. })
  641. it('does not turn an abort observed after a guarded create into a failed write', async () => {
  642. const remote = new FakeRemote()
  643. const controller = new AbortController()
  644. remote.abortAfterRename = controller
  645. const { fs } = await setup(remote)
  646. await expect(fs.writeText(
  647. await fs.resolve('committed-create'),
  648. 'yes',
  649. { kind: 'createIfAbsent' },
  650. controller.signal,
  651. )).resolves.toMatchObject({ operation: 'create' })
  652. expect(controller.signal.aborted).toBe(true)
  653. })
  654. it('does not turn post-commit staging cleanup failure into a failed write', async () => {
  655. const remote = new FakeRemote()
  656. remote.nextRemoveError = new Error('empty staging cleanup failed')
  657. const { fs } = await setup(remote)
  658. await expect(fs.writeText(await fs.resolve('committed'), 'yes'))
  659. .resolves.toMatchObject({ operation: 'create' })
  660. expect(new TextDecoder().decode(remote.nodes.get('/workspace/committed')?.data)).toBe('yes')
  661. })
  662. it('returns committed rename metadata without a fallible post-commit lookup', async () => {
  663. const remote = new FakeRemote()
  664. const getInfo = vi.spyOn(remote.sandbox.files, 'getInfo')
  665. const { fs } = await setup(remote)
  666. await expect(fs.writeText(await fs.resolve('committed'), 'yes'))
  667. .resolves.toMatchObject({ operation: 'create' })
  668. expect(getInfo).toHaveBeenCalledTimes(1)
  669. expect(remote.renames).toHaveLength(1)
  670. })
  671. it('cleans staging files and maps command, permission, and abort failures', async () => {
  672. const remote = new FakeRemote()
  673. const { fs } = await setup(remote)
  674. const commandTarget = await fs.resolve('command')
  675. remote.nextCommandError = commandError(1, 'chmod failed')
  676. await expectCode(fs.writeText(commandTarget, 'x'), 'FS_IO_ERROR')
  677. expect(remote.removals).toHaveLength(1)
  678. remote.nextRenameError = new Error('permission denied')
  679. await expectCode(fs.writeText(await fs.resolve('permission'), 'x'), 'FS_PERMISSION_DENIED')
  680. remote.nextRemoveError = new Error('cleanup also failed')
  681. remote.nextRenameError = new DOMException('aborted', 'AbortError')
  682. await expectCode(fs.writeText(await fs.resolve('abort'), 'x'), 'FS_ABORTED')
  683. const removalsBeforeCollision = remote.removals.length
  684. remote.nextMakeDirResult = false
  685. await expectCode(fs.writeText(await fs.resolve('collision'), 'x'), 'FS_IO_ERROR')
  686. expect(remote.removals).toHaveLength(removalsBeforeCollision)
  687. })
  688. it('applies literal edits atomically and restores the detected CRLF style', async () => {
  689. const remote = new FakeRemote()
  690. remote.file('/workspace/file.txt', 'one\r\ntwo\r\nthree\n')
  691. const { fs } = await setup(remote)
  692. const target = await fs.resolve('file.txt')
  693. const version = (await fs.stat(target))!.version
  694. const outcome = await fs.editText(
  695. target,
  696. { oldString: 'two\r\n', newString: 'TWO\r\n', replaceAll: false },
  697. { version },
  698. )
  699. expect(outcome).toMatchObject({ before: 'one\ntwo\nthree\n', after: 'one\nTWO\nthree\n' })
  700. expect(new TextDecoder().decode(remote.nodes.get('/workspace/file.txt')?.data)).toBe('one\r\nTWO\r\nthree\r\n')
  701. })
  702. it('reports stale and literal-match failures with stable codes', async () => {
  703. const remote = new FakeRemote()
  704. remote.file('/workspace/file.txt', 'a a')
  705. remote.dir('/workspace/dir')
  706. const { fs } = await setup(remote)
  707. const target = await fs.resolve('file.txt')
  708. await expectCode(fs.editText(target, { oldString: '', newString: 'x', replaceAll: false }), 'FS_EDIT_NOT_FOUND')
  709. await expectCode(fs.editText(target, { oldString: 'z', newString: 'x', replaceAll: false }), 'FS_EDIT_NOT_FOUND')
  710. await expectCode(fs.editText(target, { oldString: 'a', newString: 'x', replaceAll: false }), 'FS_AMBIGUOUS_EDIT')
  711. await expect(fs.editText(target, { oldString: 'a', newString: 'x', replaceAll: true }))
  712. .resolves.toMatchObject({ after: 'x x' })
  713. await expectCode(fs.editText(target, { oldString: 'x', newString: 'y', replaceAll: false }, { version: FsVersion('stale') }), 'FS_STALE_VERSION')
  714. await expectCode(fs.editText(await fs.resolve('missing'), { oldString: 'x', newString: 'y', replaceAll: false }), 'FS_STALE_VERSION')
  715. await expectCode(fs.editText(await fs.resolve('dir'), { oldString: 'x', newString: 'y', replaceAll: false }), 'FS_NOT_REGULAR_FILE')
  716. })
  717. it('serializes guarded mutations so only one stale version can win', async () => {
  718. const remote = new FakeRemote()
  719. remote.file('/workspace/file.txt', 'base')
  720. const { fs } = await setup(remote)
  721. const target = await fs.resolve('file.txt')
  722. const version = (await fs.stat(target))!.version
  723. const results = await Promise.allSettled([
  724. fs.writeText(target, 'one', { kind: 'replaceIfVersion', version }),
  725. fs.editText(target, { oldString: 'base', newString: 'two', replaceAll: false }, { version }),
  726. ])
  727. expect(results.filter(result => result.status === 'fulfilled')).toHaveLength(1)
  728. expect(results.filter(result => result.status === 'rejected')).toHaveLength(1)
  729. })
  730. })
  731. describe('E2B filesystem adapter integration edges', () => {
  732. it('maps canonicalization, permission, and generic provider failures', async () => {
  733. const remote = new FakeRemote()
  734. const { fs } = await setup(remote)
  735. remote.nextCommandError = commandError(1, 'not a directory')
  736. await expectCode(fs.resolve('bad'), 'FS_IO_ERROR')
  737. remote.nextCommandError = commandError(1)
  738. await expectCode(fs.resolve('bad-again'), 'FS_IO_ERROR')
  739. remote.nextCommandError = new Error('canonical transport failed')
  740. await expectCode(fs.resolve('bad-transport'), 'FS_IO_ERROR')
  741. remote.file('/workspace/a', 'a')
  742. const target = await fs.resolve('a')
  743. remote.nextInfoError = new Error('metadata transport failed')
  744. await expectCode(fs.stat(target), 'FS_IO_ERROR')
  745. remote.nextReadError = new Error('operation not permitted')
  746. await expectCode(fs.readText(target), 'FS_PERMISSION_DENIED')
  747. remote.nextReadError = 'transport vanished'
  748. await expectCode(fs.readText(target), 'FS_IO_ERROR')
  749. })
  750. it('uses listing metadata directly and canonicalizes only symbolic links', async () => {
  751. const remote = new FakeRemote()
  752. remote.file('/workspace/a', 'a')
  753. remote.file('/workspace/target', 'target')
  754. remote.file('/workspace/gone', 'gone')
  755. remote.symlink('/workspace/link', '/workspace/target')
  756. remote.symlink('/workspace/vanished-link', '/workspace/gone')
  757. remote.disappearOnInfo.add('/workspace/gone')
  758. const { fs } = await setup(remote)
  759. const directory = await fs.resolve('/workspace')
  760. const commandsBefore = remote.commands.length
  761. const getInfo = vi.spyOn(remote.sandbox.files, 'getInfo')
  762. const listed = await fs.listDir(directory)
  763. expect(listed.find(entry => entry.name === 'a')).toMatchObject({
  764. type: 'file', target: { targetKey: '/workspace/a' }, size: 1,
  765. })
  766. expect(listed.find(entry => entry.name === 'link')).toMatchObject({
  767. type: 'file', target: { targetKey: '/workspace/target' }, size: 6,
  768. })
  769. expect(listed.find(entry => entry.name === 'vanished-link')).toEqual({
  770. name: 'vanished-link',
  771. type: 'other',
  772. target: { targetKey: '/workspace/gone', displayPath: '/workspace/vanished-link' },
  773. })
  774. expect(remote.commands.slice(commandsBefore)).toHaveLength(2)
  775. expect(getInfo).toHaveBeenCalledTimes(3)
  776. })
  777. })