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:
21 files changed
+438
-211
No files matched your search
@@ -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: '' }
|
||||
|
||||
Reference in New Issue
Block a user