fix(e2b): harden SDK shell and cleanup boundaries

E2B starts command and PTY requests through login shells, so isolate each control shell behind a fresh randomized HOME and blank sandbox credential names before mutable profiles can run. Preserve the real remote HOME only for the requested argv.

Collapse duplicate termination state, keep failed force cleanup retryable until quiescence is observed, and make terminal state allocation cancellable. Leave numeric PGID reuse as an explicit provider-level TODO because a userspace precheck would remain TOCTOU.
This commit is contained in:
Tianyi Cui
2026-08-08 22:19:12 +08:00
parent 091af03a81
commit 81e2e1f647
21 files changed
+438 -211

No files matched your search

+85 -97
View File
@@ -5,6 +5,7 @@ import { PassThrough, Writable } from 'node:stream'
import { posix } from 'node:path'
import {
CommandExitError,
e2bControlEnvs,
FileNotFoundError,
SandboxNotFoundError,
quoteE2BShellArg,
@@ -18,7 +19,7 @@ import type {
SubprocessSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
import { readRemoteEnvironment, serializeRemoteEnvironment } from './environment.ts'
import { bootstrapEnvironment, readRemoteEnvironment, serializeRemoteEnvironment } from './environment.ts'
import { E2BBase64Decoder, E2B_OUTPUT_COMPLETE_FRAME, E2BOutputReader } from './output.ts'
const GROUP_POLL_MS = 20
@@ -142,8 +143,11 @@ function commandText(spec: SubprocessSpawnSpec, paths: RemotePaths): string {
return bootstrap
}
function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } {
return signal === undefined ? {} : { signal }
function commandOpts(
envs: Record<string, string>,
signal: AbortSignal | undefined,
): { envs: Record<string, string>; signal?: AbortSignal } {
return { envs: e2bControlEnvs(envs), ...(signal === undefined ? {} : { signal }) }
}
function isAborted(signal: AbortSignal | undefined): boolean {
@@ -197,21 +201,18 @@ export class E2BSubprocessHandle implements SubprocessHandle {
private readonly readyState = Promise.withResolvers<CommandHandle>()
private readonly stdoutDecoder = new E2BBase64Decoder()
private readonly stderrDecoder = new E2BBase64Decoder()
private readonly outputTermination = new AbortController()
private readonly startupController = new AbortController()
private readonly terminationController = new AbortController()
private readonly stdoutReader: E2BOutputReader | undefined
private readonly stderrReader: E2BOutputReader | undefined
private readonly paths: RemotePaths
private controlEnvs: Record<string, string> = {}
private remotePid = -1
private commandHandle: CommandHandle | undefined
private outputTransportError: Error | undefined
private outputDrainExpired = false
private stateDirectoryCreated = false
private preparing = true
private invalidHandleQuiescent = false
private provisionalHandleQuiescent = false
private terminationStarted = false
private terminationFenced = false
private quiescenceProven = false
private terminationAttempt: Promise<void> | undefined
private terminationFailure: Error | undefined
@@ -264,20 +265,16 @@ export class E2BSubprocessHandle implements SubprocessHandle {
/** @inheritdoc */
terminate(): void {
if (this.terminationFenced || this.quiescenceProven || this.terminationAttempt !== undefined) return
if (this.quiescenceProven || this.terminationAttempt !== undefined) return
this.terminationStarted = true
if (this.preparing) this.startupController.abort(new Error('subprocess-e2b: command terminated during startup'))
this.outputTermination.abort()
this.terminationController.abort(new Error('subprocess-e2b: command terminated'))
this.stdout?.destroy()
this.stderr?.destroy()
this.terminationFailure = undefined
const attempt = this.terminateRemote()
this.terminationAttempt = attempt
void attempt.then(
() => {
this.terminationFenced = true
this.terminationAttempt = undefined
},
() => { this.terminationAttempt = undefined },
(error: unknown) => {
if (!this.quiescenceProven) this.terminationFailure = asError(error)
this.terminationAttempt = undefined
@@ -301,11 +298,8 @@ export class E2BSubprocessHandle implements SubprocessHandle {
const attempt = this.terminationAttempt
if (attempt !== undefined && await waitWithSignal(attempt, signal) === WAIT_ABORTED) return false
this.throwTerminationFailure()
/* v8 ignore else -- successful provisional cleanup always records one proof; failures throw above. */
if (this.invalidHandleQuiescent || this.provisionalHandleQuiescent) {
this.markQuiescent()
return true
}
// Successful pre-publication termination records quiescence; its only other outcome is the failure above.
return true
}
} else {
try {
@@ -361,6 +355,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
{
background: true,
cwd: this.spec.cwd,
envs: e2bControlEnvs(this.controlEnvs),
stdin: this.spec.stdio.stdin !== 'ignore',
timeoutMs: 0,
onStdout: async (data) => { await this.dispatchOutput('stdout', data) },
@@ -374,7 +369,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
const invalidPid = new Error(`subprocess-e2b: E2B returned invalid command pid ${handle.pid}`)
try {
await handle.kill()
this.invalidHandleQuiescent = true
this.markQuiescent()
this.commandHandle = undefined
} catch (cleanupError: unknown) {
this.terminationFailure = asError(cleanupError)
@@ -412,7 +407,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
} catch (error: unknown) {
const canceledPreparation = this.preparing
&& this.terminationStarted
&& this.startupController.signal.aborted
&& this.terminationController.signal.aborted
let failure = await this.rollbackPublishedFailure(error)
if (sandbox !== undefined && this.stateDirectoryCreated) {
try {
@@ -437,11 +432,15 @@ export class E2BSubprocessHandle implements SubprocessHandle {
}
private async prepareState(sandbox: Sandbox): Promise<void> {
const signal = this.startupController.signal
const signal = this.terminationController.signal
const ambient = await readRemoteEnvironment(sandbox, signal)
this.controlEnvs = bootstrapEnvironment(ambient)
await sandbox.files.makeDir(this.stateDir, { signal })
this.stateDirectoryCreated = true
await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(this.stateDir)}`, { signal })
await sandbox.commands.run(
`chmod 700 -- ${quoteE2BShellArg(this.stateDir)}`,
commandOpts(this.controlEnvs, signal),
)
const files = [
{ path: this.paths.pid, data: '' },
{ path: this.paths.status, data: '' },
@@ -450,7 +449,10 @@ export class E2BSubprocessHandle implements SubprocessHandle {
...(hasSpill(this.spec.stdio.stderr) ? [{ path: this.paths.stderr, data: '' }] : []),
]
await sandbox.files.write(files, { signal })
await sandbox.commands.run(`chmod 600 -- ${files.map(file => quoteE2BShellArg(file.path)).join(' ')}`, { signal })
await sandbox.commands.run(
`chmod 600 -- ${files.map(file => quoteE2BShellArg(file.path)).join(' ')}`,
commandOpts(this.controlEnvs, signal),
)
signal.throwIfAborted()
}
@@ -490,7 +492,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
private async writeOutput(pipe: PassThrough | undefined, inherited: NodeJS.WriteStream | undefined, data: Uint8Array): Promise<void> {
const target = pipe ?? inherited
if (target === undefined || data.length === 0 || this.outputTermination.signal.aborted) return
if (target === undefined || data.length === 0 || this.terminationController.signal.aborted) return
if (target.destroyed) throw new Error('subprocess output stream is closed')
if (target.write(data)) return
await new Promise<void>((resolve, reject) => {
@@ -502,13 +504,13 @@ export class E2BSubprocessHandle implements SubprocessHandle {
target.removeListener('drain', onDrain)
target.removeListener('close', onClose)
target.removeListener('error', onError)
this.outputTermination.signal.removeEventListener('abort', onTermination)
this.terminationController.signal.removeEventListener('abort', onTermination)
}
target.once('drain', onDrain)
target.once('close', onClose)
target.once('error', onError)
this.outputTermination.signal.addEventListener('abort', onTermination, { once: true })
if (this.outputTermination.signal.aborted) onTermination()
this.terminationController.signal.addEventListener('abort', onTermination, { once: true })
if (this.terminationController.signal.aborted) onTermination()
})
}
@@ -596,12 +598,8 @@ export class E2BSubprocessHandle implements SubprocessHandle {
// `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()
await this.forceKillGroup(sandbox, handle, handle.pid)
this.markQuiescent()
}
private async terminateRemote(): Promise<void> {
@@ -618,91 +616,76 @@ export class E2BSubprocessHandle implements SubprocessHandle {
private async terminateRemoteInSandbox(): Promise<void> {
const handle = await this.commandState.promise
if (handle === undefined) return
if (handle === undefined) {
this.markQuiescent()
return
}
if (!isValidProcessId(handle.pid) && this.remotePid <= 0) {
await handle.kill()
this.invalidHandleQuiescent = true
this.markQuiescent()
this.commandHandle = undefined
return
}
if (this.remotePid <= 0) {
const sandbox = await this.runtime.getSandbox()
this.terminationSignal = 'SIGTERM'
try {
const delivered = await this.signalGroup(sandbox, handle.pid, 'TERM')
if (delivered) {
const deadline = Date.now() + this.spec.graceMs
while (Date.now() < deadline && await this.groupAlive(sandbox, handle.pid)) await waitTick()
if (!await this.groupAlive(sandbox, handle.pid)) {
this.provisionalHandleQuiescent = true
return
}
}
} catch (_gracefulTerminationFailure) {
// A missing or unobservable provisional group still has the SDK handle fallback.
}
this.terminationSignal = 'SIGKILL'
let groupDelivered = false
let groupFailure: unknown
try {
groupDelivered = await this.signalGroup(sandbox, handle.pid, 'KILL')
} catch (error: unknown) {
groupFailure = error
}
let handleFailure: unknown
try {
if (!await handle.kill()) handleFailure = new Error('E2B SDK kill did not report command termination')
} catch (error: unknown) {
handleFailure = error
}
if (!groupDelivered && await this.groupAlive(sandbox, handle.pid)) {
throw new AggregateError(
[
...(groupFailure === undefined ? [] : [groupFailure]),
...(handleFailure === undefined
? [new Error('E2B SDK kill did not quiesce the provisional process group')]
: [handleFailure]),
],
'subprocess-e2b: force termination failed through both process-group and SDK transports',
)
}
while (await this.groupAlive(sandbox, handle.pid)) await waitTick()
this.provisionalHandleQuiescent = true
return
}
const sandbox = await this.runtime.getSandbox()
const processGroupId = this.remotePid
const processGroupId = this.remotePid > 0 ? this.remotePid : handle.pid
await this.terminateGroup(sandbox, handle, processGroupId)
}
private async terminateGroup(sandbox: Sandbox, handle: CommandHandle, processGroupId: number): Promise<void> {
this.terminationSignal = 'SIGTERM'
try {
await this.signalGroup(sandbox, processGroupId, 'TERM')
const deadline = Date.now() + this.spec.graceMs
while (Date.now() < deadline && await this.groupAlive(sandbox, processGroupId)) {
await waitTick()
if (await this.waitForGroupExit(sandbox, processGroupId)) {
this.markQuiescent()
return
}
if (!await this.groupAlive(sandbox, processGroupId)) return
} catch (_gracefulTerminationFailure) {
// Failed TERM delivery or observation cannot prove exit; force cleanup still owns the group.
}
this.terminationSignal = 'SIGKILL'
await this.forceKillGroup(sandbox, handle, processGroupId)
this.markQuiescent()
}
private async forceKillGroup(sandbox: Sandbox, handle: CommandHandle, processGroupId: number): Promise<void> {
let groupFailure: unknown
let groupDelivered = false
try {
groupDelivered = await this.signalGroup(sandbox, processGroupId, 'KILL')
if (!await this.signalGroup(sandbox, processGroupId, 'KILL')) {
groupFailure = new Error('process-group KILL did not report delivery')
}
} catch (error: unknown) {
groupFailure = error
}
let handleFailure: unknown
try {
await handle.kill()
if (!await handle.kill()) handleFailure = new Error('E2B SDK kill did not report command termination')
} catch (error: unknown) {
handleFailure = error
}
if (!groupDelivered && handleFailure !== undefined && await this.groupAlive(sandbox, processGroupId)) {
throw new AggregateError(
[...(groupFailure === undefined ? [] : [groupFailure]), handleFailure],
'subprocess-e2b: force termination failed through both process-group and SDK transports',
)
let proofFailure: unknown
try {
if (await this.waitForGroupExit(sandbox, processGroupId)) return
proofFailure = new Error(`remote process group ${processGroupId} remained live after force termination`)
} catch (error: unknown) {
proofFailure = error
}
throw new AggregateError(
[
...(groupFailure === undefined ? [] : [groupFailure]),
...(handleFailure === undefined ? [] : [handleFailure]),
proofFailure,
],
'subprocess-e2b: force termination failed through both process-group and SDK transports',
)
}
private async waitForGroupExit(sandbox: Sandbox, processGroupId: number): Promise<boolean> {
const deadline = Date.now() + this.spec.graceMs
while (await this.groupAlive(sandbox, processGroupId)) {
if (Date.now() >= deadline) return false
await waitTick()
}
return true
}
private throwTerminationFailure(): void {
@@ -710,8 +693,13 @@ export class E2BSubprocessHandle implements SubprocessHandle {
}
private async signalGroup(sandbox: Sandbox, pid: number, signal: 'TERM' | 'KILL'): Promise<boolean> {
// TODO(e2b-pgid-identity): Prefer an atomic identity-bound group signal if E2B adds one;
// a userspace identity precheck cannot close the numeric-PGID reuse race.
try {
await sandbox.commands.run(`kill -${signal} -- -${pid}`)
await sandbox.commands.run(
`kill -${signal} -- -${pid}`,
commandOpts(this.controlEnvs, undefined),
)
return true
} catch (error: unknown) {
if (error instanceof CommandExitError || error instanceof SandboxNotFoundError) return false
@@ -722,7 +710,7 @@ export class E2BSubprocessHandle implements SubprocessHandle {
private async groupAlive(sandbox: Sandbox, pid: number, signal?: AbortSignal): Promise<boolean> {
const result = await sandbox.commands.run(
`set -o pipefail; ps -eo pgid=,stat= | awk '$1 == ${pid} && $2 !~ /^[ZXx]/ { live=1 } END { if (live) print "live" }'`,
signalOpts(signal),
commandOpts(this.controlEnvs, signal),
).catch((error: unknown) => {
if (signal?.aborted === true) return undefined
if (error instanceof SandboxNotFoundError) return { exitCode: 0, stdout: '', stderr: '' }