fix(e2b): harden remote adapter boundaries
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: 3b3bfa88e7e6483decfcdec11355942ae4ff7403
|
||||
README.zh.md: 3ff9a51c60636dea5789f9dd11b04aa902b91d0c
|
||||
README.md: 066d35a099f560fe40fe629ede632c37535129f7
|
||||
README.zh.md: d3af14e8cc58c7d86331156358f78a0abbb24c39
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 句柄。
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user