fix(e2b): close remote lifecycle gaps

This commit is contained in:
Tianyi Cui
2026-07-28 16:26:20 +08:00
parent e64d40837c
commit 3dea36f1ce
22 changed files with 568 additions and 181 deletions

View File

@@ -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: ce61de1100791be6e5c4db74c73ff43566281ed9
README.zh.md: a4c619f0c002cc1d36310c5a9b4a3f7ae655ad1f
README.md: 3b3bfa88e7e6483decfcdec11355942ae4ff7403
README.zh.md: 3ff9a51c60636dea5789f9dd11b04aa902b91d0c

View File

@@ -11,7 +11,7 @@ E2B implementation of the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subpr
- **Environment boundary** — the wrapper starts from the sandbox command environment, removes ambient `DSH_*` and credential-shaped (`*KEY*`, `*SECRET*`, `*TOKEN*`) names, then restores every `spec.env` entry as an explicit caller opt-in. Host ambient variables never enter the sandbox implicitly.
- **Stdio projection** — pipe mode forwards E2B callbacks into host Node streams; inherit mode forwards them to the harness process streams; collect mode retains a bounded host tail with offset reads. Optional complete spill files are written remotely and advertised only while within their cap. Batch and streaming stdin use the SDK handle.
The base E2B image supplies the Bash/GNU utilities this adapter invokes: `bash`, `setsid`, `ps`, `tr`, `env`, `chmod`, `tee`, and `kill`. A custom template must retain compatible commands.
The base E2B image supplies the Bash/GNU utilities this adapter invokes: `bash`, `setsid`, `ps`, `tr`, `env`, `chmod`, `tee`, `head`, and `kill`. A custom template must retain compatible commands.
## Model Experience

View File

@@ -11,7 +11,7 @@
- **环境边界**:包装层从沙箱命令环境开始,移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*``*SECRET*``*TOKEN*`),再把每个 `spec.env` 条目恢复为调用方显式选择。宿主环境变量绝不会隐式进入沙箱。
- **stdio 投影**pipe 模式把 E2B 回调转发到宿主 Node 流inherit 模式把回调转发到 harness 进程流collect 模式保留有界的宿主尾部,并支持基于偏移量读取。可选的完整 spill 文件写在远程,并且只有未超过其上限时才会对外公布。批量 stdin 和流式 stdin 都使用 SDK 句柄。
基础 E2B 镜像提供该适配器调用的 Bash/GNU 工具:`bash``setsid``ps``tr``env``chmod``tee``kill`。自定义模板必须保留兼容的命令。
基础 E2B 镜像提供该适配器调用的 Bash/GNU 工具:`bash``setsid``ps``tr``env``chmod``tee``head``kill`。自定义模板必须保留兼容的命令。
## 模型体验

View File

@@ -55,37 +55,38 @@ class DeferredStdin extends Writable {
interface RemotePaths {
pid: string
status: string
environment: string
stdout: string
stderr: string
}
function explicitEnvironmentNames(env: Readonly<Record<string, string>> | undefined): string {
return Object.keys(env ?? {})
.map(quoteE2BShellArg)
.join(' ')
function explicitEnvironment(env: Readonly<Record<string, string>> | undefined): string {
return Object.entries(env ?? {})
.map(([name, value]) => `${name}=${value}\0`)
.join('')
}
function commandText(spec: SubprocessSpawnSpec, paths: RemotePaths): string {
const stdoutRedirect = hasSpill(spec.stdio.stdout)
? `> >(tee -a -- ${quoteE2BShellArg(paths.stdout)})`
? `> >(tee --output-error=warn-nopipe >(head -c ${spec.stdio.stdout.spill.maxBytes} > ${quoteE2BShellArg(paths.stdout)}))`
: ''
const stderrRedirect = hasSpill(spec.stdio.stderr)
? `2> >(tee -a -- ${quoteE2BShellArg(paths.stderr)} >&2)`
? `2> >(tee --output-error=warn-nopipe >(head -c ${spec.stdio.stderr.spill.maxBytes} > ${quoteE2BShellArg(paths.stderr)}) >&2)`
: ''
const environmentNames = explicitEnvironmentNames(spec.env)
const inner = [
'set +e',
'umask 077',
'dsh_e2b_pgid="$(ps -o pgid= -p "$$" | tr -d " ")"',
`printf '%s\\n' "$dsh_e2b_pgid" > ${quoteE2BShellArg(paths.pid)}`,
`mapfile -d '' -t dsh_e2b_explicit < ${quoteE2BShellArg(paths.environment)}`,
`: > ${quoteE2BShellArg(paths.environment)}`,
'dsh_e2b_env=()',
`dsh_e2b_explicit=(${environmentNames})`,
'while IFS= read -r dsh_e2b_name; do',
"while IFS= read -r -d '' dsh_e2b_entry; do",
' dsh_e2b_name="${dsh_e2b_entry%%=*}"',
' case "${dsh_e2b_name^^}" in DSH_*|*KEY*|*SECRET*|*TOKEN*) continue ;; esac',
' dsh_e2b_env+=("$dsh_e2b_name=${!dsh_e2b_name}")',
'done < <(compgen -e)',
'for dsh_e2b_name in "${dsh_e2b_explicit[@]}"; do dsh_e2b_env+=("$dsh_e2b_name=${!dsh_e2b_name}"); done',
`env -i "\${dsh_e2b_env[@]}" "$@" ${stdoutRedirect} ${stderrRedirect}`.trimEnd(),
' dsh_e2b_env+=("$dsh_e2b_entry")',
'done < <(env -0)',
`env -i "\${dsh_e2b_env[@]}" "\${dsh_e2b_explicit[@]}" "$@" ${stdoutRedirect} ${stderrRedirect}`.trimEnd(),
'dsh_e2b_status=$?',
'wait',
`printf '%s\\n' "$dsh_e2b_status" > ${quoteE2BShellArg(paths.status)}`,
@@ -131,7 +132,6 @@ export class E2BSubprocessHandle implements SubprocessHandle {
private readonly stderrReader: E2BOutputReader | undefined
private readonly paths: RemotePaths
private remotePid = -1
private settled = false
private terminationRequested = false
private terminationSignal: NodeJS.Signals | null = null
private termination: Promise<void> | undefined
@@ -150,6 +150,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
this.paths = {
pid: posix.join(stateDir, 'pid'),
status: posix.join(stateDir, 'exit-code'),
environment: posix.join(stateDir, 'environment'),
stdout: posix.join(stateDir, 'stdout.log'),
stderr: posix.join(stateDir, 'stderr.log'),
}
@@ -182,7 +183,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
/** @inheritdoc */
terminate(): void {
if (this.terminationRequested || this.settled) return
if (this.terminationRequested) return
this.terminationRequested = true
this.termination = this.terminateRemote()
void this.termination.catch(() => {})
@@ -240,7 +241,6 @@ export class E2BSubprocessHandle implements SubprocessHandle {
cwd: this.spec.cwd,
stdin: this.spec.stdio.stdin !== 'ignore',
timeoutMs: 0,
...(this.spec.env !== undefined ? { envs: this.spec.env } : {}),
onStdout: async (data) => { await this.dispatchOutput('stdout', data) },
onStderr: async (data) => { await this.dispatchOutput('stderr', data) },
},
@@ -250,7 +250,12 @@ export class E2BSubprocessHandle implements SubprocessHandle {
}
const completion = handle.wait()
void completion.catch(() => {})
this.remotePid = await this.waitForProcessGroupId(sandbox, completion)
try {
this.remotePid = await this.waitForProcessGroupId(sandbox, completion)
} catch (error: unknown) {
await Promise.allSettled([handle.kill()])
throw error
}
this.readyState.resolve(handle)
await this.writeBatchStdin(handle)
const outcome = await this.waitForCommand(completion)
@@ -260,7 +265,6 @@ export class E2BSubprocessHandle implements SubprocessHandle {
this.readyState.reject(error)
throw error
} finally {
this.settled = true
this.spec.signal?.removeEventListener('abort', this.onAbort)
this.stdout?.end()
this.stderr?.end()
@@ -269,17 +273,16 @@ export class E2BSubprocessHandle implements SubprocessHandle {
private async prepareState(sandbox: Sandbox): Promise<void> {
await sandbox.files.makeDir(this.stateDir)
await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(this.stateDir)}`)
const files = [
{ path: this.paths.pid, data: '' },
{ path: this.paths.status, data: '' },
{ path: this.paths.environment, data: explicitEnvironment(this.spec.env) },
...(hasSpill(this.spec.stdio.stdout) ? [{ path: this.paths.stdout, data: '' }] : []),
...(hasSpill(this.spec.stdio.stderr) ? [{ path: this.paths.stderr, data: '' }] : []),
]
await sandbox.files.write(files)
await sandbox.commands.run([
`chmod 700 -- ${quoteE2BShellArg(this.stateDir)}`,
`chmod 600 -- ${files.map(file => quoteE2BShellArg(file.path)).join(' ')}`,
].join('\n'))
await sandbox.commands.run(`chmod 600 -- ${files.map(file => quoteE2BShellArg(file.path)).join(' ')}`)
}
private async writeBatchStdin(handle: CommandHandle): Promise<void> {

View File

@@ -80,6 +80,7 @@ class FakeSandbox {
readonly handle = new FakeCommandHandle()
readonly commandsSeen: string[] = []
readonly writtenFiles: string[][] = []
readonly writtenFileData = new Map<string, string>()
readonly removed: string[] = []
readonly directories: string[] = []
startOptions: StartOptions | undefined
@@ -129,6 +130,7 @@ class FakeSandbox {
},
write: async (files: Array<{ path: string; data: string }>): Promise<object[]> => {
this.writtenFiles.push(files.map(file => file.path))
for (const file of files) this.writtenFileData.set(file.path, file.data)
return files.map(() => ({}))
},
read: async (): Promise<string> => this.processGroupReads.shift() ?? this.processGroupId,
@@ -251,7 +253,7 @@ describe('E2BSubprocessHandle', () => {
const handle = new E2BSubprocessHandle(runtime(fake), spec({
argv: ['tool', 'argument with spaces'],
stdio: { stdin: 'pipe', stdout: 'pipe', stderr: { maxBytes: 8, spill: { maxBytes: 32 } } },
env: { PATH: '/bin', DEEPSEEK_API_KEY: 'explicit-secret', DSH_MODE: 'test' },
env: { PATH: '/bin', 'FOO-BAR': 'hyphen-value', DEEPSEEK_API_KEY: 'explicit-secret', DSH_MODE: 'test' },
}), '/workspace/.dsh-e2b/processes/one')
expect(handle.pid).toBe(-1)
handle.stdin!.write('hello')
@@ -261,17 +263,26 @@ describe('E2BSubprocessHandle', () => {
expect(handle.pid).toBe(4343)
expect(fake.handle.sent.map(value => String(value))).toEqual(['hello'])
expect(fake.handle.closes).toBe(1)
expect(fake.startOptions?.envs).toEqual({ PATH: '/bin', DEEPSEEK_API_KEY: 'explicit-secret', DSH_MODE: 'test' })
expect(fake.startOptions?.envs).toBeUndefined()
const command = fake.commandsSeen.find(value => value.startsWith('exec setsid'))!
expect(command).toContain('exec setsid --wait -- bash -c')
expect(command).toContain('DEEPSEEK_API_KEY')
expect(command).toContain('DSH_MODE')
expect(command).not.toContain('DEEPSEEK_API_KEY')
expect(command).not.toContain('DSH_MODE')
expect(command).not.toContain('FOO-BAR')
expect(command).not.toContain('explicit-secret')
expect(command).not.toContain('hyphen-value')
expect(command).not.toContain('${!dsh_e2b_name}')
expect(command).toContain('env -0')
expect(command).toContain('mapfile -d')
expect(fake.writtenFiles[0]).toEqual([
'/workspace/.dsh-e2b/processes/one/pid',
'/workspace/.dsh-e2b/processes/one/exit-code',
'/workspace/.dsh-e2b/processes/one/environment',
'/workspace/.dsh-e2b/processes/one/stderr.log',
])
expect(fake.writtenFileData.get('/workspace/.dsh-e2b/processes/one/environment')).toBe(
'PATH=/bin\0FOO-BAR=hyphen-value\0DEEPSEEK_API_KEY=explicit-secret\0DSH_MODE=test\0',
)
let piped = ''
handle.stdout!.on('data', (chunk) => { piped += String(chunk) })
@@ -350,6 +361,11 @@ describe('E2BSubprocessHandle', () => {
await handle.done
expect(handle.collected.stdout!.readFrom(0)).toEqual({ text: 'cd', nextOffset: 4, lossy: true })
expect(fake.removed).toContain('/runtime/oversize/stdout.log')
const command = fake.commandsSeen.find(value => value.startsWith('exec setsid'))!
expect(command).toContain('head -c 3')
expect(command).toContain('/runtime/oversize/stdout.log')
expect(command).toContain('tee --output-error=warn-nopipe')
expect(command).not.toContain('tee -a')
})
it('contains remote spill-removal failures and routes empty inherited output', async () => {
@@ -415,6 +431,22 @@ describe('E2BSubprocessHandle', () => {
await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGTERM' })
})
it('can terminate a surviving process group after the command leader settles', async () => {
const fake = new FakeSandbox()
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/surviving-group')
await flush()
fake.handle.succeed(0)
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
expect(fake.alive).toBe(true)
handle.terminate()
await flush()
const signaled = fake.commandsSeen.includes('kill -TERM -- -4242')
if (!signaled) fake.finish()
await expect(handle.waitForExit()).resolves.toBe(true)
expect(signaled).toBe(true)
})
it('bounds waitForExit while startup or a live group is pending', async () => {
const fake = new FakeSandbox()
fake.deferStart()
@@ -564,8 +596,14 @@ describe('E2BSubprocessHandle', () => {
it('rejects invalid or absent process-group publication', async () => {
const invalidGroup = new FakeSandbox()
invalidGroup.processGroupId = 'not-a-pid\n'
vi.spyOn(invalidGroup.handle, 'kill').mockImplementation(async () => {
invalidGroup.handle.kills += 1
invalidGroup.finish()
return true
})
const invalid = new E2BSubprocessHandle(runtime(invalidGroup), spec(), '/runtime/invalid-group')
await expect(invalid.done).rejects.toThrow(/invalid process-group id/)
expect(invalidGroup.handle.kills).toBe(1)
const absentGroup = new FakeSandbox()
absentGroup.processGroupId = ''
@@ -573,6 +611,7 @@ describe('E2BSubprocessHandle', () => {
await flush()
absentGroup.finish()
await expect(absent.done).rejects.toThrow(/exited before publishing/)
expect(absentGroup.handle.kills).toBe(1)
})
it('waits for delayed process-group publication', async () => {