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/README.md
|
||||
README.md: ef2e5ef49056e6688630e785a61c01fa53febbb1
|
||||
README.zh.md: d76b05f9b60bc471c1793decd1def1d15615c852
|
||||
README.md: cb733ba00d21070773ff9b381e4a72d9d3b9a8df
|
||||
README.zh.md: 29f8e58a0cdc59098cf8d74120cb360d11cfbcb3
|
||||
|
||||
@@ -12,4 +12,4 @@ An experimental provider-composition POC that places one filesystem/process exec
|
||||
|
||||
The existing [`dsh-bash-local`](../bash/bash-local/README.md), [`dsh-pty-local`](../pty/pty-local/README.md), [`dsh-lsp-local`](../lsp/lsp-local/README.md), and [`dsh-code-runtime-subprocess`](../code-runtime/code-runtime-subprocess/README.md) need no E2B-specific forks. They delegate every execution-world operation to `ctx.fs` and `ctx.subprocess`, so mounting the two E2B adapters places their mutable work in the same sandbox.
|
||||
|
||||
This boundary does not move the harness process, Cordis objects, model calls, agent/session state, session persistence, skills, higher-level protocol state, or E2B SDK buffers. The [shared-runtime decision](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md) owns the POC boundary; the [portable-consumer decision](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md) owns the generic composition.
|
||||
This boundary does not move the harness process, Cordis objects, model calls, agent/session state, session persistence, skills, higher-level protocol state, or E2B SDK buffers. The [portable execution-world decision](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md) owns both the generic composition and this POC boundary.
|
||||
|
||||
@@ -12,4 +12,4 @@
|
||||
|
||||
现有的 [`dsh-bash-local`](../bash/bash-local/README.md)、[`dsh-pty-local`](../pty/pty-local/README.md)、[`dsh-lsp-local`](../lsp/lsp-local/README.md) 及 [`dsh-code-runtime-subprocess`](../code-runtime/code-runtime-subprocess/README.md) 无需 E2B 专用 fork。它们把执行环境中的所有操作委托给 `ctx.fs` 和 `ctx.subprocess`,因此挂载这两个 E2B 适配器后,它们执行的可变操作都发生在同一个沙箱内。
|
||||
|
||||
该边界不会迁移 harness 进程、Cordis 对象、模型调用、agent(智能体)/会话状态、会话持久化、skill(技能)、更高层协议状态或 E2B SDK 缓冲。[共享运行时决策](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md)界定 POC 边界;[可移植消费方决策](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md)界定通用组合。
|
||||
该边界不会迁移 harness 进程、Cordis 对象、模型调用、agent(智能体)/会话状态、会话持久化、skill(技能)、更高层协议状态或 E2B SDK 缓冲。[可移植执行世界决策](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md)同时界定通用组合和此 POC 边界。
|
||||
|
||||
@@ -160,7 +160,8 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
||||
expect(stderr).toBe('')
|
||||
const output = JSON.parse(stdout) as Record<string, unknown>
|
||||
expect(output).toMatchObject({
|
||||
bashRead: 'written-by-fs\n',
|
||||
bashRead: 'written-by-fs-versioned\n',
|
||||
fsVersionGuard: true,
|
||||
fsRead: 'written-by-bash\n',
|
||||
explicitEnvironment: true,
|
||||
splitUtf8Output: '你好',
|
||||
@@ -184,7 +185,6 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
|
||||
kind: 'locations',
|
||||
locations: [{ range: { start: { line: 0, character: 6 }, end: { line: 0, character: 10 } } }],
|
||||
},
|
||||
lspContainment: true,
|
||||
lspDocumentBound: true,
|
||||
terminal: {
|
||||
echo: { waitReason: 'stdin_read', sessionStatus: { kind: 'running' } },
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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/subprocess-e2b/README.md
|
||||
README.md: 9b86af428533ebcc2c0da56339e6ea2a28170fd3
|
||||
README.zh.md: e3e8368534266e35ec04dcf5a2d6718829b4a016
|
||||
README.md: 9e044c13b673461a876e016ec560db4897a27c66
|
||||
README.zh.md: b9180c0c0679915fae7a1db22353613708392a8c
|
||||
|
||||
@@ -9,9 +9,9 @@ E2B implementation of the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subpr
|
||||
- **Asynchronous remote start** — the synchronous seam returns a handle immediately while `Sandbox.commands.run(..., { background: true })` starts remotely. `pid` is `-1` until the wrapper publishes and the adapter validates its process-group id; stdin and ordinary observation wait for that publication. An owned startup signal aborts environment and private-state preparation before allocation; once allocation begins, cancellation waits for a provisional SDK handle it can clean.
|
||||
- **Execution-world coordinates** — `cwd` and private `runtimeRoot` come from the shared owner; executable lookup verifies absolute paths or resolves a bare name against the sandbox PATH plus explicit overrides.
|
||||
- **Linux process groups** — a quoted wrapper starts each argv under `exec setsid --wait` and records its actual process-group id plus private status files beneath `ctx.e2b.runtimeRoot/processes`. The handle waits for that file instead of treating the SDK command PID as its published identity. Termination signals the negative recorded id with `SIGTERM`, waits the caller's `graceMs`, then escalates to `SIGKILL` and the SDK kill fallback; TERM delivery or probe failures also force that escalation. Process-table probes treat groups containing only zombie or dead entries as quiescent. Force cleanup succeeds only after a bounded probe finds the group empty; otherwise `waitForExit()` exposes a retryable failure, while proven quiescence makes later termination a no-op. Publication and monitoring failures apply the same cleanup transaction before rejecting. Service disposal rejects new starts, terminates and joins every retained process group, then awaits SDK settlement and private cleanup before the sandbox owner disposes.
|
||||
- **Environment boundary** — the sandbox command environment crosses the SDK callback boundary as base64 ASCII before one strict UTF-8 decode, then the wrapper removes ambient `DSH_*` and credential-shaped (`*KEY*`, `*SECRET*`, `*TOKEN*`) names and restores every valid `spec.env` entry as an explicit caller opt-in; empty names, `=`, and NUL framing violations reject before launch. E2B's fixed command and PTY login shells receive a fresh randomized root-level `HOME` plus empty overrides for every scrubbed ambient name before user profiles can run; the requested argv receives the serialized environment afterward. Host ambient variables never enter the sandbox implicitly. Private environment files are removed after consumption, and failed command or terminal setup removes its private state before rejecting.
|
||||
- **Environment boundary** — one trusted control-shell probe transports the sandbox environment as base64 ASCII for one strict UTF-8 decode; the wrapper then removes ambient `DSH_*` and credential-shaped (`*KEY*`, `*SECRET*`, `*TOKEN*`) names and restores every valid `spec.env` entry as an explicit caller opt-in. Empty names, `=`, and NUL framing violations reject before launch. Subsequent E2B command and PTY login shells receive a fresh randomized root-level `HOME` plus empty overrides for every scrubbed ambient name before user profiles can run; the requested argv receives the serialized environment afterward. Host ambient variables never enter the sandbox implicitly. Private environment files are removed after consumption, and failed command or terminal setup removes its private state before rejecting.
|
||||
- **Stdio projection** — the remote wrapper branches raw bytes into optional bounded spill files, frames each live chunk as newline-delimited base64 ASCII, and the host incrementally restores bytes across arbitrary SDK callback boundaries. Pipe mode writes those bytes to host Node streams; inherit mode writes them to the harness process streams; collect mode retains a bounded host tail with offset reads. The wrapper publishes the direct command status before waiting for inherited writers. For collect or inherit output, the adapter disconnects an incomplete SDK stream after `graceMs`, withholds its partial spill, and returns that status while retaining the remote group for `waitForExit()` and termination. Natural raw-pipe completion instead awaits lossless transport and preserves backpressure; explicit termination destroys the host pipes and releases blocked output before remote cleanup. Batch and streaming stdin use the SDK handle.
|
||||
- **Terminal sessions** — `spawnTerminal()` uses E2B's byte PTY API, installs the exact argv and scrubbed environment through private mode-`0600` files, reports the foreground process group, sends real signals, and tears down every live group in the remote terminal session before settlement; zombie-only groups are already quiescent. A private random output boundary discards the E2B bootstrap shell's prompt and echoed runner command while preserving every requested-process byte, including its first prompt. PTY allocation is awaited through handle publication before cancellation is observed, so owned rollback can clean the published handle. Setup and teardown own the private state transaction, abort pending setup during service disposal, fence publication, and retain an unproven setup cleanup for disposal retry. Prompt detection, scrollback, readiness, and owner policy remain in `dsh-pty-local`.
|
||||
- **Terminal sessions** — `spawnTerminal()` uses E2B's byte PTY API, installs the exact argv and scrubbed environment through private mode-`0600` files, reports the foreground process group, sends real signals, and tears down every live group in the remote terminal session through one retryable awaited `terminate()`; zombie-only groups are already quiescent. A private random output boundary discards the E2B bootstrap shell's prompt and echoed runner command while preserving every requested-process byte, including its first prompt. PTY allocation is awaited through handle publication before cancellation is observed, so owned rollback can clean the published handle. Setup and teardown own the private state transaction, abort pending setup during service disposal, fence publication, and retain an unproven setup cleanup for disposal retry. Prompt detection, scrollback, readiness, and owner policy remain in `dsh-pty-local`.
|
||||
- **Sandbox disappearance** — `SandboxNotFoundError` during process or terminal liveness, termination, rollback, or disconnect proves the remote execution world cannot retain work, so cleanup treats it as quiescent; unrelated failures remain observable.
|
||||
|
||||
The base E2B image supplies the runtime and Bash/GNU utilities this adapter invokes: `node`, `bash`, `setsid`, `ps`, `awk`, `tr`, `env`, `base64`, `chmod`, `tee`, `head`, `rm`, and `kill`. A custom template must retain compatible commands and E2B PTY support.
|
||||
@@ -30,6 +30,8 @@ No direct invalidation; the named consumers own any request-prefix changes.
|
||||
- **Synchronous-PID consumers are unsupported** — `pid` remains `-1` during remote startup; consumers that require a positive PID immediately, including the ACP child backend, cannot use this provider unchanged.
|
||||
- **Reconnect does not reconstruct handles** — remote PID/status/spill files survive a retained sandbox, but a new harness process does not rebuild live `SubprocessHandle` objects or output cursors from them.
|
||||
- **Remote state accumulates when retained** — process directories and valid spill files remain under `.dsh-e2b`; this POC supplies no retention sweep.
|
||||
- **Numeric process identities are not reuse-fenced** — E2B exposes numeric PID/PGID PTY input, signalling, and cleanup operations but no atomic identity-bound alternative. The adapter minimizes host round trips and live coverage exercises the reproducible stale-interrupt overlap; replacement is deferred until E2B adds an identity primitive or a failure demonstrates a narrower protocol.
|
||||
- **The initial environment probe inherits sandbox defaults** — E2B merges command overrides with default environment entries, so the probe cannot blank unknown credential-shaped names before enumerating them. In a reconnected sandbox, a same-UID untrusted process could inspect that short-lived control shell; this POC therefore does not support secrets in sandbox-default environment variables and requires an E2B replacement-environment primitive or a hardened template to close the gap.
|
||||
- **E2B exposes no signal fact** — an adapter-requested `SIGTERM` or `SIGKILL` is reported only when no wrapper-published direct exit code wins; every unrequested SDK exit remains an exit code, including values shaped like `128 + signal`.
|
||||
- **Exact terminal stdin-wait inspection is unavailable** — E2B exposes the foreground process group but not the syscall evidence needed to prove it is waiting on fd 0, so the generic PTY backend falls back to controlled prompt markers and bounded silence.
|
||||
- **Linux utility and E2B transport semantics are assumed** — there is no Windows, arbitrary-template, escaped-session recovery, or network-partition fidelity layer.
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
- **异步远程启动**:同步 seam 会立即返回一个句柄,同时由 `Sandbox.commands.run(..., { background: true })` 在远程启动进程。包装层发布进程组 ID 并由适配器完成验证之前,`pid` 为 `-1`;stdin 和常规观察会等待该发布。自有启动信号会在分配前中止环境和私有状态准备;分配开始后,取消会等待可清理的临时 SDK 句柄。
|
||||
- **执行世界坐标**:`cwd` 和私有 `runtimeRoot` 来自共享所有者;可执行文件查找会验证绝对路径,或根据沙箱 PATH 加显式覆盖来解析裸名称。
|
||||
- **Linux 进程组**:带引号保护的包装层会在 `exec setsid --wait` 下启动每组 argv,并在 `ctx.e2b.runtimeRoot/processes` 下记录实际进程组 ID 和私有状态文件。句柄会等待该文件,而不会把 SDK 命令 PID 当作已发布的身份。终止操作以记录的负数 ID 发送 `SIGTERM`,等待调用方的 `graceMs`,再升级到 `SIGKILL` 和 SDK kill 回退;TERM 信号发送或探测失败也会强制触发该升级。进程表探测会把仅含僵尸或已死亡条目的进程组视为完全停稳。强制清理只有在有界探测发现进程组为空后才算成功;否则 `waitForExit()` 会公开可重试的失败,而已证明的完全停稳会让后续终止操作不再执行任何动作。发布失败与监控失败都会在拒绝前执行同一清理事务。服务 dispose(资源释放)会拒绝新的启动请求、终止并等待每个保留进程组退出,再等待 SDK 结算和私有清理完成,之后沙箱所有者才会释放。
|
||||
- **环境边界**:沙箱命令环境会先以 base64 ASCII 跨越 SDK 回调边界,再进行一次严格 UTF-8 解码;随后包装层移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*`、`*SECRET*`、`*TOKEN*`),并把每个有效的 `spec.env` 条目恢复为调用方显式选择。空名称、`=` 和违反 NUL 分帧规则的条目会在启动前被拒绝。在用户 profile 脚本运行前,E2B 固定的命令 shell 与 PTY 登录 shell 会获得位于根目录下、全新随机生成的 `HOME`,并为每个被清理的环境变量名设置空值覆盖;之后,请求的 argv 才会接收序列化环境。宿主环境变量绝不会隐式进入沙箱。私有环境文件在使用后会被删除;命令或终端设置失败时,会先删除其私有状态再拒绝。
|
||||
- **环境边界**:一次受信任的控制 shell 探测会以 base64 ASCII 传输沙箱环境,再进行一次严格 UTF-8 解码;随后包装层移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*`、`*SECRET*`、`*TOKEN*`),并把每个有效的 `spec.env` 条目恢复为调用方显式选择。空名称、`=` 和违反 NUL 分帧规则的条目会在启动前被拒绝。在用户 profile 脚本运行前,此后的 E2B 命令 shell 与 PTY 登录 shell 会获得位于根目录下、全新随机生成的 `HOME`,并为每个被清理的环境变量名设置空值覆盖;之后,请求的 argv 才会接收序列化环境。宿主环境变量绝不会隐式进入沙箱。私有环境文件在使用后会被删除;命令或终端设置失败时,会先删除其私有状态再拒绝。
|
||||
- **stdio 投影**:远程包装层先把原始字节分流到可选的有界 spill 文件,再把每个实时分片编码为换行分隔的 base64 ASCII 帧;宿主会跨任意 SDK 回调边界增量恢复字节。pipe 模式把这些字节写入宿主 Node 流;inherit 模式把字节写入 harness 进程流;collect 模式保留有界的宿主尾部,并支持基于偏移量读取。包装层会在等待继承管道的写入方之前发布直接命令状态。对于 collect 或 inherit 输出,超过 `graceMs` 后,适配器会断开未完成的 SDK 流,不公开其中不完整的 spill,并返回该状态,同时保留远程进程组供 `waitForExit()` 和终止操作使用。原始 pipe 自然完成时,会等待无损传输完成并保留背压;显式终止则会销毁宿主 pipe,并在远程清理前释放受阻的输出写入。批量 stdin 和流式 stdin 都使用 SDK 句柄。
|
||||
- **终端会话**:`spawnTerminal()` 使用 E2B 的字节 PTY API,以 mode 为 `0600` 的私有文件传入原样 argv 与清理后的环境,报告前台进程组,发送真实信号,并在结算前清理远程终端会话中仍存活的每个进程组;仅含僵尸进程的进程组已经完全停稳。私有随机输出边界会丢弃 E2B 引导 shell 的提示符和回显的 runner 命令,同时保留请求进程的每个字节,包括其第一个提示符。PTY 分配会一直等待到句柄发布后才观察取消,以便由承担清理责任的回滚清理已发布句柄。setup 与 teardown 负责私有状态事务,在服务 dispose 期间中止待处理的 setup、阻止发布,并保留未证明已完成的 setup 清理事务,供 dispose 重试。提示符检测、scrollback、就绪状态与所有者策略仍归 `dsh-pty-local` 所有。
|
||||
- **终端会话**:`spawnTerminal()` 使用 E2B 的字节 PTY API,以 mode 为 `0600` 的私有文件传入原样 argv 与清理后的环境,报告前台进程组,发送真实信号,并通过一项可重试且须等待的 `terminate()` 清理远程终端会话中仍存活的每个进程组;仅含僵尸进程的进程组已经完全停稳。私有随机输出边界会丢弃 E2B 引导 shell 的提示符和回显的 runner 命令,同时保留请求进程的每个字节,包括其第一个提示符。PTY 分配会一直等待到句柄发布后才观察取消,以便由承担清理责任的回滚清理已发布句柄。setup 与 teardown 负责私有状态事务,在服务 dispose 期间中止待处理的 setup、阻止发布,并保留未证明已完成的 setup 清理事务,供 dispose 重试。提示符检测、scrollback、就绪状态与所有者策略仍归 `dsh-pty-local` 所有。
|
||||
- **沙箱消失**:在进程或终端的存活探测、终止、回滚或断开连接期间出现 `SandboxNotFoundError`,证明远程执行环境无法保留工作,因此清理会将其视为完全停稳;其他故障仍可观察。
|
||||
|
||||
基础 E2B 镜像提供该适配器调用的运行时和 Bash/GNU 工具:`node`、`bash`、`setsid`、`ps`、`awk`、`tr`、`env`、`base64`、`chmod`、`tee`、`head`、`rm` 和 `kill`。自定义模板必须保留兼容的命令和 E2B PTY 支持。
|
||||
@@ -30,6 +30,8 @@
|
||||
- **不支持需要同步 PID 的消费方**:远程启动期间,`pid` 保持为 `-1`;包括 ACP 子进程后端在内,要求立即获得正 PID 的消费方无法原样使用本提供方。
|
||||
- **重新连接不会重建句柄**:保留沙箱后,远程 PID/状态/spill 文件仍然存在,但新的 harness 进程不会据此重建实时 `SubprocessHandle` 对象或输出游标。
|
||||
- **保留沙箱时会累积远程状态**:进程目录和有效的 spill 文件会留在 `.dsh-e2b` 下;本 POC 不提供保留清理。
|
||||
- **数值进程身份没有复用围栏**:E2B 公开基于数值 PID/PGID 的 PTY 输入、信号发送和清理操作,却没有与身份原子绑定的替代方案。适配器会尽量减少宿主往返,真实环境测试会覆盖可复现的陈旧中断重叠;在 E2B 新增身份原语,或实际故障证明需要更窄的协议之前,替代方案会继续延后。
|
||||
- **初始环境探测会继承沙箱默认值**:E2B 会把命令覆盖与默认环境条目合并,因此探测无法在枚举未知且形似凭据的名称之前将它们置空。在重新连接的沙箱中,一个同 UID 的不可信进程可以检查该短时存在的控制 shell;因此,该 POC 不支持把 secret 放入沙箱默认环境变量,需要 E2B 的替换环境原语或经加固的模板才能弥合该缺口。
|
||||
- **E2B 不公开信号事实**:适配器请求的 `SIGTERM` 或 `SIGKILL` 只有在包装层发布的直接退出码没有胜出时才报告为信号;其他未请求的 SDK 退出始终保留为退出码,包括形似 `128 + signal` 的值。
|
||||
- **无法精确检查终端 stdin 等待状态**:E2B 会公开前台进程组,但不提供证明其正在等待 fd 0 所需的 syscall 证据,因此通用 PTY 后端会回退到受控提示符标记与有界静默机制。
|
||||
- **依赖 Linux 工具与 E2B 传输语义**:没有 Windows、任意模板、逃逸会话恢复或网络分区的保真层。
|
||||
|
||||
@@ -26,6 +26,8 @@ function remoteEnvironmentEntries(raw: string): Array<readonly [string, string]>
|
||||
* @returns the complete NUL-delimited UTF-8 environment.
|
||||
*/
|
||||
export async function readRemoteEnvironment(sandbox: Sandbox, signal?: AbortSignal): Promise<string> {
|
||||
// TODO(e2b-replace-environment): Remove this ambient probe when E2B can start
|
||||
// a command with a replacement environment instead of merged overrides.
|
||||
const result = await sandbox.commands.run(
|
||||
'set -o pipefail; printf \'%s\' "$PWD" | base64 -w 0; printf \'\\n\'; env -0 | base64 -w 0',
|
||||
{ envs: e2bControlEnvs(), ...(signal === undefined ? {} : { signal }) },
|
||||
|
||||
@@ -61,8 +61,7 @@ export class E2BSubprocessService extends SubprocessService {
|
||||
}))
|
||||
}
|
||||
for (const terminal of terminals) {
|
||||
terminal.terminate()
|
||||
pending.push(terminal.waitForExit().then(() => { this.terminals.delete(terminal) }))
|
||||
pending.push(terminal.terminate().then(() => { this.terminals.delete(terminal) }))
|
||||
}
|
||||
for (const cleanup of failedTerminalSetupCleanups) {
|
||||
pending.push(cleanup().then(() => { this.failedTerminalSetupCleanups.delete(cleanup) }))
|
||||
@@ -125,7 +124,9 @@ export class E2BSubprocessService extends SubprocessService {
|
||||
await handle.waitForExit()
|
||||
this.live.delete(handle)
|
||||
}
|
||||
void handle.done.then(release, release).catch(() => {})
|
||||
void handle.done.then(release, release).catch((_automaticReleaseFailure: unknown) => {
|
||||
// Retain the handle so service disposal can retry its cleanup transaction.
|
||||
})
|
||||
return handle
|
||||
}
|
||||
|
||||
@@ -158,16 +159,17 @@ export class E2BSubprocessService extends SubprocessService {
|
||||
)
|
||||
this.terminals.add(terminal)
|
||||
if (this.isDisposing()) {
|
||||
terminal.terminate()
|
||||
await terminal.waitForExit()
|
||||
await terminal.terminate()
|
||||
this.terminals.delete(terminal)
|
||||
throw new Error('subprocess-e2b: service disposed during terminal setup')
|
||||
}
|
||||
const release = async (): Promise<void> => {
|
||||
await terminal.waitForExit()
|
||||
await terminal.terminate()
|
||||
this.terminals.delete(terminal)
|
||||
}
|
||||
void terminal.done.then(release, release).catch(() => {})
|
||||
void terminal.done.then(release, release).catch((_automaticReleaseFailure: unknown) => {
|
||||
// Retain the terminal so service disposal can retry its cleanup transaction.
|
||||
})
|
||||
return terminal
|
||||
} finally {
|
||||
this.terminalSetups.delete(setup.promise)
|
||||
|
||||
@@ -726,7 +726,9 @@ export class E2BSubprocessHandle implements SubprocessHandle {
|
||||
// A spill mode is a collect mode, so construction always created its reader.
|
||||
const size = (reader as E2BOutputReader).size
|
||||
if (this.outputDrainExpired || size <= mode.maxBytes || size > mode.spill.maxBytes) {
|
||||
removals.push(sandbox.files.remove(path).catch(() => {}))
|
||||
removals.push(sandbox.files.remove(path).catch((_adapterPrivateSpillRemovalFailure: unknown) => {
|
||||
// The command outcome is authoritative; a retained sandbox tolerates private residue.
|
||||
}))
|
||||
}
|
||||
}
|
||||
collect(this.spec.stdio.stdout, this.stdoutReader, this.paths.stdout)
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
quoteE2BShellArg,
|
||||
} from '@deepseek-ai/dsh-e2b'
|
||||
import type { CommandHandle, CommandResult, Sandbox } from '@deepseek-ai/dsh-e2b'
|
||||
import { SubprocessTerminalLifecycle } from '@deepseek-ai/dsh-subprocess'
|
||||
import type {
|
||||
SubprocessOutcome,
|
||||
SubprocessTerminalForeground,
|
||||
@@ -345,7 +344,7 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
|
||||
readonly done: Promise<SubprocessOutcome>
|
||||
|
||||
private topLevelExited = false
|
||||
private readonly lifecycle: SubprocessTerminalLifecycle
|
||||
private cleanup: Promise<void> | undefined
|
||||
private terminationSignal: NodeJS.Signals | null = null
|
||||
|
||||
constructor(
|
||||
@@ -357,21 +356,17 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
|
||||
private readonly controlEnvs: Record<string, string>,
|
||||
private readonly stateDir: string,
|
||||
private readonly graceMs: number,
|
||||
signal?: AbortSignal,
|
||||
) {
|
||||
this.pid = handle.pid
|
||||
this.done = this.waitForCommand()
|
||||
this.lifecycle = new SubprocessTerminalLifecycle({
|
||||
done: this.done,
|
||||
cleanup: () => this.closeOnce(),
|
||||
signal,
|
||||
})
|
||||
}
|
||||
|
||||
// TODO(e2b-pgid-identity): Replace retained numeric PTY/session ids when E2B
|
||||
// exposes identity-bound input, foreground-signal, and cleanup operations.
|
||||
/** @inheritdoc */
|
||||
async write(data: Uint8Array): Promise<void> {
|
||||
async write(data: string): Promise<void> {
|
||||
if (this.topLevelExited) throw new Error('terminal process has exited')
|
||||
await this.sandbox.pty.sendInput(this.pid, data)
|
||||
await this.sandbox.pty.sendInput(this.pid, Buffer.from(data, 'utf8'))
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
@@ -413,13 +408,14 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
terminate(): void {
|
||||
this.lifecycle.terminate()
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
async waitForExit(signal?: AbortSignal): Promise<boolean> {
|
||||
return await this.lifecycle.waitForExit(signal)
|
||||
terminate(): Promise<void> {
|
||||
if (this.cleanup !== undefined) return this.cleanup
|
||||
const cleanup = this.closeOnce()
|
||||
this.cleanup = cleanup
|
||||
void cleanup.catch((_cleanupFailure: unknown) => {
|
||||
this.cleanup = undefined
|
||||
})
|
||||
return cleanup
|
||||
}
|
||||
|
||||
private async waitForCommand(): Promise<SubprocessOutcome> {
|
||||
@@ -474,7 +470,11 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof SandboxNotFoundError)) throw error
|
||||
}
|
||||
await this.sandbox.files.remove(this.stateDir).catch(() => {})
|
||||
try {
|
||||
await this.sandbox.files.remove(this.stateDir)
|
||||
} catch (_adapterPrivateStateRemovalFailure) {
|
||||
// The terminal is quiescent; a retained sandbox tolerates private residue.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -558,7 +558,6 @@ export async function spawnE2BTerminal(
|
||||
controlEnvs,
|
||||
stateDir,
|
||||
spec.graceMs,
|
||||
spec.signal,
|
||||
)
|
||||
} catch (error: unknown) {
|
||||
output.destroy()
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { once } from 'node:events'
|
||||
import { PassThrough } from 'node:stream'
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
|
||||
import type { SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess'
|
||||
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
|
||||
import { E2BTerminalHandle, spawnE2BTerminal } from '../src/terminal.ts'
|
||||
import { spawnE2BTerminal } from '../src/terminal.ts'
|
||||
|
||||
function commandError(exitCode: number): CommandExitError {
|
||||
return new CommandExitError({ exitCode, stdout: '', stderr: '', error: `exit ${exitCode}` })
|
||||
@@ -298,20 +297,20 @@ describe('E2B terminal allocation', () => {
|
||||
await fake.createOptions?.onData(Buffer.from('late bootstrap callback'))
|
||||
expect(output).toBe('requested-shell$ ')
|
||||
|
||||
await terminal.write(Buffer.from('echo ok\r'))
|
||||
await terminal.write('echo ok\r')
|
||||
expect(fake.inputs.at(-1)?.data.toString()).toBe('echo ok\r')
|
||||
await expect(terminal.inspectForeground()).resolves.toEqual({ processGroupId: 456, inputWaiting: false })
|
||||
await expect(terminal.signalForeground('SIGINT')).resolves.toBe(456)
|
||||
expect(fake.commands).toContain('kill -INT -- -456')
|
||||
|
||||
terminal.terminate()
|
||||
const terminated = terminal.terminate()
|
||||
await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
await terminated
|
||||
expect(fake.handle.disconnects).toBe(1)
|
||||
expect(fake.removed).toContain('/runtime/terminal-one')
|
||||
})
|
||||
|
||||
it('inherits only safe ambient values and binds live abort to terminal cleanup', async () => {
|
||||
it('inherits only safe ambient values and limits the allocation signal to setup', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
const controller = new AbortController()
|
||||
const terminal = await spawnE2BTerminal(
|
||||
@@ -325,9 +324,10 @@ describe('E2B terminal allocation', () => {
|
||||
expect(environment).not.toContain('DSH_STALE')
|
||||
|
||||
controller.abort(new Error('stop'))
|
||||
await terminal.write('still live\r')
|
||||
expect(fake.inputs.at(-1)?.data.toString()).toBe('still live\r')
|
||||
await terminal.terminate()
|
||||
await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
|
||||
await expect(terminal.waitForExit(controller.signal)).resolves.toBe(false)
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('publishes the PTY handle before honoring allocation cancellation', async () => {
|
||||
@@ -553,31 +553,11 @@ describe('E2B terminal lifecycle', () => {
|
||||
fake.handle.succeed(7)
|
||||
await expect(terminal.done).resolves.toEqual({ exitCode: 7, signal: null })
|
||||
await ended
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
await expect(terminal.write(Buffer.from('late'))).rejects.toThrow('exited')
|
||||
await expect(terminal.write('late')).rejects.toThrow('exited')
|
||||
fake.foregroundFailure = commandError(1)
|
||||
await expect(terminal.inspectForeground()).resolves.toBeUndefined()
|
||||
await expect(terminal.signalForeground('SIGINT')).rejects.toThrow('cannot resolve foreground process group')
|
||||
})
|
||||
|
||||
it('starts cleanup when the lifetime signal is already aborted at handle publication', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
const controller = new AbortController()
|
||||
controller.abort(new Error('publication cancelled'))
|
||||
const terminal = new E2BTerminalHandle(
|
||||
fake.sandbox,
|
||||
fake.handle.asHandle(),
|
||||
new PassThrough(),
|
||||
fake.handle.wait(),
|
||||
123,
|
||||
{ TERM: 'dumb' },
|
||||
'/runtime/pre-aborted',
|
||||
1,
|
||||
controller.signal,
|
||||
)
|
||||
|
||||
await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
await terminal.terminate()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -590,18 +570,7 @@ describe('E2B terminal lifecycle', () => {
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec(), `/runtime/exit-${exitCode}`)
|
||||
fake.handle.fail(exitCode)
|
||||
await expect(terminal.done).resolves.toEqual(expected)
|
||||
await expect(terminal.waitForExit(new AbortController().signal)).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('lets an early quiescence observer follow a transport rejection', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.groups = []
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/early-observer')
|
||||
terminal.output.on('error', () => {})
|
||||
const quiescence = terminal.waitForExit()
|
||||
fake.handle.crash(new Error('transport failed'))
|
||||
await expect(terminal.done).rejects.toThrow('transport failed')
|
||||
await expect(quiescence).resolves.toBe(true)
|
||||
await terminal.terminate()
|
||||
})
|
||||
|
||||
it('treats a terminal session containing only zombies as quiescent', async () => {
|
||||
@@ -612,7 +581,7 @@ describe('E2B terminal lifecycle', () => {
|
||||
|
||||
fake.handle.succeed(0)
|
||||
await expect(terminal.done).resolves.toEqual({ exitCode: 0, signal: null })
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
await terminal.terminate()
|
||||
expect(fake.commands).toContain(
|
||||
"set -o pipefail; ps -eo sid=,pgid=,stat= | awk '$1 == 123 && $3 !~ /^[ZXx]/ { print $2 }'",
|
||||
)
|
||||
@@ -625,7 +594,7 @@ describe('E2B terminal lifecycle', () => {
|
||||
fake.handle.succeed(0)
|
||||
|
||||
await expect(terminal.done).resolves.toEqual({ exitCode: 0, signal: null })
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
await terminal.terminate()
|
||||
})
|
||||
|
||||
it('treats sandbox disappearance during PTY kill as quiescent', async () => {
|
||||
@@ -635,8 +604,7 @@ describe('E2B terminal lifecycle', () => {
|
||||
fake.ptyKillError = new SandboxNotFoundError('sandbox expired')
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/expired-pty-kill')
|
||||
|
||||
terminal.terminate()
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
await terminal.terminate()
|
||||
expect(fake.ptyKills).toBe(1)
|
||||
})
|
||||
|
||||
@@ -647,8 +615,11 @@ describe('E2B terminal lifecycle', () => {
|
||||
fake.ptyKillError = new Error('PTY kill transport failed')
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/failed-pty-kill')
|
||||
|
||||
terminal.terminate()
|
||||
await expect(terminal.waitForExit()).rejects.toThrow('PTY kill transport failed')
|
||||
await expect(terminal.terminate()).rejects.toThrow('PTY kill transport failed')
|
||||
fake.ptyKillError = undefined
|
||||
fake.handle.succeed(0)
|
||||
await terminal.done
|
||||
await terminal.terminate()
|
||||
})
|
||||
|
||||
it.each([
|
||||
@@ -661,8 +632,8 @@ describe('E2B terminal lifecycle', () => {
|
||||
fake.groups = []
|
||||
fake.handle.succeed(0)
|
||||
|
||||
if (accepted) await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
else await expect(terminal.waitForExit()).rejects.toThrow('disconnect failed')
|
||||
if (accepted) await expect(terminal.terminate()).resolves.toBeUndefined()
|
||||
else await expect(terminal.terminate()).rejects.toThrow('disconnect failed')
|
||||
})
|
||||
|
||||
it('rejects killing the terminal shell and propagates live foreground failures', async () => {
|
||||
@@ -677,23 +648,17 @@ describe('E2B terminal lifecycle', () => {
|
||||
fake.foregroundFailure = commandError(2)
|
||||
await expect(terminal.inspectForeground()).rejects.toBeInstanceOf(CommandExitError)
|
||||
fake.clearOnTerm = true
|
||||
terminal.terminate()
|
||||
await terminal.waitForExit()
|
||||
await terminal.terminate()
|
||||
})
|
||||
|
||||
it('escalates surviving process groups and bounds an observing wait', async () => {
|
||||
it('escalates surviving process groups', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.groups = [123, 456]
|
||||
fake.clearOnTerm = false
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/escalate')
|
||||
const controller = new AbortController()
|
||||
const observing = terminal.waitForExit(controller.signal)
|
||||
controller.abort()
|
||||
await expect(observing).resolves.toBe(false)
|
||||
|
||||
terminal.terminate()
|
||||
const terminating = terminal.terminate()
|
||||
await expect(terminal.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
await terminating
|
||||
expect(fake.commands).toContain('kill -TERM -- -123 -456')
|
||||
expect(fake.commands).toContain('kill -KILL -- -123 -456')
|
||||
})
|
||||
@@ -702,49 +667,31 @@ describe('E2B terminal lifecycle', () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.groups = [1]
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/retry')
|
||||
terminal.terminate()
|
||||
await expect(terminal.waitForExit(new AbortController().signal)).rejects.toThrow('unsafe process group 1')
|
||||
await expect(terminal.terminate()).rejects.toThrow('unsafe process group 1')
|
||||
|
||||
fake.groups = []
|
||||
fake.handle.succeed(0)
|
||||
await terminal.done
|
||||
terminal.terminate()
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
await terminal.terminate()
|
||||
})
|
||||
|
||||
it('propagates a process-group signalling transport failure before retry', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.termFailure = new Error('signal transport failed')
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/signal-failure')
|
||||
terminal.terminate()
|
||||
await expect(terminal.waitForExit()).rejects.toThrow('signal transport failed')
|
||||
await expect(terminal.terminate()).rejects.toThrow('signal transport failed')
|
||||
|
||||
fake.groups = []
|
||||
fake.handle.succeed(0)
|
||||
await terminal.done
|
||||
terminal.terminate()
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
await terminal.terminate()
|
||||
|
||||
const alreadyExited = new FakeTerminalSandbox()
|
||||
alreadyExited.termFailure = commandError(1)
|
||||
const tolerant = await spawnE2BTerminal(runtime(alreadyExited), spec({ graceMs: 1 }), '/runtime/group-exited')
|
||||
tolerant.terminate()
|
||||
const tolerantTermination = tolerant.terminate()
|
||||
await expect(tolerant.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
|
||||
await expect(tolerant.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('normalizes a non-Error cleanup rejection for an observing wait', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/non-error-cleanup')
|
||||
fake.commandFailure = 'cleanup transport gone'
|
||||
terminal.terminate()
|
||||
await expect(terminal.waitForExit(new AbortController().signal)).rejects.toThrow('cleanup transport gone')
|
||||
|
||||
fake.groups = []
|
||||
fake.handle.succeed(0)
|
||||
await terminal.done
|
||||
terminal.terminate()
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
await tolerantTermination
|
||||
})
|
||||
|
||||
it('keeps command rejection authoritative while cleanup is already waiting', async () => {
|
||||
@@ -753,11 +700,11 @@ describe('E2B terminal lifecycle', () => {
|
||||
fake.removeError = new Error('private state already gone')
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec(), '/runtime/reject-during-cleanup')
|
||||
terminal.output.on('error', () => {})
|
||||
terminal.terminate()
|
||||
const cleanup = terminal.terminate()
|
||||
await Promise.resolve()
|
||||
fake.handle.crash(new Error('command transport failed'))
|
||||
await expect(terminal.done).rejects.toThrow('command transport failed')
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
await cleanup
|
||||
})
|
||||
|
||||
it('keeps a late command rejection authoritative after PTY kill', async () => {
|
||||
@@ -766,12 +713,12 @@ describe('E2B terminal lifecycle', () => {
|
||||
fake.settleOnPtyKill = false
|
||||
const terminal = await spawnE2BTerminal(runtime(fake), spec({ graceMs: 1 }), '/runtime/reject-after-kill')
|
||||
terminal.output.on('error', () => {})
|
||||
terminal.terminate()
|
||||
const cleanup = terminal.terminate()
|
||||
while (fake.ptyKills === 0) await new Promise(resolve => setTimeout(resolve, 0))
|
||||
await Promise.resolve()
|
||||
fake.handle.crash(new Error('late command transport failed'))
|
||||
await expect(terminal.done).rejects.toThrow('late command transport failed')
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
await cleanup
|
||||
})
|
||||
|
||||
it('reports surviving groups, a surviving top-level pid, and transport failure', async () => {
|
||||
@@ -779,15 +726,13 @@ describe('E2B terminal lifecycle', () => {
|
||||
survivor.clearOnTerm = false
|
||||
survivor.clearOnKill = false
|
||||
const terminal = await spawnE2BTerminal(runtime(survivor), spec({ graceMs: 1 }), '/runtime/survivor')
|
||||
terminal.terminate()
|
||||
await expect(terminal.waitForExit()).rejects.toThrow('surviving process groups: 123')
|
||||
await expect(terminal.terminate()).rejects.toThrow('surviving process groups: 123')
|
||||
|
||||
const livePid = new FakeTerminalSandbox()
|
||||
livePid.groups = []
|
||||
livePid.settleOnPtyKill = false
|
||||
const live = await spawnE2BTerminal(runtime(livePid), spec({ graceMs: 1 }), '/runtime/live-pid')
|
||||
live.terminate()
|
||||
await expect(live.waitForExit()).rejects.toThrow('surviving pid: 123')
|
||||
await expect(live.terminate()).rejects.toThrow('surviving pid: 123')
|
||||
livePid.handle.succeed(0)
|
||||
await live.done
|
||||
|
||||
@@ -798,7 +743,7 @@ describe('E2B terminal lifecycle', () => {
|
||||
crashed.handle.crash('transport gone')
|
||||
await expect(failed.done).rejects.toEqual('transport gone')
|
||||
await expect(outputError).resolves.toMatchObject([{ message: 'transport gone' }])
|
||||
await expect(failed.waitForExit()).resolves.toBe(true)
|
||||
await failed.terminate()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -943,7 +888,7 @@ describe('E2B subprocess terminal service', () => {
|
||||
const terminal = await ctx.subprocess.spawnTerminal(spec())
|
||||
fake.handle.succeed(0)
|
||||
await terminal.done
|
||||
await terminal.waitForExit()
|
||||
await terminal.terminate()
|
||||
const signals = fake.commands.filter(command => command.startsWith('kill -')).length
|
||||
await fiber.dispose()
|
||||
expect(fake.commands.filter(command => command.startsWith('kill -'))).toHaveLength(signals)
|
||||
@@ -961,6 +906,6 @@ describe('E2B subprocess terminal service', () => {
|
||||
|
||||
fake.groups = []
|
||||
await fiber.dispose()
|
||||
await expect(terminal.waitForExit()).resolves.toBe(true)
|
||||
await expect(terminal.terminate()).resolves.toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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/fs/README.md
|
||||
README.md: 96bbe6c95bd2aca7e66cf2d57abb3f056cd19390
|
||||
README.zh.md: 7f091b4f0955b847d21b8b3423dab421e610cda3
|
||||
README.md: b15012e882b60847e1ad22edf08d1202ba64fe5b
|
||||
README.zh.md: 628f6c74894bc67559d49f7cf5d1378d0ece2382
|
||||
|
||||
@@ -14,7 +14,7 @@ The filesystem stack: a provider seam (execution-world paths, bounded text IO, a
|
||||
| `tool-fs/` | Model-facing `read`/`write`/`edit` tools AND the executor (reads via `ctx.fs`, owns read windowing, dispatches `fs/*`); preserves filesystem semantics for session-cwd-relative paths and advertises sandbox escalation fields when the mounted `ctx.fs` confines | (registers on `ctx.tools`) |
|
||||
| `tool-fs-search/` | Model-facing `glob`/`grep` discovery tools when `rg` is available on the bash executor `PATH`, backed by fixed ripgrep commands through `ctx.bash`, NOT by `ctx.fs` provider methods | (registers on `ctx.tools`) |
|
||||
|
||||
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas: `fs-sandbox` provides an in-process path fence over the shared sandbox mode ([decision](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)), while `fs-e2b` places file state in the remote runtime shared with the E2B subprocess provider ([POC decision](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).
|
||||
The interface lives at `fs/fs/`. A sandboxed, remote, or project-scoped filesystem backend can replace `fs-local` without touching the seam, the policy gate, or the model-facing tool schemas: `fs-sandbox` provides an in-process path fence over the shared sandbox mode ([decision](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)), while `fs-e2b` places file state in the remote execution world shared with the E2B subprocess provider ([decision](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md)). The policy (`fs-policy/`) is a plugin that participates only through the `fs/*` event gate, not a service the tool injects — so dropping it gracefully loses the policy and leaves the unconstrained bare provider rather than breaking the tool. A deployment that loads `tool-fs/` is expected to also load it. The mode fence and the read-before-edit gate are orthogonal and compose. Discovery (`tool-fs-search/`) deliberately does NOT extend the provider seam: search is a process-backed `rg` workflow on the bash executor, so filesystem backends stay free of a universal search contract; its tools register only when that executor can find `rg`, and its results are follow-up-readable when the bash workdir and the `read` root are the same workspace (the co-located deployment its README documents).
|
||||
|
||||
## No timeouts on file IO
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
| `tool-fs/` | 面向模型的 `read`/`write`/`edit` 工具以及执行器(通过 `ctx.fs` 读取,拥有读取窗口逻辑,分派 `fs/*`);为会话 cwd 相对路径保留文件系统语义,并在已挂载的 `ctx.fs` 实施约束时声明沙箱升级字段 | (注册到 `ctx.tools`) |
|
||||
| `tool-fs-search/` | 面向模型的 `glob`/`grep` 发现工具;当 `rg` 位于 bash 执行器 `PATH` 上时注册,通过 `ctx.bash` 运行固定 ripgrep 命令,而不是使用 `ctx.fs` 提供方方法 | (注册到 `ctx.tools`) |
|
||||
|
||||
接口位于 `fs/fs/`。沙箱化、远程或限定项目作用域的文件系统后端可以替换 `fs-local`,而无需更改 seam、政策门禁或面向模型的工具 schema:`fs-sandbox` 基于共享沙箱模式提供进程内路径围栏([决策](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)),而 `fs-e2b` 则把文件状态置于与 E2B 进程管理提供方共享的远程运行时中([POC 决策](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md))。政策(`fs-policy/`)是一个只通过 `fs/*` 事件门禁参与的插件,不是工具注入的服务;因此移除它会平稳失去政策,留下不受约束的裸提供方,而不会破坏工具。加载 `tool-fs/` 的部署也应加载该插件。模式围栏与编辑前读取门禁彼此正交,可以组合。发现(`tool-fs-search/`)有意不扩展提供方 seam:搜索是在 bash 执行器上运行 `rg`、由进程支持的工作流,因此文件系统后端无需承担通用搜索契约;只有当执行器能找到 `rg` 时,其工具才会注册。如果 bash 工作目录与 `read` 根目录是同一工作区,结果就能继续读取,这也是其 README 所述的共置部署。
|
||||
接口位于 `fs/fs/`。沙箱化、远程或限定项目作用域的文件系统后端可以替换 `fs-local`,而无需更改 seam、政策门禁或面向模型的工具 schema:`fs-sandbox` 基于共享沙箱模式提供进程内路径围栏([决策](../../.agents/notes/implemented/feature/2026-07-14-cross-family-fs-sandbox.md)),而 `fs-e2b` 则把文件状态置于与 E2B 进程管理提供方共享的远程执行世界中([决策](../../.agents/notes/implemented/architecture/2026-07-28-portable-execution-world-consumers.md))。政策(`fs-policy/`)是一个只通过 `fs/*` 事件门禁参与的插件,不是工具注入的服务;因此移除它会平稳失去政策,留下不受约束的裸提供方,而不会破坏工具。加载 `tool-fs/` 的部署也应加载该插件。模式围栏与编辑前读取门禁彼此正交,可以组合。发现(`tool-fs-search/`)有意不扩展提供方 seam:搜索是在 bash 执行器上运行 `rg`、由进程支持的工作流,因此文件系统后端无需承担通用搜索契约;只有当执行器能找到 `rg` 时,其工具才会注册。如果 bash 工作目录与 `read` 根目录是同一工作区,结果就能继续读取,这也是其 README 所述的共置部署。
|
||||
|
||||
## 文件 I/O 不设超时
|
||||
|
||||
|
||||
Reference in New Issue
Block a user