fix(e2b): harden remote adapter boundaries

This commit is contained in:
Tianyi Cui
2026-07-28 18:31:37 +08:00
parent dc44f93c39
commit 3e343b4477
33 changed files with 939 additions and 146 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: 3b3bfa88e7e6483decfcdec11355942ae4ff7403
README.zh.md: 3ff9a51c60636dea5789f9dd11b04aa902b91d0c
README.md: 066d35a099f560fe40fe629ede632c37535129f7
README.zh.md: d3af14e8cc58c7d86331156358f78a0abbb24c39

View File

@@ -6,8 +6,8 @@ E2B implementation of the [`@deepseek-ai/dsh-subprocess`](../../subprocess/subpr
## Behavior
- **Asynchronous remote start** — the synchronous seam returns a handle immediately while `Sandbox.commands.run(..., { background: true })` starts remotely. `pid` is `-1` until the SDK returns the command PID; `done`, stdin, termination, and `waitForExit()` wait for readiness internally.
- **Linux process groups** — a quoted wrapper starts each argv under `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 assuming the SDK command PID is the group id. Termination signals the negative recorded id with `SIGTERM`, waits the caller's `graceMs`, then escalates to `SIGKILL` and the SDK kill fallback. Service disposal terminates and joins every retained handle before the sandbox owner disposes.
- **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; `done`, stdin, termination, and `waitForExit()` wait for readiness internally.
- **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. If publication fails, the SDK PID remains the provisional `exec setsid` group id; rollback kills and verifies that group before startup rejects. Service disposal terminates and joins every retained handle before the sandbox owner disposes.
- **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.

View File

@@ -6,8 +6,8 @@
## 行为
- **异步远程启动**:同步 seam 会立即返回一个句柄,同时由 `Sandbox.commands.run(..., { background: true })` 在远程启动进程。SDK 返回命令 PID 之前,`pid``-1``done`、stdin、终止和 `waitForExit()` 会在内部等待就绪。
- **Linux 进程组**:带引号保护的包装层会在 `setsid --wait` 下启动每组 argv并在 `ctx.e2b.runtimeRoot/processes` 下记录实际进程组 ID 和私有状态文件。句柄会等待该文件,而不会假设 SDK 命令 PID 就是进程组 ID。终止操作以记录的负数 ID 发送 `SIGTERM`,等待调用方的 `graceMs`,再升级到 `SIGKILL` 和 SDK kill 回退。服务 dispose资源释放会在沙箱所有者释放前终止并等待每个保留句柄退出。
- **异步远程启动**:同步 seam 会立即返回一个句柄,同时由 `Sandbox.commands.run(..., { background: true })` 在远程启动进程。包装层发布进程组 ID 并由适配器完成验证之前,`pid``-1``done`、stdin、终止和 `waitForExit()` 会在内部等待就绪。
- **Linux 进程组**:带引号保护的包装层会在 `exec setsid --wait` 下启动每组 argv并在 `ctx.e2b.runtimeRoot/processes` 下记录实际进程组 ID 和私有状态文件。句柄会等待该文件,而不会 SDK 命令 PID 当作已发布的身份。终止操作以记录的负数 ID 发送 `SIGTERM`,等待调用方的 `graceMs`,再升级到 `SIGKILL` 和 SDK kill 回退。如果发布失败SDK PID 仍为临时的 `exec setsid` 进程组 ID回滚会终止并验证该进程组随后启动操作才会以拒绝结束。服务 dispose资源释放会在沙箱所有者释放前终止并等待每个保留句柄退出。
- **环境边界**:包装层从沙箱命令环境开始,移除环境中的 `DSH_*` 和形似凭据的名称(`*KEY*``*SECRET*``*TOKEN*`),再把每个 `spec.env` 条目恢复为调用方显式选择。宿主环境变量绝不会隐式进入沙箱。
- **stdio 投影**pipe 模式把 E2B 回调转发到宿主 Node 流inherit 模式把回调转发到 harness 进程流collect 模式保留有界的宿主尾部,并支持基于偏移量读取。可选的完整 spill 文件写在远程,并且只有未超过其上限时才会对外公布。批量 stdin 和流式 stdin 都使用 SDK 句柄。

View File

@@ -253,7 +253,14 @@ export class E2BSubprocessHandle implements SubprocessHandle {
try {
this.remotePid = await this.waitForProcessGroupId(sandbox, completion)
} catch (error: unknown) {
await Promise.allSettled([handle.kill()])
try {
await this.rollbackUnpublishedGroup(sandbox, handle)
} catch (cleanupError: unknown) {
throw new AggregateError(
[error, cleanupError],
'subprocess-e2b: process-group publication failed and rollback did not reach quiescence',
)
}
throw error
}
this.readyState.resolve(handle)
@@ -361,6 +368,19 @@ export class E2BSubprocessHandle implements SubprocessHandle {
}
}
private async rollbackUnpublishedGroup(sandbox: Sandbox, handle: CommandHandle): Promise<void> {
// The background command begins with `exec setsid`, so E2B's command PID is
// the provisional group id even before the private publication file can be
// trusted. Kill that group before the SDK-PID fallback, then prove no group
// member survived before rejecting startup.
try {
await this.signalGroup(sandbox, handle.pid, 'KILL')
} finally {
await handle.kill().catch(() => false)
}
while (await this.groupAlive(sandbox, handle.pid)) await waitTick()
}
private async terminateRemote(): Promise<void> {
let handle: CommandHandle
try {

View File

@@ -89,6 +89,7 @@ class FakeSandbox {
probeError: unknown
signalError: unknown
trapsTerm = false
delaysKill = false
alive = true
processGroupId = '4242\n'
readonly processGroupReads: string[] = []
@@ -176,7 +177,7 @@ class FakeSandbox {
this.signalError = undefined
throw error
}
this.alive = false
if (!this.delaysKill) this.alive = false
this.handle.fail(137)
return { exitCode: 0, stdout: '', stderr: '' }
}
@@ -596,14 +597,13 @@ 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
})
invalidGroup.delaysKill = true
invalidGroup.afterProbe = () => { invalidGroup.alive = false }
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)
expect(invalidGroup.commandsSeen).toContain('kill -KILL -- -4242')
await expect(invalid.waitForExit()).resolves.toBe(true)
const absentGroup = new FakeSandbox()
absentGroup.processGroupId = ''
@@ -612,6 +612,35 @@ describe('E2BSubprocessHandle', () => {
absentGroup.finish()
await expect(absent.done).rejects.toThrow(/exited before publishing/)
expect(absentGroup.handle.kills).toBe(1)
expect(absentGroup.commandsSeen).toContain('kill -KILL -- -4242')
await expect(absent.waitForExit()).resolves.toBe(true)
})
it('preserves publication and rollback failures when cleanup cannot be verified', async () => {
const fake = new FakeSandbox()
fake.processGroupId = 'not-a-pid\n'
fake.signalError = new Error('rollback signal failed')
fake.handle.killError = new Error('SDK kill failed')
const handle = new E2BSubprocessHandle(runtime(fake), spec(), '/runtime/failed-rollback')
let failure: unknown
try {
await handle.done
} catch (error: unknown) {
failure = error
}
expect(failure).toBeInstanceOf(AggregateError)
if (!(failure instanceof AggregateError)) throw new Error('expected AggregateError')
expect(failure.message).toBe('subprocess-e2b: process-group publication failed and rollback did not reach quiescence')
const failures = Array.from(failure.errors as Iterable<unknown>)
expect(failures).toHaveLength(2)
expect(failures[0]).toBeInstanceOf(Error)
expect(failures[1]).toBeInstanceOf(Error)
if (!(failures[0] instanceof Error) || !(failures[1] instanceof Error)) throw new Error('expected nested errors')
expect(failures[0].message).toContain('invalid process-group id')
expect(failures[1].message).toBe('rollback signal failed')
expect(fake.handle.kills).toBe(1)
fake.finish()
})
it('waits for delayed process-group publication', async () => {