fix(e2b): close adapter lifecycle review gaps
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/e2b/fs-e2b/README.md
|
||||
README.md: 86ad8720d3e4c7ce70ee0ac8c41713af03ed297a
|
||||
README.zh.md: 90dff57a5b9fe786aea3315633125536277ecc20
|
||||
README.md: 8abf130ce16e2b79cda5fc858182159442f9e3c3
|
||||
README.zh.md: 943d9603046ad9b2a65f41d5801cac882040a0ff
|
||||
|
||||
@@ -10,7 +10,7 @@ E2B implementation of the [`@deepseek-ai/dsh-fs`](../../fs/fs/README.md) provide
|
||||
- **Execution-world paths** — canonical targets expose absolute POSIX process paths, percent-encoded `file:` URIs, and provider-owned containment checks, so generic subprocess consumers never parse E2B target ids or apply host path rules.
|
||||
- **UTF-8 reads** — whole reads and streamed reads preserve cross-chunk decoding, reject invalid UTF-8, and use the seam's 8192-byte NUL sample for binary detection. The model-facing tool still owns size selection and line windowing.
|
||||
- **Stable bounded reads** — a dependency-free Node helper walks directory descriptors with no-follow opens and reads one held regular-file descriptor through the byte cap. Generic LSP queries therefore reject parent swaps, non-files, invalid UTF-8, and growth past the configured document limit before server startup.
|
||||
- **Atomic mutations** — writes upload a mode-`0600` temporary sibling, preserve an existing file's POSIX mode, and publish through E2B's same-directory atomic rename. The rename response supplies the committed version, so no fallible metadata request follows the commit point. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process. Optional create/version guards keep the base seam's observed-state semantics.
|
||||
- **Atomic mutations** — writes create a random sibling staging directory, change it to mode `0700` before uploading content, preserve an existing file's POSIX mode, and publish the staged file through E2B's same-filesystem atomic rename. The rename response supplies the committed version, so no fallible metadata request follows the commit point. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process. Optional create/version guards keep the base seam's observed-state semantics.
|
||||
- **Failures and cancellation** — E2B not-found, permission, abort, and other controller failures map to the existing `FsError` vocabulary. Cancellation is best-effort at SDK request boundaries; a successful rename is the commit point.
|
||||
|
||||
The provider does not copy, mount, or reconcile the host workspace. Giving it a host path as `cwd` creates a remote directory with the same spelling only.
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
- **执行世界路径**:规范化目标公开绝对 POSIX 进程路径、百分号编码的 `file:` URI,以及由提供方负责的包含关系检查,因此通用进程管理消费方无需解析 E2B 目标 ID,也不会套用宿主路径规则。
|
||||
- **UTF-8 读取**:完整读取和流式读取会保留跨分片解码、拒绝无效 UTF-8,并使用 seam 的 8192 字节 NUL 样本检测二进制内容。面向模型的工具仍负责选择大小和行窗口。
|
||||
- **稳定的有界读取**:一个零依赖 Node 辅助程序会以不跟随链接的方式逐级打开目录描述符,并通过一个持续持有的常规文件描述符读取至字节上限。因此,通用 LSP 查询会在服务器启动前拒绝父目录交换、非文件、无效 UTF-8,以及增长后超出所配置文档上限的文件。
|
||||
- **原子变更**:写入会上传 mode 为 `0600` 的同级临时文件,保留现有文件的 POSIX mode,并通过 E2B 的同目录原子重命名发布。重命名响应会提供已提交的版本,因此提交点之后不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF,存储时恢复占主导的 CRLF,并在宿主进程内按规范化目标串行执行变更。可选的创建/版本防护会保留基础 seam 的已观察状态语义。
|
||||
- **原子变更**:写入会创建随机的同级暂存目录,在上传内容前将其 mode 改为 `0700`,保留现有文件的 POSIX mode,并通过 E2B 的同一文件系统原子重命名发布暂存文件。重命名响应会提供已提交的版本,因此提交点之后不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF,存储时恢复占主导的 CRLF,并在宿主进程内按规范化目标串行执行变更。可选的创建/版本防护会保留基础 seam 的已观察状态语义。
|
||||
- **失败与取消**:E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在 SDK 请求边界上采用尽力而为语义;成功 rename 是提交点。
|
||||
|
||||
该提供方不会复制、挂载或协调宿主工作区。把宿主路径用作 `cwd`,只会在远程创建一个拼写相同的目录。
|
||||
|
||||
@@ -498,8 +498,15 @@ export class E2BFileSystem extends FileSystem {
|
||||
const sandbox = await this.ctx.e2b.getSandbox()
|
||||
const targetPath = String(target.targetKey)
|
||||
const versionId = randomUUID()
|
||||
const temporary = posix.join(posix.dirname(targetPath), `.${posix.basename(targetPath)}.dsh-${randomUUID()}.tmp`)
|
||||
const stagingDirectory = posix.join(posix.dirname(targetPath), `.dsh-${randomUUID()}.tmp`)
|
||||
const temporary = posix.join(stagingDirectory, 'content')
|
||||
let stagingDirectoryCreated = false
|
||||
try {
|
||||
const created = await sandbox.files.makeDir(stagingDirectory, signalOpts(signal))
|
||||
if (!created) throw new Error('private staging directory already exists')
|
||||
stagingDirectoryCreated = true
|
||||
await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(stagingDirectory)}`, signalOpts(signal))
|
||||
assertNotAborted(signal, 'write')
|
||||
await sandbox.files.write(temporary, content, {
|
||||
metadata: { [VERSION_METADATA_KEY]: versionId },
|
||||
...signalOpts(signal),
|
||||
@@ -512,12 +519,19 @@ export class E2BFileSystem extends FileSystem {
|
||||
)
|
||||
assertNotAborted(signal, 'write')
|
||||
const committed = await sandbox.files.rename(temporary, targetPath, signalOpts(signal))
|
||||
try {
|
||||
await sandbox.files.remove(stagingDirectory)
|
||||
} catch (_committedStagingCleanupFailure) {
|
||||
// The target is already committed; an empty private directory cannot turn that write into a failure.
|
||||
}
|
||||
return entryVersion(committed)
|
||||
} catch (error: unknown) {
|
||||
try {
|
||||
await sandbox.files.remove(temporary)
|
||||
} catch (_temporaryAlreadyAbsent) {
|
||||
// Only the private staging path is swallowed; the original failure owns the operation.
|
||||
if (stagingDirectoryCreated) {
|
||||
try {
|
||||
await sandbox.files.remove(stagingDirectory)
|
||||
} catch (_stagingDirectoryAlreadyAbsentOrCleanupFailed) {
|
||||
// Only the private staging directory is swallowed; the original failure owns the operation.
|
||||
}
|
||||
}
|
||||
throw mapError(error, 'write', target.displayPath, signal)
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ function commandError(exitCode: number, stderr = ''): CommandExitError {
|
||||
class FakeRemote {
|
||||
readonly nodes = new Map<string, RemoteNode>()
|
||||
readonly writes: Array<{ path: string; data: string; metadata?: Record<string, string> }> = []
|
||||
readonly writeParentModes: number[] = []
|
||||
readonly renames: Array<{ from: string; to: string }> = []
|
||||
readonly removals: string[] = []
|
||||
readonly commands: string[] = []
|
||||
@@ -42,6 +43,7 @@ class FakeRemote {
|
||||
streamKeepOpen = false
|
||||
readonly streamCancel = vi.fn()
|
||||
nextCommandError: unknown
|
||||
nextMakeDirResult: boolean | undefined
|
||||
nextInfoError: unknown
|
||||
nextListError: unknown
|
||||
nextReadError: unknown
|
||||
@@ -130,7 +132,13 @@ class FakeRemote {
|
||||
readonly sandbox = {
|
||||
sandboxId: 'fake',
|
||||
files: {
|
||||
makeDir: async (path: string): Promise<boolean> => {
|
||||
makeDir: async (path: string, options?: { signal?: AbortSignal }): Promise<boolean> => {
|
||||
this.checkAbort(options)
|
||||
if (this.nextMakeDirResult !== undefined) {
|
||||
const result = this.nextMakeDirResult
|
||||
this.nextMakeDirResult = undefined
|
||||
return result
|
||||
}
|
||||
if (this.nodes.has(path)) return false
|
||||
this.dir(path)
|
||||
return true
|
||||
@@ -178,6 +186,7 @@ class FakeRemote {
|
||||
this.checkAbort(options)
|
||||
const parent = dirname(path)
|
||||
if (!this.nodes.has(parent)) this.dir(parent)
|
||||
this.writeParentModes.push(this.required(parent).mode)
|
||||
this.nodes.set(path, {
|
||||
type: FileType.FILE,
|
||||
data: bytes(data),
|
||||
@@ -209,7 +218,9 @@ class FakeRemote {
|
||||
this.nextRemoveError = undefined
|
||||
throw error
|
||||
}
|
||||
this.nodes.delete(path)
|
||||
for (const candidate of this.nodes.keys()) {
|
||||
if (candidate === path || candidate.startsWith(`${path}/`)) this.nodes.delete(candidate)
|
||||
}
|
||||
},
|
||||
},
|
||||
commands: {
|
||||
@@ -499,6 +510,10 @@ describe('E2BFileSystem atomic writes and edits', () => {
|
||||
expect(outcome).toMatchObject({ operation: 'create', before: null, after: 'one\ntwo\rthree' })
|
||||
expect(remote.nodes.get('/workspace/new.txt')?.mode).toBe(0o600)
|
||||
expect(remote.nodes.get('/workspace/new.txt')?.metadata?.['dsh-version']).toBeDefined()
|
||||
expect(remote.writeParentModes).toEqual([0o700])
|
||||
const stagingDirectory = posix.dirname(remote.writes[0]!.path)
|
||||
expect(posix.dirname(stagingDirectory)).toBe('/workspace')
|
||||
expect(remote.removals).toContain(stagingDirectory)
|
||||
await expect(fs.stat(target)).resolves.toMatchObject({ version: outcome.version, size: 14 })
|
||||
})
|
||||
|
||||
@@ -558,6 +573,15 @@ describe('E2BFileSystem atomic writes and edits', () => {
|
||||
expect(controller.signal.aborted).toBe(true)
|
||||
})
|
||||
|
||||
it('does not turn post-commit staging cleanup failure into a failed write', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.nextRemoveError = new Error('empty staging cleanup failed')
|
||||
const { fs } = await setup(remote)
|
||||
await expect(fs.writeText(await fs.resolve('committed'), 'yes'))
|
||||
.resolves.toMatchObject({ operation: 'create' })
|
||||
expect(new TextDecoder().decode(remote.nodes.get('/workspace/committed')?.data)).toBe('yes')
|
||||
})
|
||||
|
||||
it('returns committed rename metadata without a fallible post-commit lookup', async () => {
|
||||
const remote = new FakeRemote()
|
||||
const getInfo = vi.spyOn(remote.sandbox.files, 'getInfo')
|
||||
@@ -582,6 +606,11 @@ describe('E2BFileSystem atomic writes and edits', () => {
|
||||
remote.nextRemoveError = new Error('cleanup also failed')
|
||||
remote.nextRenameError = new DOMException('aborted', 'AbortError')
|
||||
await expectCode(fs.writeText(await fs.resolve('abort'), 'x'), 'FS_ABORTED')
|
||||
|
||||
const removalsBeforeCollision = remote.removals.length
|
||||
remote.nextMakeDirResult = false
|
||||
await expectCode(fs.writeText(await fs.resolve('collision'), 'x'), 'FS_IO_ERROR')
|
||||
expect(remote.removals).toHaveLength(removalsBeforeCollision)
|
||||
})
|
||||
|
||||
it('applies literal edits atomically and restores the detected CRLF style', async () => {
|
||||
|
||||
Reference in New Issue
Block a user