fix(e2b): close remote lifecycle gaps
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/e2b/subprocess-e2b/README.md
|
||||
README.md: ce61de1100791be6e5c4db74c73ff43566281ed9
|
||||
README.zh.md: a4c619f0c002cc1d36310c5a9b4a3f7ae655ad1f
|
||||
README.md: 3b3bfa88e7e6483decfcdec11355942ae4ff7403
|
||||
README.zh.md: 3ff9a51c60636dea5789f9dd11b04aa902b91d0c
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`。自定义模板必须保留兼容的命令。
|
||||
|
||||
## 模型体验
|
||||
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user