index.ts 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628
  1. /**
  2. * E2B provider for the filesystem capability seam. Paths, contents, and
  3. * atomic staging files remain inside the shared remote sandbox.
  4. * @module @deepseek-ai/dsh-fs-e2b
  5. */
  6. import { createHash, randomUUID } from 'node:crypto'
  7. import { Buffer } from 'node:buffer'
  8. import { posix } from 'node:path'
  9. import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs'
  10. import type {
  11. FsDirEntry,
  12. FsEditOutcome,
  13. FsEditRequest,
  14. FsInfo,
  15. FsPathInfo,
  16. FsTarget,
  17. FsWriteIntent,
  18. FsWriteOutcome,
  19. } from '@deepseek-ai/dsh-fs'
  20. import {
  21. CommandExitError,
  22. e2bControlEnvs,
  23. FileNotFoundError,
  24. FileType,
  25. quoteE2BShellArg,
  26. } from '@deepseek-ai/dsh-e2b'
  27. import type { EntryInfo, Sandbox } from '@deepseek-ai/dsh-e2b'
  28. const VERSION_METADATA_KEY = 'dsh-version'
  29. const BINARY_SAMPLE_BYTES = 8192
  30. const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
  31. function assertNotAborted(signal: AbortSignal | undefined, operation: string): void {
  32. if (signal?.aborted === true) throw new FsError(`${operation} aborted`, 'FS_ABORTED')
  33. }
  34. function normalizeLineEndings(value: string): string {
  35. return value.replaceAll('\r\n', '\n')
  36. }
  37. function detectsCrlf(value: string): boolean {
  38. const sample = value.slice(0, 4096)
  39. const crlf = sample.split('\r\n').length - 1
  40. const lf = sample.split('\n').length - 1 - crlf
  41. return crlf > lf
  42. }
  43. function restoreLineEndings(value: string, crlf: boolean): string {
  44. return crlf ? normalizeLineEndings(value).replaceAll('\n', '\r\n') : value
  45. }
  46. function decodeText(bytes: Uint8Array, displayPath: string, binarySampleBytes: number): string {
  47. if (bytes.subarray(0, binarySampleBytes).includes(0)) {
  48. throw new FsError(`cannot read "${displayPath}": binary file`, 'FS_NOT_TEXT')
  49. }
  50. try {
  51. return new TextDecoder('utf-8', { fatal: true }).decode(bytes)
  52. } catch (error: unknown) {
  53. throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT', { cause: error })
  54. }
  55. }
  56. function decodeCanonicalPath(encoded: string): string {
  57. if (encoded.length === 0 || !BASE64.test(encoded)) {
  58. throw new Error('fs-e2b: canonical path transport returned invalid base64')
  59. }
  60. const framed = Buffer.from(encoded, 'base64')
  61. if (framed.toString('base64') !== encoded
  62. || framed.length < 2
  63. || framed.at(-1) !== 0
  64. || framed.subarray(0, -1).includes(0)) {
  65. throw new Error('fs-e2b: canonical path transport returned invalid NUL framing')
  66. }
  67. let path: string
  68. try {
  69. path = new TextDecoder('utf-8', { fatal: true }).decode(framed.subarray(0, -1))
  70. } catch (error: unknown) {
  71. throw new Error('fs-e2b: canonical path is not valid UTF-8', { cause: error })
  72. }
  73. if (!posix.isAbsolute(path)) throw new Error('fs-e2b: canonical path is not absolute')
  74. return path
  75. }
  76. function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } {
  77. return signal === undefined ? {} : { signal }
  78. }
  79. function commandOpts(signal: AbortSignal | undefined): { envs: Record<string, string>; signal?: AbortSignal } {
  80. return { envs: e2bControlEnvs(), ...signalOpts(signal) }
  81. }
  82. async function openReadStream(
  83. sandbox: Sandbox,
  84. target: FsTarget,
  85. signal: AbortSignal | undefined,
  86. ): Promise<ReadableStream<Uint8Array>> {
  87. try {
  88. // The pinned SDK's stream overload lies for empty files: content-length 0
  89. // returns '' instead of a ReadableStream.
  90. const read = await sandbox.files.read(String(target.targetKey), { format: 'stream', ...signalOpts(signal) }) as
  91. ReadableStream<Uint8Array> | string
  92. return typeof read === 'string'
  93. ? new ReadableStream<Uint8Array>({ start(controller) { controller.close() } })
  94. : read
  95. } catch (error: unknown) {
  96. throw mapError(error, 'read', target.displayPath, signal)
  97. }
  98. }
  99. function entryType(entry: EntryInfo): FsInfo['type'] {
  100. switch (entry.type) {
  101. case FileType.FILE:
  102. return 'file'
  103. case FileType.DIR:
  104. return 'directory'
  105. default:
  106. return 'other'
  107. }
  108. }
  109. function entryVersion(entry: EntryInfo): ReturnType<typeof FsVersion> {
  110. const facts = JSON.stringify([
  111. entry.metadata?.[VERSION_METADATA_KEY],
  112. entry.path,
  113. entry.type,
  114. entry.size,
  115. entry.mode,
  116. entry.modifiedTime?.toISOString(),
  117. entry.symlinkTarget,
  118. ])
  119. return FsVersion(`e2b:${createHash('sha256').update(facts).digest('hex')}`)
  120. }
  121. function mapError(error: unknown, operation: string, displayPath: string, signal?: AbortSignal): FsError {
  122. if (error instanceof FsError) return error
  123. if (signal?.aborted === true || (error instanceof DOMException && error.name === 'AbortError')) {
  124. return new FsError(`${operation} aborted`, 'FS_ABORTED', { cause: error })
  125. }
  126. if (error instanceof FileNotFoundError) {
  127. return new FsError(`cannot ${operation} "${displayPath}": not found`, 'FS_NOT_FOUND', { cause: error })
  128. }
  129. if (/permission denied|operation not permitted/i.test(String(error))) {
  130. return new FsError(`cannot ${operation} "${displayPath}": permission denied`, 'FS_PERMISSION_DENIED', { cause: error })
  131. }
  132. return new FsError(`cannot ${operation} "${displayPath}": ${String(error)}`, 'FS_IO_ERROR', { cause: error })
  133. }
  134. function literalEdit(content: string, request: FsEditRequest, displayPath: string): string {
  135. const oldString = normalizeLineEndings(request.oldString)
  136. const newString = normalizeLineEndings(request.newString)
  137. if (oldString.length === 0) {
  138. throw new FsError(`cannot edit "${displayPath}": old_string must be non-empty`, 'FS_EDIT_NOT_FOUND')
  139. }
  140. let matches = 0
  141. let offset = 0
  142. while (true) {
  143. const found = content.indexOf(oldString, offset)
  144. if (found < 0) break
  145. matches += 1
  146. offset = found + oldString.length
  147. }
  148. if (matches === 0) throw new FsError(`cannot edit "${displayPath}": old_string was not found`, 'FS_EDIT_NOT_FOUND')
  149. if (!request.replaceAll && matches !== 1) {
  150. throw new FsError(`cannot edit "${displayPath}": old_string matched ${matches} times`, 'FS_AMBIGUOUS_EDIT')
  151. }
  152. return request.replaceAll ? content.split(oldString).join(newString) : content.replace(oldString, newString)
  153. }
  154. /** Remote filesystem backend sharing the sandbox owned by `ctx.e2b`. */
  155. export class E2BFileSystem extends FileSystem {
  156. static inject = ['e2b']
  157. private readonly locks = new Map<string, Promise<unknown>>()
  158. override async resolve(path: string, opts?: { cwd?: string; signal?: AbortSignal }): Promise<FsTarget> {
  159. assertNotAborted(opts?.signal, 'resolve')
  160. if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
  161. const displayPath = posix.resolve(opts?.cwd ?? this.ctx.e2b.cwd, path)
  162. try {
  163. const sandbox = await this.ctx.e2b.getSandbox()
  164. const targetKey = await this.canonicalPath(sandbox, displayPath, opts?.signal)
  165. assertNotAborted(opts?.signal, 'resolve')
  166. return { targetKey: FsTargetKey(targetKey), displayPath }
  167. } catch (error: unknown) {
  168. throw mapError(error, 'resolve', displayPath, opts?.signal)
  169. }
  170. }
  171. override processPath(target: FsTarget): string {
  172. return String(target.targetKey)
  173. }
  174. override fileUrl(target: FsTarget): string {
  175. const path = this.processPath(target)
  176. if (!posix.isAbsolute(path)) throw new Error(`fs-e2b: expected an absolute process path: ${JSON.stringify(path)}`)
  177. return `file://${path.split('/').map(segment => encodeURIComponent(segment)).join('/')}`
  178. }
  179. override contains(parent: FsTarget, child: FsTarget): boolean {
  180. const relative = posix.relative(this.processPath(parent), this.processPath(child))
  181. return relative === '' || (relative !== '..' && !relative.startsWith('../') && !posix.isAbsolute(relative))
  182. }
  183. override async stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined> {
  184. assertNotAborted(signal, 'stat')
  185. const entry = await this.probe(String(target.targetKey), target.displayPath, signal)
  186. if (entry === undefined) return undefined
  187. return {
  188. version: entryVersion(entry),
  189. type: entryType(entry),
  190. ...(entry.type === FileType.FILE ? { size: entry.size } : {}),
  191. }
  192. }
  193. override async lstat(path: string, opts?: { cwd?: string }, signal?: AbortSignal): Promise<FsPathInfo | undefined> {
  194. assertNotAborted(signal, 'lstat')
  195. if (path.trim().length === 0) throw new FsError('file_path must be a non-empty string', 'FS_NOT_FOUND')
  196. const displayPath = posix.resolve(opts?.cwd ?? this.ctx.e2b.cwd, path)
  197. const entry = await this.probe(displayPath, displayPath, signal)
  198. if (entry === undefined) return undefined
  199. const type = entry.symlinkTarget !== undefined
  200. ? 'symlink' as const
  201. : entry.type === FileType.FILE
  202. ? 'file' as const
  203. : entry.type === FileType.DIR
  204. ? 'directory' as const
  205. : 'other' as const
  206. return {
  207. version: entryVersion(entry),
  208. type,
  209. ...(entry.type === FileType.FILE ? { size: entry.size } : {}),
  210. }
  211. }
  212. override async readText(target: FsTarget, signal?: AbortSignal): Promise<string> {
  213. const sandbox = await this.ctx.e2b.getSandbox()
  214. await this.requireRegular(target, signal)
  215. try {
  216. const bytes = await sandbox.files.read(String(target.targetKey), { format: 'bytes', ...signalOpts(signal) })
  217. assertNotAborted(signal, 'read')
  218. return decodeText(bytes, target.displayPath, BINARY_SAMPLE_BYTES)
  219. } catch (error: unknown) {
  220. throw mapError(error, 'read', target.displayPath, signal)
  221. }
  222. }
  223. override async readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise<Uint8Array> {
  224. const sandbox = await this.ctx.e2b.getSandbox()
  225. const info = await this.requireRegular(target, signal)
  226. if (info.size !== undefined && info.size > maxBytes) {
  227. throw new FsError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE')
  228. }
  229. const stream = await openReadStream(sandbox, target, signal)
  230. const reader = stream.getReader()
  231. const chunks: Uint8Array[] = []
  232. let bytes = 0
  233. let completed = false
  234. try {
  235. while (true) {
  236. assertNotAborted(signal, 'read')
  237. const next = await reader.read()
  238. if (next.done) break
  239. // The stat preflight covers the at-rest case; this streamed bound stops
  240. // a post-stat grower without transferring past the first overflowing chunk.
  241. bytes += next.value.byteLength
  242. if (bytes > maxBytes) {
  243. throw new FsError(`cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE')
  244. }
  245. chunks.push(next.value)
  246. }
  247. completed = true
  248. } catch (error: unknown) {
  249. throw mapError(error, 'read', target.displayPath, signal)
  250. } finally {
  251. if (!completed) {
  252. try {
  253. await reader.cancel()
  254. } catch (_streamCancellationFailure) {
  255. // The read already failed; a cancellation failure on the abandoned
  256. // remote stream adds nothing actionable for the caller.
  257. }
  258. }
  259. reader.releaseLock()
  260. }
  261. const whole = new Uint8Array(bytes)
  262. let offset = 0
  263. for (const chunk of chunks) {
  264. whole.set(chunk, offset)
  265. offset += chunk.byteLength
  266. }
  267. return whole
  268. }
  269. override async readByteRange(target: FsTarget, range: { offset: number; length: number }, signal?: AbortSignal): Promise<Uint8Array> {
  270. const sandbox = await this.ctx.e2b.getSandbox()
  271. await this.requireRegular(target, signal)
  272. if (range.length === 0) return new Uint8Array(0)
  273. // The SDK streams only from the file's start: skip to `offset`, keep
  274. // `length` bytes, and cancel the stream there, so no more than the window
  275. // beyond the skipped prefix is ever transferred.
  276. const stream = await openReadStream(sandbox, target, signal)
  277. const reader = stream.getReader()
  278. const window = new Uint8Array(range.length)
  279. const end = range.offset + range.length
  280. let position = 0
  281. let filled = 0
  282. let drained = false
  283. try {
  284. while (filled < range.length) {
  285. assertNotAborted(signal, 'read')
  286. const next = await reader.read()
  287. if (next.done) {
  288. drained = true
  289. break
  290. }
  291. const from = Math.max(range.offset, position)
  292. const to = Math.min(end, position + next.value.byteLength)
  293. if (to > from) {
  294. window.set(next.value.subarray(from - position, to - position), filled)
  295. filled += to - from
  296. }
  297. position += next.value.byteLength
  298. }
  299. } catch (error: unknown) {
  300. throw mapError(error, 'read', target.displayPath, signal)
  301. } finally {
  302. if (!drained) {
  303. try {
  304. await reader.cancel()
  305. } catch (_streamCancellationFailure) {
  306. // The window is complete or the read already failed; a cancellation
  307. // failure on the abandoned remote stream adds nothing actionable.
  308. }
  309. }
  310. reader.releaseLock()
  311. }
  312. return filled === range.length ? window : window.subarray(0, filled)
  313. }
  314. override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
  315. const sandbox = await this.ctx.e2b.getSandbox()
  316. await this.requireRegular(target, signal)
  317. const stream = await openReadStream(sandbox, target, signal)
  318. const displayPath = target.displayPath
  319. return {
  320. async *[Symbol.asyncIterator](): AsyncGenerator<string> {
  321. const reader = stream.getReader()
  322. const decoder = new TextDecoder('utf-8', { fatal: true })
  323. let sampledBytes = 0
  324. let completed = false
  325. try {
  326. while (true) {
  327. assertNotAborted(signal, 'read')
  328. const next = await reader.read()
  329. if (next.done) break
  330. if (sampledBytes < BINARY_SAMPLE_BYTES) {
  331. const sample = next.value.subarray(0, BINARY_SAMPLE_BYTES - sampledBytes)
  332. if (sample.includes(0)) throw new FsError(`cannot read "${displayPath}": binary file`, 'FS_NOT_TEXT')
  333. sampledBytes += sample.length
  334. }
  335. let text: string
  336. try {
  337. text = decoder.decode(next.value, { stream: true })
  338. } catch (error: unknown) {
  339. throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT', { cause: error })
  340. }
  341. if (text.length > 0) yield text
  342. }
  343. try {
  344. decoder.decode()
  345. } catch (error: unknown) {
  346. throw new FsError(`cannot read "${displayPath}": invalid UTF-8 text`, 'FS_NOT_TEXT', { cause: error })
  347. }
  348. completed = true
  349. } catch (error: unknown) {
  350. throw mapError(error, 'read', displayPath, signal)
  351. } finally {
  352. if (!completed) {
  353. try {
  354. await reader.cancel()
  355. } catch (_streamCancellationFailure) {
  356. // The primary read outcome owns the result; cancellation is best-effort after early stop.
  357. }
  358. }
  359. reader.releaseLock()
  360. }
  361. },
  362. }
  363. }
  364. override async listDir(target: FsTarget, signal?: AbortSignal): Promise<FsDirEntry[]> {
  365. const info = await this.stat(target, signal)
  366. if (info === undefined) throw new FsError(`cannot list "${target.displayPath}": not found`, 'FS_NOT_FOUND')
  367. if (info.type !== 'directory') throw new FsError(`cannot list "${target.displayPath}": not a directory`, 'FS_NOT_DIRECTORY')
  368. try {
  369. const sandbox = await this.ctx.e2b.getSandbox()
  370. const listed = await sandbox.files.list(String(target.targetKey), { depth: 1, ...signalOpts(signal) })
  371. const entries: FsDirEntry[] = []
  372. for (const entry of listed) {
  373. const displayPath = posix.join(target.displayPath, entry.name)
  374. const canonical = entry.symlinkTarget === undefined
  375. ? entry.path
  376. : await this.canonicalPath(sandbox, entry.path, signal)
  377. const resolved = entry.symlinkTarget === undefined
  378. ? entry
  379. : await this.probe(canonical, displayPath, signal)
  380. entries.push({
  381. name: entry.name,
  382. type: resolved === undefined ? 'other' : entryType(resolved),
  383. target: { targetKey: FsTargetKey(canonical), displayPath },
  384. ...(resolved !== undefined ? { version: entryVersion(resolved) } : {}),
  385. ...(resolved?.type === FileType.FILE ? { size: resolved.size } : {}),
  386. })
  387. }
  388. return entries.sort((left, right) => left.name.localeCompare(right.name))
  389. } catch (error: unknown) {
  390. throw mapError(error, 'list', target.displayPath, signal)
  391. }
  392. }
  393. override async writeText(
  394. target: FsTarget,
  395. content: string,
  396. expected?: FsWriteIntent,
  397. signal?: AbortSignal,
  398. ): Promise<FsWriteOutcome> {
  399. return this.withLock(String(target.targetKey), async () => {
  400. const existing = await this.probe(String(target.targetKey), target.displayPath, signal)
  401. if (existing !== undefined && entryType(existing) !== 'file') {
  402. throw new FsError(`cannot write "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
  403. }
  404. this.checkWriteIntent(existing, expected, target)
  405. const before = existing === undefined ? null : await this.readForDiff(target, signal)
  406. const version = await this.writeAtomic(
  407. target,
  408. content,
  409. existing,
  410. expected?.kind === 'createIfAbsent',
  411. signal,
  412. )
  413. return {
  414. operation: existing === undefined ? 'create' : 'update',
  415. version,
  416. before,
  417. after: normalizeLineEndings(content),
  418. }
  419. })
  420. }
  421. override async editText(
  422. target: FsTarget,
  423. edit: FsEditRequest,
  424. expected?: { version: ReturnType<typeof FsVersion> },
  425. signal?: AbortSignal,
  426. ): Promise<FsEditOutcome> {
  427. return this.withLock(String(target.targetKey), async () => {
  428. const existing = await this.probe(String(target.targetKey), target.displayPath, signal)
  429. if (existing === undefined) {
  430. throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
  431. }
  432. if (entryType(existing) !== 'file') {
  433. throw new FsError(`cannot edit "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
  434. }
  435. if (expected !== undefined && entryVersion(existing) !== expected.version) {
  436. throw new FsError(`cannot edit "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
  437. }
  438. const raw = await this.readForEdit(target, signal)
  439. const before = normalizeLineEndings(raw)
  440. const after = literalEdit(before, edit, target.displayPath)
  441. const storage = restoreLineEndings(after, detectsCrlf(raw))
  442. const version = await this.writeAtomic(target, storage, existing, false, signal)
  443. return { version, before, after }
  444. })
  445. }
  446. private async withLock<T>(targetKey: string, operation: () => Promise<T>): Promise<T> {
  447. const prior = this.locks.get(targetKey) ?? Promise.resolve()
  448. const run = prior.then(operation, operation)
  449. const tail = run.then(() => undefined, () => undefined)
  450. this.locks.set(targetKey, tail)
  451. try {
  452. return await run
  453. } finally {
  454. if (this.locks.get(targetKey) === tail) this.locks.delete(targetKey)
  455. }
  456. }
  457. private async canonicalPath(sandbox: Sandbox, path: string, signal?: AbortSignal): Promise<string> {
  458. try {
  459. const result = await sandbox.commands.run(
  460. `set -o pipefail; realpath -mz -- ${quoteE2BShellArg(path)} | base64 -w0`,
  461. commandOpts(signal),
  462. )
  463. return decodeCanonicalPath(result.stdout)
  464. } catch (error: unknown) {
  465. if (error instanceof CommandExitError) throw new Error(error.stderr || error.message, { cause: error })
  466. throw error
  467. }
  468. }
  469. private async probe(path: string, displayPath: string, signal?: AbortSignal): Promise<EntryInfo | undefined> {
  470. assertNotAborted(signal, 'stat')
  471. try {
  472. const sandbox = await this.ctx.e2b.getSandbox()
  473. const entry = await sandbox.files.getInfo(path, signalOpts(signal))
  474. assertNotAborted(signal, 'stat')
  475. return entry
  476. } catch (error: unknown) {
  477. if (error instanceof FileNotFoundError) return undefined
  478. throw mapError(error, 'stat', displayPath, signal)
  479. }
  480. }
  481. private async requireRegular(target: FsTarget, signal?: AbortSignal): Promise<FsInfo> {
  482. const info = await this.stat(target, signal)
  483. if (info === undefined) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
  484. if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
  485. return info
  486. }
  487. private checkWriteIntent(existing: EntryInfo | undefined, expected: FsWriteIntent | undefined, target: FsTarget): void {
  488. if (expected?.kind === 'createIfAbsent' && existing !== undefined) {
  489. throw new FsError(`cannot overwrite existing "${target.displayPath}" without reading it first`, 'FS_NOT_OBSERVED')
  490. }
  491. if (expected?.kind === 'replaceIfVersion') {
  492. if (existing === undefined || entryVersion(existing) !== expected.version) {
  493. throw new FsError(`cannot write "${target.displayPath}": file changed since it was read`, 'FS_STALE_VERSION')
  494. }
  495. }
  496. }
  497. private async readForDiff(target: FsTarget, signal?: AbortSignal): Promise<string | null> {
  498. try {
  499. const sandbox = await this.ctx.e2b.getSandbox()
  500. const bytes = await sandbox.files.read(String(target.targetKey), { format: 'bytes', ...signalOpts(signal) })
  501. assertNotAborted(signal, 'read')
  502. return normalizeLineEndings(decodeText(bytes, target.displayPath, bytes.length))
  503. } catch (error: unknown) {
  504. if (error instanceof FsError && error.code === 'FS_NOT_TEXT') return null
  505. throw mapError(error, 'read', target.displayPath, signal)
  506. }
  507. }
  508. private async readForEdit(target: FsTarget, signal?: AbortSignal): Promise<string> {
  509. try {
  510. const sandbox = await this.ctx.e2b.getSandbox()
  511. const bytes = await sandbox.files.read(String(target.targetKey), { format: 'bytes', ...signalOpts(signal) })
  512. assertNotAborted(signal, 'edit')
  513. return decodeText(bytes, target.displayPath, bytes.length)
  514. } catch (error: unknown) {
  515. throw mapError(error, 'edit', target.displayPath, signal)
  516. }
  517. }
  518. private async writeAtomic(
  519. target: FsTarget,
  520. content: string,
  521. existing: EntryInfo | undefined,
  522. createIfAbsent: boolean,
  523. signal?: AbortSignal,
  524. ): Promise<ReturnType<typeof FsVersion>> {
  525. assertNotAborted(signal, 'write')
  526. const sandbox = await this.ctx.e2b.getSandbox()
  527. const targetPath = String(target.targetKey)
  528. const versionId = randomUUID()
  529. const stagingDirectory = posix.join(posix.dirname(targetPath), `.dsh-${randomUUID()}.tmp`)
  530. const temporary = posix.join(stagingDirectory, 'content')
  531. let stagingDirectoryCreated = false
  532. try {
  533. const created = await sandbox.files.makeDir(stagingDirectory, signalOpts(signal))
  534. if (!created) throw new Error('private staging directory already exists')
  535. stagingDirectoryCreated = true
  536. await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(stagingDirectory)}`, commandOpts(signal))
  537. assertNotAborted(signal, 'write')
  538. await sandbox.files.write(temporary, content, {
  539. metadata: { [VERSION_METADATA_KEY]: versionId },
  540. ...signalOpts(signal),
  541. })
  542. assertNotAborted(signal, 'write')
  543. const mode = existing === undefined ? 0o600 : existing.mode & 0o777
  544. await sandbox.commands.run(
  545. `chmod ${mode.toString(8)} -- ${quoteE2BShellArg(temporary)}`,
  546. commandOpts(signal),
  547. )
  548. assertNotAborted(signal, 'write')
  549. let committed: EntryInfo
  550. if (createIfAbsent) {
  551. const staged = await sandbox.files.getInfo(temporary, signalOpts(signal))
  552. assertNotAborted(signal, 'write')
  553. const targetArg = quoteE2BShellArg(targetPath)
  554. const publication = await sandbox.commands.run(
  555. `if ln -T -- ${quoteE2BShellArg(temporary)} ${targetArg}; then printf created; elif test -e ${targetArg} || test -L ${targetArg}; then printf exists; else exit 1; fi`,
  556. commandOpts(undefined),
  557. )
  558. if (publication.stdout === 'exists') {
  559. throw new FsError(
  560. `cannot overwrite existing "${target.displayPath}" without reading it first`,
  561. 'FS_NOT_OBSERVED',
  562. )
  563. }
  564. if (publication.stdout !== 'created') {
  565. throw new Error('guarded create returned an invalid publication result')
  566. }
  567. committed = { ...staged, name: posix.basename(targetPath), path: targetPath }
  568. } else {
  569. committed = await sandbox.files.rename(temporary, targetPath)
  570. }
  571. try {
  572. await sandbox.files.remove(stagingDirectory)
  573. } catch (_committedStagingCleanupFailure) {
  574. // The target is already committed; an empty private directory cannot turn that write into a failure.
  575. }
  576. return entryVersion(committed)
  577. } catch (error: unknown) {
  578. if (stagingDirectoryCreated) {
  579. try {
  580. await sandbox.files.remove(stagingDirectory)
  581. } catch (_stagingDirectoryAlreadyAbsentOrCleanupFailed) {
  582. // Only the private staging directory is swallowed; the original failure owns the operation.
  583. }
  584. }
  585. throw mapError(error, 'write', target.displayPath, signal)
  586. }
  587. }
  588. }
  589. export default E2BFileSystem