refactor(e2b): keep provider layer substrate-only
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: bb92b5785383e9703a382fddefcd1cff9b2644cb
|
||||
README.zh.md: 57ebfdbb92799660e74078fcd0affcc8d5bc120a
|
||||
README.md: 6827e16e9c45532590ee1aa986c18a353d175fdc
|
||||
README.zh.md: 21b067829ea95c4581d7e89f3d225f9c90e630ef
|
||||
|
||||
@@ -6,10 +6,9 @@ E2B implementation of the [`@deepseek-ai/dsh-fs`](../../fs/fs/README.md) provide
|
||||
|
||||
## Behavior
|
||||
|
||||
- **Remote identity and metadata** — relative paths resolve as POSIX paths against the caller cwd or `ctx.e2b.cwd`; `realpath -m` supplies canonical target identity without requiring the final file to exist. `stat`, no-follow `lstat`, and stable one-level directory listings project E2B metadata into the filesystem seam. Versions are opaque hashes of E2B metadata plus a per-write extended attribute.
|
||||
- **Remote identity and metadata** — relative paths resolve as POSIX paths against the caller cwd or `ctx.e2b.cwd`; GNU `realpath -mz` supplies canonical target identity without requiring the final file to exist, and ASCII/base64 plus strict NUL framing preserves newline and multibyte paths across the decoded SDK transport. `stat`, no-follow `lstat`, and stable one-level directory listings project E2B metadata into the filesystem seam; listings reuse returned metadata and resolve symbolic-link entries sequentially. Versions are opaque hashes of E2B metadata plus a per-write extended attribute.
|
||||
- **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 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 earlier SDK request boundaries and checked immediately before rename. The signal is not forwarded into the rename RPC, so cancellation cannot interrupt the atomic commit; a successful rename is the commit point.
|
||||
|
||||
@@ -27,5 +26,6 @@ No direct invalidation; the named consumer owns any request-prefix changes.
|
||||
|
||||
- **No host synchronization** — an empty E2B cwd stays empty until a tool, command, template, or external process populates it; local files are neither uploaded nor reflected back.
|
||||
- **Mutation coordination is host-process-local** — another harness connection or remote command can race the adapter; version guards detect only metadata changes represented by E2B.
|
||||
- **Reads reopen canonical targets by path** — a concurrent remote path replacement between resolution and stream opening is not fenced by a stable file handle; no observed product defect justifies a provider-specific bounded-read protocol in this POC.
|
||||
- **Whole-file mutation costs remain** — overwrite diffs and literal edits read complete files into host memory, and every operation incurs E2B controller latency.
|
||||
- **Custom templates must support the used Linux, Node, procfs, and envd features** — `realpath`, `chmod`, `mv`, same-filesystem POSIX rename, streaming reads, file metadata extended attributes, `/proc/self/fd`, and no-follow descriptor opens are required; unsupported templates fail rather than degrade silently.
|
||||
- **Custom templates must support the used Linux/GNU and E2B filesystem features** — `realpath -mz`, `base64 -w0`, `chmod`, same-filesystem rename, streaming reads, and file metadata extended attributes are required; unsupported templates fail rather than degrade silently.
|
||||
|
||||
@@ -6,10 +6,9 @@
|
||||
|
||||
## 行为
|
||||
|
||||
- **远程身份与元数据**:相对路径以调用方 cwd 或 `ctx.e2b.cwd` 为基准,按照 POSIX 路径解析;`realpath -m` 提供规范化目标身份,且不要求最终文件存在。`stat`、不跟随链接的 `lstat` 和稳定的单层目录列表会把 E2B 元数据投影到文件系统 seam。版本是 E2B 元数据与每次写入设置的扩展属性所组成的不透明哈希。
|
||||
- **远程身份与元数据**:相对路径以调用方 cwd 或 `ctx.e2b.cwd` 为基准,按照 POSIX 路径解析;GNU `realpath -mz` 提供规范化目标身份,且不要求最终文件存在;ASCII/base64 加严格 NUL 分帧会在已解码的 SDK 传输中保留含换行符和多字节字符的路径。`stat`、不跟随链接的 `lstat` 和稳定的单层目录列表会把 E2B 元数据投影到文件系统 seam;目录列表会复用已返回的元数据,并依次解析符号链接条目。版本是 E2B 元数据与每次写入设置的扩展属性所组成的不透明哈希。
|
||||
- **执行世界路径**:规范化目标公开绝对 POSIX 进程路径、百分号编码的 `file:` URI,以及由提供方负责的包含关系检查,因此通用进程管理消费方无需解析 E2B 目标 ID,也不会套用宿主路径规则。
|
||||
- **UTF-8 读取**:完整读取和流式读取会保留跨分片解码、拒绝无效 UTF-8,并使用 seam 的 8192 字节 NUL 样本检测二进制内容。面向模型的工具仍负责选择大小和行窗口。
|
||||
- **稳定的有界读取**:一个零依赖 Node 辅助程序会以不跟随链接的方式逐级打开目录描述符,并通过一个持续持有的常规文件描述符读取至字节上限。因此,通用 LSP 查询会在服务器启动前拒绝父目录交换、非文件、无效 UTF-8,以及增长后超出所配置文档上限的文件。
|
||||
- **原子变更**:写入会创建随机的同级暂存目录,在上传内容前将其 mode 改为 `0700`,保留现有文件的 POSIX mode,并通过 E2B 的同一文件系统原子重命名发布暂存文件。重命名响应会提供已提交的版本,因此提交点之后不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF,存储时恢复占主导的 CRLF,并在宿主进程内按规范化目标串行执行变更。可选的创建/版本防护会保留基础 seam 的已观察状态语义。
|
||||
- **失败与取消**:E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在更早的 SDK 请求边界上采用尽力而为语义,并在 rename 前立即检查。信号不会传入 rename RPC,因此取消无法中断原子提交;成功 rename 是提交点。
|
||||
|
||||
@@ -27,5 +26,6 @@
|
||||
|
||||
- **不提供宿主同步**:空的 E2B cwd 会一直为空,直到工具、命令、模板或外部进程填充它;本地文件既不会上传,也不会同步回本地。
|
||||
- **变更协调仅限宿主进程内**:另一个 harness 连接或远程命令可能与适配器发生竞态;版本防护只能检测 E2B 元数据所体现的变更。
|
||||
- **读取会按路径重新打开规范化目标**:在解析与打开流之间若并发替换远程路径,该操作没有稳定文件句柄提供围栏;在该 POC 中,没有已观察到的产品缺陷能够证明提供方专用的有界读取协议值得引入。
|
||||
- **仍需承担完整文件变更成本**:覆盖差异和字面量编辑会把完整文件读入宿主内存,每项操作也都会产生 E2B 控制器延迟。
|
||||
- **自定义模板必须支持所用的 Linux、Node、procfs 与 envd 功能**:必须支持 `realpath`、`chmod`、`mv`、同一文件系统内的 POSIX rename、流式读取、文件元数据扩展属性、`/proc/self/fd` 和不跟随链接的描述符打开操作;不支持的模板会失败,而不会静默降级。
|
||||
- **自定义模板必须支持所用的 Linux/GNU 与 E2B 文件系统功能**:必须支持 `realpath -mz`、`base64 -w0`、`chmod`、同一文件系统内的 rename、流式读取和文件元数据扩展属性;不支持的模板会失败,而不会静默降级。
|
||||
|
||||
@@ -26,17 +26,10 @@ import {
|
||||
quoteE2BShellArg,
|
||||
} from '@deepseek-ai/dsh-e2b'
|
||||
import type { EntryInfo, Sandbox } from '@deepseek-ai/dsh-e2b'
|
||||
import { BOUNDED_READER_SOURCE } from './source-reader.ts'
|
||||
|
||||
const VERSION_METADATA_KEY = 'dsh-version'
|
||||
const BINARY_SAMPLE_BYTES = 8192
|
||||
|
||||
type BoundedReadResponse =
|
||||
| { kind: 'ok'; data: string }
|
||||
| { kind: 'not-file' }
|
||||
| { kind: 'oversize'; size: number }
|
||||
| { kind: 'grew' }
|
||||
| { kind: 'open-error'; message: string }
|
||||
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
|
||||
|
||||
function assertNotAborted(signal: AbortSignal | undefined, operation: string): void {
|
||||
if (signal?.aborted === true) throw new FsError(`${operation} aborted`, 'FS_ABORTED')
|
||||
@@ -68,6 +61,27 @@ function decodeText(bytes: Uint8Array, displayPath: string, binarySampleBytes: n
|
||||
}
|
||||
}
|
||||
|
||||
function decodeCanonicalPath(encoded: string): string {
|
||||
if (encoded.length === 0 || !BASE64.test(encoded)) {
|
||||
throw new Error('fs-e2b: canonical path transport returned invalid base64')
|
||||
}
|
||||
const framed = Buffer.from(encoded, 'base64')
|
||||
if (framed.toString('base64') !== encoded
|
||||
|| framed.length < 2
|
||||
|| framed.at(-1) !== 0
|
||||
|| framed.subarray(0, -1).includes(0)) {
|
||||
throw new Error('fs-e2b: canonical path transport returned invalid NUL framing')
|
||||
}
|
||||
let path: string
|
||||
try {
|
||||
path = new TextDecoder('utf-8', { fatal: true }).decode(framed.subarray(0, -1))
|
||||
} catch (error: unknown) {
|
||||
throw new Error('fs-e2b: canonical path is not valid UTF-8', { cause: error })
|
||||
}
|
||||
if (!posix.isAbsolute(path)) throw new Error('fs-e2b: canonical path is not absolute')
|
||||
return path
|
||||
}
|
||||
|
||||
function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } {
|
||||
return signal === undefined ? {} : { signal }
|
||||
}
|
||||
@@ -213,69 +227,6 @@ export class E2BFileSystem extends FileSystem {
|
||||
}
|
||||
}
|
||||
|
||||
override async readTextBounded(target: FsTarget, maxBytes: number, signal?: AbortSignal): Promise<string> {
|
||||
if (!Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
||||
throw new Error('bounded read maxBytes must be a positive safe integer')
|
||||
}
|
||||
assertNotAborted(signal, 'read')
|
||||
const sandbox = await this.ctx.e2b.getSandbox()
|
||||
try {
|
||||
const node = await sandbox.commands.run('command -v -- node', commandOpts(signal))
|
||||
const executable = node.stdout.trim()
|
||||
if (!posix.isAbsolute(executable) || executable.includes('\n')) {
|
||||
throw new Error('fs-e2b: bounded reader requires one absolute Node executable')
|
||||
}
|
||||
const command = [
|
||||
quoteE2BShellArg(executable),
|
||||
'--input-type=commonjs',
|
||||
'-e',
|
||||
quoteE2BShellArg(BOUNDED_READER_SOURCE),
|
||||
quoteE2BShellArg(this.processPath(target)),
|
||||
String(maxBytes),
|
||||
].join(' ')
|
||||
const result = await sandbox.commands.run(command, commandOpts(signal))
|
||||
assertNotAborted(signal, 'read')
|
||||
const response = this.parseBoundedRead(result.stdout, target)
|
||||
if (response.kind === 'not-file') {
|
||||
throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE')
|
||||
}
|
||||
if (response.kind === 'oversize' && Number.isSafeInteger(response.size)) {
|
||||
throw new FsError(
|
||||
`cannot read "${target.displayPath}": ${response.size} bytes exceeds the ${maxBytes}-byte limit`,
|
||||
'FS_IO_ERROR',
|
||||
)
|
||||
}
|
||||
if (response.kind === 'grew') {
|
||||
throw new FsError(
|
||||
`cannot read "${target.displayPath}": file grew past the ${maxBytes}-byte limit while reading`,
|
||||
'FS_IO_ERROR',
|
||||
)
|
||||
}
|
||||
if (response.kind === 'open-error' && typeof response.message === 'string') {
|
||||
if (/ENOENT|no such file/i.test(response.message)) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND')
|
||||
}
|
||||
if (/EACCES|EPERM|permission denied|operation not permitted/i.test(response.message)) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": permission denied`, 'FS_PERMISSION_DENIED')
|
||||
}
|
||||
throw new FsError(
|
||||
`cannot read "${target.displayPath}" safely: ${response.message}`,
|
||||
'FS_IO_ERROR',
|
||||
)
|
||||
}
|
||||
if (response.kind !== 'ok' || typeof response.data !== 'string') {
|
||||
throw new FsError(`cannot read "${target.displayPath}": bounded reader returned an invalid response`, 'FS_IO_ERROR')
|
||||
}
|
||||
const bytes = Buffer.from(response.data, 'base64')
|
||||
if (bytes.toString('base64') !== response.data || bytes.length > maxBytes) {
|
||||
throw new FsError(`cannot read "${target.displayPath}": bounded reader returned invalid bytes`, 'FS_IO_ERROR')
|
||||
}
|
||||
return decodeText(bytes, target.displayPath, BINARY_SAMPLE_BYTES)
|
||||
} catch (error: unknown) {
|
||||
throw mapError(error, 'read', target.displayPath, signal)
|
||||
}
|
||||
}
|
||||
|
||||
override async streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>> {
|
||||
const sandbox = await this.ctx.e2b.getSandbox()
|
||||
await this.requireRegular(target, signal)
|
||||
@@ -339,18 +290,23 @@ export class E2BFileSystem extends FileSystem {
|
||||
try {
|
||||
const sandbox = await this.ctx.e2b.getSandbox()
|
||||
const listed = await sandbox.files.list(String(target.targetKey), { depth: 1, ...signalOpts(signal) })
|
||||
const entries = await Promise.all(listed.map(async (entry): Promise<FsDirEntry> => {
|
||||
const entries: FsDirEntry[] = []
|
||||
for (const entry of listed) {
|
||||
const displayPath = posix.join(target.displayPath, entry.name)
|
||||
const canonical = await this.canonicalPath(sandbox, entry.path, signal)
|
||||
const resolved = await this.probe(canonical, displayPath, signal)
|
||||
return {
|
||||
const canonical = entry.symlinkTarget === undefined
|
||||
? entry.path
|
||||
: await this.canonicalPath(sandbox, entry.path, signal)
|
||||
const resolved = entry.symlinkTarget === undefined
|
||||
? entry
|
||||
: await this.probe(canonical, displayPath, signal)
|
||||
entries.push({
|
||||
name: entry.name,
|
||||
type: resolved === undefined ? 'other' : entryType(resolved),
|
||||
target: { targetKey: FsTargetKey(canonical), displayPath },
|
||||
...(resolved !== undefined ? { version: entryVersion(resolved) } : {}),
|
||||
...(resolved?.type === FileType.FILE ? { size: resolved.size } : {}),
|
||||
}
|
||||
}))
|
||||
})
|
||||
}
|
||||
return entries.sort((left, right) => left.name.localeCompare(right.name))
|
||||
} catch (error: unknown) {
|
||||
throw mapError(error, 'list', target.displayPath, signal)
|
||||
@@ -420,26 +376,17 @@ export class E2BFileSystem extends FileSystem {
|
||||
|
||||
private async canonicalPath(sandbox: Sandbox, path: string, signal?: AbortSignal): Promise<string> {
|
||||
try {
|
||||
const result = await sandbox.commands.run(`realpath -m -- ${quoteE2BShellArg(path)}`, commandOpts(signal))
|
||||
return result.stdout.replace(/\n$/, '')
|
||||
const result = await sandbox.commands.run(
|
||||
`set -o pipefail; realpath -mz -- ${quoteE2BShellArg(path)} | base64 -w0`,
|
||||
commandOpts(signal),
|
||||
)
|
||||
return decodeCanonicalPath(result.stdout)
|
||||
} catch (error: unknown) {
|
||||
if (error instanceof CommandExitError) throw new Error(error.stderr || error.message, { cause: error })
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private parseBoundedRead(stdout: string, target: FsTarget): BoundedReadResponse {
|
||||
try {
|
||||
return JSON.parse(stdout) as BoundedReadResponse
|
||||
} catch (error: unknown) {
|
||||
throw new FsError(
|
||||
`cannot read "${target.displayPath}": bounded reader returned invalid JSON`,
|
||||
'FS_IO_ERROR',
|
||||
{ cause: error },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private async probe(path: string, displayPath: string, signal?: AbortSignal): Promise<EntryInfo | undefined> {
|
||||
assertNotAborted(signal, 'stat')
|
||||
try {
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
/** Dependency-free stable-handle bounded reader installed inside E2B. */
|
||||
export const BOUNDED_READER_SOURCE = String.raw`
|
||||
/* dsh-e2b-bounded-reader */
|
||||
const fs = require('node:fs')
|
||||
const target = process.argv[1]
|
||||
const maxBytes = Number(process.argv[2])
|
||||
const directoryFlags = fs.constants.O_RDONLY | fs.constants.O_DIRECTORY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK
|
||||
const fileFlags = fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK
|
||||
let directory
|
||||
let descriptor
|
||||
let response
|
||||
|
||||
const openChild = (parent, component, flags) => fs.openSync('/proc/self/fd/' + parent + '/' + component, flags)
|
||||
const invalidComponent = component => component === '' || component === '.' || component === '..'
|
||||
|
||||
try {
|
||||
if (typeof target !== 'string' || !target.startsWith('/') || !Number.isSafeInteger(maxBytes) || maxBytes <= 0) {
|
||||
throw new Error('bounded reader requires an absolute target and positive byte limit')
|
||||
}
|
||||
const components = target === '/' ? [] : target.slice(1).split('/')
|
||||
if (components.length === 0 || components.some(invalidComponent)) {
|
||||
throw new Error('bounded reader received a non-canonical file path')
|
||||
}
|
||||
|
||||
directory = fs.openSync('/', directoryFlags)
|
||||
for (const component of components.slice(0, -1)) {
|
||||
const child = openChild(directory, component, directoryFlags)
|
||||
fs.closeSync(directory)
|
||||
directory = child
|
||||
}
|
||||
descriptor = openChild(directory, components.at(-1), fileFlags)
|
||||
|
||||
const info = fs.fstatSync(descriptor)
|
||||
if (!info.isFile()) response = { kind: 'not-file' }
|
||||
else if (info.size > maxBytes) response = { kind: 'oversize', size: info.size }
|
||||
else {
|
||||
const chunks = []
|
||||
let total = 0
|
||||
while (total <= maxBytes) {
|
||||
const chunk = Buffer.allocUnsafe(Math.min(65536, maxBytes - total + 1))
|
||||
const bytesRead = fs.readSync(descriptor, chunk, 0, chunk.length, null)
|
||||
if (bytesRead === 0) break
|
||||
chunks.push(chunk.subarray(0, bytesRead))
|
||||
total += bytesRead
|
||||
}
|
||||
response = total > maxBytes
|
||||
? { kind: 'grew' }
|
||||
: { kind: 'ok', data: Buffer.concat(chunks, total).toString('base64') }
|
||||
}
|
||||
} catch (error) {
|
||||
response = { kind: 'open-error', message: error instanceof Error ? error.message : String(error) }
|
||||
} finally {
|
||||
for (const openDescriptor of [descriptor, directory]) {
|
||||
if (openDescriptor === undefined) continue
|
||||
try {
|
||||
fs.closeSync(openDescriptor)
|
||||
} catch (error) {
|
||||
response = { kind: 'open-error', message: error instanceof Error ? error.message : String(error) }
|
||||
}
|
||||
}
|
||||
}
|
||||
process.stdout.write(JSON.stringify(response))
|
||||
`
|
||||
@@ -49,10 +49,7 @@ class FakeRemote {
|
||||
nextReadError: unknown
|
||||
nextRenameError: unknown
|
||||
nextRemoveError: unknown
|
||||
boundedOutput: string | undefined
|
||||
boundedError: unknown
|
||||
nodeExecutable = '/usr/bin/node\n'
|
||||
abortAfterBoundedCommand: AbortController | undefined
|
||||
canonicalOutput: string | undefined
|
||||
abortAfterRename: AbortController | undefined
|
||||
disappearOnInfo = new Set<string>()
|
||||
private clock = 1
|
||||
@@ -239,18 +236,18 @@ class FakeRemote {
|
||||
this.nextCommandError = undefined
|
||||
throw error
|
||||
}
|
||||
if (command.startsWith('realpath -m -- ')) {
|
||||
const input = command.slice('realpath -m -- '.length).slice(1, -1)
|
||||
const realpathPrefix = 'set -o pipefail; realpath -mz -- '
|
||||
const realpathSuffix = ' | base64 -w0'
|
||||
if (command.startsWith(realpathPrefix) && command.endsWith(realpathSuffix)) {
|
||||
const quoted = command.slice(realpathPrefix.length, -realpathSuffix.length)
|
||||
const input = quoted.slice(1, -1).replaceAll(String.raw`'"'"'`, '\'')
|
||||
const node = this.nodes.get(input)
|
||||
return { exitCode: 0, stdout: `${node?.symlinkTarget ?? input}\n`, stderr: '' }
|
||||
}
|
||||
if (command === 'command -v -- node') {
|
||||
return { exitCode: 0, stdout: this.nodeExecutable, stderr: '' }
|
||||
}
|
||||
if (command.includes('dsh-e2b-bounded-reader')) {
|
||||
if (this.boundedError !== undefined) throw this.boundedError
|
||||
this.abortAfterBoundedCommand?.abort('after bounded read')
|
||||
return { exitCode: 0, stdout: this.boundedOutput ?? '{"kind":"ok","data":""}', stderr: '' }
|
||||
const canonical = `${node?.symlinkTarget ?? input}\0`
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: this.canonicalOutput ?? Buffer.from(canonical).toString('base64'),
|
||||
stderr: '',
|
||||
}
|
||||
}
|
||||
const chmod = /^chmod ([0-7]+) -- '([^']+)'$/.exec(command)
|
||||
if (chmod !== null) this.required(chmod[2]!).mode = Number.parseInt(chmod[1]!, 8)
|
||||
@@ -340,6 +337,28 @@ describe('E2BFileSystem identity, metadata, and reads', () => {
|
||||
.toThrow('expected an absolute process path')
|
||||
})
|
||||
|
||||
it('preserves newline and multibyte canonical paths through strict ASCII framing', async () => {
|
||||
const remote = new FakeRemote()
|
||||
const path = '/workspace/你好\nfile.ts'
|
||||
remote.file(path, 'text')
|
||||
const { fs } = await setup(remote)
|
||||
|
||||
await expect(fs.resolve(path)).resolves.toEqual({ targetKey: path, displayPath: path })
|
||||
})
|
||||
|
||||
it.each([
|
||||
['invalid base64', '!!!!'],
|
||||
['missing terminator', Buffer.from('/workspace/file').toString('base64')],
|
||||
['multiple records', Buffer.from('/workspace/file\0/other\0').toString('base64')],
|
||||
['invalid UTF-8', Buffer.from([47, 0xff, 0]).toString('base64')],
|
||||
['relative path', Buffer.from('workspace/file\0').toString('base64')],
|
||||
])('rejects %s from canonical path transport', async (_label, output) => {
|
||||
const remote = new FakeRemote()
|
||||
remote.canonicalOutput = output
|
||||
const { fs } = await setup(remote)
|
||||
await expectCode(fs.resolve('file'), 'FS_IO_ERROR')
|
||||
})
|
||||
|
||||
it('reads whole and streamed UTF-8 across chunk boundaries', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/text.txt', 'A€B')
|
||||
@@ -424,78 +443,6 @@ describe('E2BFileSystem identity, metadata, and reads', () => {
|
||||
await expectCode(fs.readText(await fs.resolve('a')), 'FS_ABORTED')
|
||||
})
|
||||
|
||||
it('performs stable bounded reads through the remote no-follow reader', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/a', 'unused')
|
||||
const { fs } = await setup(remote)
|
||||
const target = await fs.resolve('a')
|
||||
remote.boundedOutput = JSON.stringify({ kind: 'ok', data: Buffer.from('hello 你好').toString('base64') })
|
||||
await expect(fs.readTextBounded(target, 64)).resolves.toBe('hello 你好')
|
||||
expect(remote.commands.some(command => command.includes('dsh-e2b-bounded-reader'))).toBe(true)
|
||||
|
||||
await expect(fs.readTextBounded(target, 0)).rejects.toThrow('positive safe integer')
|
||||
await expect(fs.readTextBounded(target, 1.5)).rejects.toThrow('positive safe integer')
|
||||
await expect(fs.readTextBounded(target, 64, AbortSignal.abort())).rejects.toMatchObject({ code: 'FS_ABORTED' })
|
||||
})
|
||||
|
||||
it('maps bounded-reader file, size, and open failures', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/a', 'unused')
|
||||
const { fs } = await setup(remote)
|
||||
const target = await fs.resolve('a')
|
||||
const cases: Array<[unknown, string]> = [
|
||||
[{ kind: 'not-file' }, 'FS_NOT_REGULAR_FILE'],
|
||||
[{ kind: 'oversize', size: 65 }, 'FS_IO_ERROR'],
|
||||
[{ kind: 'grew' }, 'FS_IO_ERROR'],
|
||||
[{ kind: 'open-error', message: 'ENOENT: no such file' }, 'FS_NOT_FOUND'],
|
||||
[{ kind: 'open-error', message: 'EACCES: permission denied' }, 'FS_PERMISSION_DENIED'],
|
||||
[{ kind: 'open-error', message: 'ELOOP: symbolic link' }, 'FS_IO_ERROR'],
|
||||
[{ kind: 'oversize', size: 'large' }, 'FS_IO_ERROR'],
|
||||
[{ kind: 'open-error', message: 7 }, 'FS_IO_ERROR'],
|
||||
[{ kind: 'unknown' }, 'FS_IO_ERROR'],
|
||||
]
|
||||
for (const [response, code] of cases) {
|
||||
remote.boundedOutput = JSON.stringify(response)
|
||||
await expectCode(fs.readTextBounded(target, 64), code)
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects malformed bounded-reader transports and bytes', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/a', 'unused')
|
||||
const { fs } = await setup(remote)
|
||||
const target = await fs.resolve('a')
|
||||
|
||||
remote.boundedOutput = 'not-json'
|
||||
await expectCode(fs.readTextBounded(target, 4), 'FS_IO_ERROR')
|
||||
remote.boundedOutput = JSON.stringify({ kind: 'ok', data: '!!!!' })
|
||||
await expectCode(fs.readTextBounded(target, 4), 'FS_IO_ERROR')
|
||||
remote.boundedOutput = JSON.stringify({ kind: 'ok', data: Buffer.from('12345').toString('base64') })
|
||||
await expectCode(fs.readTextBounded(target, 4), 'FS_IO_ERROR')
|
||||
remote.boundedOutput = JSON.stringify({ kind: 'ok', data: Buffer.from([0]).toString('base64') })
|
||||
await expectCode(fs.readTextBounded(target, 4), 'FS_NOT_TEXT')
|
||||
remote.boundedOutput = JSON.stringify({ kind: 'ok', data: Buffer.from([0xff]).toString('base64') })
|
||||
await expectCode(fs.readTextBounded(target, 4), 'FS_NOT_TEXT')
|
||||
|
||||
remote.nodeExecutable = 'node\n'
|
||||
await expectCode(fs.readTextBounded(target, 4), 'FS_IO_ERROR')
|
||||
remote.nodeExecutable = '/usr/bin/node\n/other\n'
|
||||
await expectCode(fs.readTextBounded(target, 4), 'FS_IO_ERROR')
|
||||
remote.nodeExecutable = '/usr/bin/node\n'
|
||||
remote.boundedError = new Error('reader transport failed')
|
||||
await expectCode(fs.readTextBounded(target, 4), 'FS_IO_ERROR')
|
||||
})
|
||||
|
||||
it('does not turn a post-read abort into successful source text', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/a', 'unused')
|
||||
const controller = new AbortController()
|
||||
remote.abortAfterBoundedCommand = controller
|
||||
remote.boundedOutput = JSON.stringify({ kind: 'ok', data: Buffer.from('text').toString('base64') })
|
||||
const { fs } = await setup(remote)
|
||||
await expectCode(fs.readTextBounded(await fs.resolve('a'), 4, controller.signal), 'FS_ABORTED')
|
||||
})
|
||||
|
||||
it('rejects empty paths and directory-listing type errors', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/file', 'x')
|
||||
@@ -686,17 +633,34 @@ describe('E2B filesystem adapter integration edges', () => {
|
||||
await expectCode(fs.readText(target), 'FS_IO_ERROR')
|
||||
})
|
||||
|
||||
it('keeps a listed child whose metadata disappears as an other entry', async () => {
|
||||
it('uses listing metadata directly and canonicalizes only symbolic links', async () => {
|
||||
const remote = new FakeRemote()
|
||||
remote.file('/workspace/a', 'a')
|
||||
remote.disappearOnInfo.add('/workspace/a')
|
||||
remote.file('/workspace/target', 'target')
|
||||
remote.file('/workspace/gone', 'gone')
|
||||
remote.symlink('/workspace/link', '/workspace/target')
|
||||
remote.symlink('/workspace/vanished-link', '/workspace/gone')
|
||||
remote.disappearOnInfo.add('/workspace/gone')
|
||||
const { fs } = await setup(remote)
|
||||
const listed = await fs.listDir(await fs.resolve('/workspace'))
|
||||
expect(listed).toEqual([{
|
||||
name: 'a',
|
||||
const directory = await fs.resolve('/workspace')
|
||||
const commandsBefore = remote.commands.length
|
||||
const getInfo = vi.spyOn(remote.sandbox.files, 'getInfo')
|
||||
|
||||
const listed = await fs.listDir(directory)
|
||||
|
||||
expect(listed.find(entry => entry.name === 'a')).toMatchObject({
|
||||
type: 'file', target: { targetKey: '/workspace/a' }, size: 1,
|
||||
})
|
||||
expect(listed.find(entry => entry.name === 'link')).toMatchObject({
|
||||
type: 'file', target: { targetKey: '/workspace/target' }, size: 6,
|
||||
})
|
||||
expect(listed.find(entry => entry.name === 'vanished-link')).toEqual({
|
||||
name: 'vanished-link',
|
||||
type: 'other',
|
||||
target: { targetKey: '/workspace/a', displayPath: '/workspace/a' },
|
||||
}])
|
||||
target: { targetKey: '/workspace/gone', displayPath: '/workspace/vanished-link' },
|
||||
})
|
||||
expect(remote.commands.slice(commandsBefore)).toHaveLength(2)
|
||||
expect(getInfo).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('registers the package-owned empty invariant installer', async () => {
|
||||
|
||||
Reference in New Issue
Block a user