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

+46 -11
View File
@@ -1,11 +1,24 @@
/** Shared remote-environment scrubbing for E2B process and terminal launchers. */
import { Buffer } from 'node:buffer'
import { posix } from 'node:path'
import { e2bControlEnvs } from '@deepseek-ai/dsh-e2b'
import type { Sandbox } from '@deepseek-ai/dsh-e2b'
import { SENSITIVE_ENV_PATTERN } from '@deepseek-ai/dsh-subprocess'
const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
function remoteEnvironmentEntries(raw: string): Array<readonly [string, string]> {
const entries: Array<readonly [string, string]> = []
for (const entry of raw.split('\0')) {
if (entry.length === 0) continue
const separator = entry.indexOf('=')
if (separator <= 0) continue
entries.push([entry.slice(0, separator), entry.slice(separator + 1)])
}
return entries
}
/**
* Read the remote environment through ASCII base64 so SDK callback chunking cannot corrupt UTF-8.
* @param sandbox - shared E2B execution world.
@@ -14,16 +27,29 @@ const BASE64 = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$
*/
export async function readRemoteEnvironment(sandbox: Sandbox, signal?: AbortSignal): Promise<string> {
const result = await sandbox.commands.run(
'set -o pipefail; env -0 | base64 -w 0',
signal === undefined ? {} : { signal },
'set -o pipefail; printf \'%s\' "$PWD" | base64 -w 0; printf \'\\n\'; env -0 | base64 -w 0',
{ envs: e2bControlEnvs(), ...(signal === undefined ? {} : { signal }) },
)
const encoded = result.stdout.trim()
if (!BASE64.test(encoded)) throw new Error('subprocess-e2b: remote environment transport returned invalid base64')
const lines = result.stdout.trim().split('\n')
if (lines.length !== 2 || !lines.every(line => BASE64.test(line))) {
throw new Error('subprocess-e2b: remote environment transport returned invalid base64')
}
const [encodedHome, encodedEnvironment] = lines as [string, string]
let home: string
let raw: string
try {
return new TextDecoder('utf-8', { fatal: true }).decode(Buffer.from(encoded, 'base64'))
const decoder = new TextDecoder('utf-8', { fatal: true })
home = decoder.decode(Buffer.from(encodedHome, 'base64'))
raw = decoder.decode(Buffer.from(encodedEnvironment, 'base64'))
} catch (error: unknown) {
throw new Error('subprocess-e2b: remote environment is not valid UTF-8', { cause: error })
}
if (!posix.isAbsolute(home) || home.includes('\0')) {
throw new Error(`subprocess-e2b: remote login home is invalid: ${JSON.stringify(home)}`)
}
const environment = new Map(remoteEnvironmentEntries(raw))
environment.set('HOME', home)
return [...environment].map(([name, value]) => `${name}=${value}\0`).join('')
}
/**
@@ -33,13 +59,22 @@ export async function readRemoteEnvironment(sandbox: Sandbox, signal?: AbortSign
*/
export function scrubRemoteEnvironment(raw: string): Map<string, string> {
const environment = new Map<string, string>()
for (const entry of raw.split('\0')) {
if (entry.length === 0) continue
const separator = entry.indexOf('=')
if (separator <= 0) continue
const name = entry.slice(0, separator)
for (const [name, value] of remoteEnvironmentEntries(raw)) {
if (name.startsWith('DSH_') || SENSITIVE_ENV_PATTERN.test(name)) continue
environment.set(name, entry.slice(separator + 1))
environment.set(name, value)
}
return environment
}
/**
* Isolate E2B's fixed login-shell bootstrap from user profiles and ambient credentials.
* @param raw - The complete NUL-delimited remote environment.
* @returns Explicit E2B command or PTY overrides for bootstrap-shell startup.
*/
export function bootstrapEnvironment(raw: string): Record<string, string> {
const environment: Record<string, string> = { TERM: 'dumb' }
for (const [name] of remoteEnvironmentEntries(raw)) {
if (name.startsWith('DSH_') || SENSITIVE_ENV_PATTERN.test(name)) environment[name] = ''
}
return environment
}
+3 -3
View File
@@ -14,7 +14,7 @@ import type {
SubprocessTerminalHandle,
SubprocessTerminalSpawnSpec,
} from '@deepseek-ai/dsh-subprocess'
import { quoteE2BShellArg } from '@deepseek-ai/dsh-e2b'
import { e2bControlEnvs, quoteE2BShellArg } from '@deepseek-ai/dsh-e2b'
import { E2BSubprocessHandle } from './process.ts'
import { spawnE2BTerminal } from './terminal.ts'
@@ -86,7 +86,7 @@ export class E2BSubprocessService extends SubprocessService {
if (posix.isAbsolute(command)) {
await sandbox.commands.run(
`test -f ${quoteE2BShellArg(command)} -a -x ${quoteE2BShellArg(command)}`,
signalOpts(signal),
{ envs: e2bControlEnvs(), ...signalOpts(signal) },
)
signal?.throwIfAborted()
return command
@@ -95,7 +95,7 @@ export class E2BSubprocessService extends SubprocessService {
const prefix = path === undefined ? '' : `PATH=${quoteE2BShellArg(path)} `
const result = await sandbox.commands.run(
`${prefix}command -v -- ${quoteE2BShellArg(command)}`,
{ cwd: this.cwd, ...signalOpts(signal) },
{ cwd: this.cwd, envs: e2bControlEnvs(), ...signalOpts(signal) },
)
signal?.throwIfAborted()
const executable = result.stdout.trim()
+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: '' }
+72 -27
View File
@@ -6,6 +6,7 @@ import { PassThrough } from 'node:stream'
import { posix } from 'node:path'
import {
CommandExitError,
e2bControlEnvs,
FileNotFoundError,
SandboxNotFoundError,
quoteE2BShellArg,
@@ -20,7 +21,11 @@ import type {
SubprocessTerminalSpawnSpec,
} 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'
const POLL_MS = 20
@@ -54,6 +59,13 @@ function signalOpts(signal: AbortSignal | undefined): { signal?: AbortSignal } {
return signal === undefined ? {} : { signal }
}
function commandOpts(
envs: Record<string, string>,
signal?: AbortSignal,
): { envs: Record<string, string>; signal?: AbortSignal } {
return { envs: e2bControlEnvs(envs), ...signalOpts(signal) }
}
function delay(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
@@ -143,8 +155,13 @@ function serializeValues(values: readonly string[], kind: string): string {
return values.map(value => `${value}\0`).join('')
}
async function terminalSessionId(sandbox: Sandbox, pid: number, signal?: AbortSignal): Promise<number> {
const result = await sandbox.commands.run(`ps -o sid= -p ${pid}`, signalOpts(signal))
async function terminalSessionId(
sandbox: Sandbox,
pid: number,
envs: Record<string, string>,
signal?: AbortSignal,
): Promise<number> {
const result = await sandbox.commands.run(`ps -o sid= -p ${pid}`, commandOpts(envs, signal))
signal?.throwIfAborted()
return parsePositiveId(result.stdout, `subprocess-e2b: cannot resolve process session for terminal ${pid}`)
}
@@ -169,11 +186,16 @@ async function waitUntilReady(
}
}
async function sessionProcessGroups(sandbox: Sandbox, sessionId: number): Promise<number[]> {
async function sessionProcessGroups(
sandbox: Sandbox,
sessionId: number,
envs: Record<string, string>,
): Promise<number[]> {
let result: CommandResult
try {
result = await sandbox.commands.run(
`set -o pipefail; ps -eo sid=,pgid=,stat= | awk '$1 == ${sessionId} && $3 !~ /^[ZXx]/ { print $2 }'`,
commandOpts(envs),
)
} catch (error: unknown) {
if (error instanceof SandboxNotFoundError) return []
@@ -194,9 +216,17 @@ async function sessionProcessGroups(sandbox: Sandbox, sessionId: number): Promis
return [...groups]
}
async function signalGroups(sandbox: Sandbox, groups: number[], signal: 'TERM' | 'KILL'): Promise<void> {
async function signalGroups(
sandbox: Sandbox,
groups: number[],
signal: 'TERM' | 'KILL',
envs: Record<string, string>,
): Promise<void> {
try {
await sandbox.commands.run(`kill -${signal} -- ${groups.map(group => `-${group}`).join(' ')}`)
await sandbox.commands.run(
`kill -${signal} -- ${groups.map(group => `-${group}`).join(' ')}`,
commandOpts(envs),
)
} catch (error: unknown) {
if (!(error instanceof CommandExitError) && !(error instanceof SandboxNotFoundError)) throw error
}
@@ -205,14 +235,15 @@ async function signalGroups(sandbox: Sandbox, groups: number[], signal: 'TERM' |
async function awaitSessionEmpty(
sandbox: Sandbox,
sessionId: number,
envs: Record<string, string>,
graceMs: number,
kill = false,
): Promise<number[]> {
const deadline = Date.now() + graceMs
for (;;) {
const groups = await sessionProcessGroups(sandbox, sessionId)
const groups = await sessionProcessGroups(sandbox, sessionId, envs)
if (groups.length === 0 || Date.now() >= deadline) return groups
if (kill) await signalGroups(sandbox, groups, 'KILL')
if (kill) await signalGroups(sandbox, groups, 'KILL', envs)
await delay(Math.min(POLL_MS, Math.max(1, deadline - Date.now())))
}
}
@@ -221,6 +252,7 @@ async function rollbackUnpublishedTerminal(
sandbox: Sandbox,
handle: CommandHandle,
completion: Promise<CommandResult>,
envs: Record<string, string>,
graceMs: number,
): Promise<void> {
let topLevelExited = false
@@ -234,20 +266,20 @@ async function rollbackUnpublishedTerminal(
if (validPid) {
sessionId = handle.pid
try {
sessionId = await terminalSessionId(sandbox, handle.pid)
sessionId = await terminalSessionId(sandbox, handle.pid, envs)
} catch (_sessionLookupFailure) {
// E2B's PTY leader is also the provisional POSIX session leader, so its
// PID remains usable after the setup lookup itself fails or is canceled.
}
try {
let groups = await sessionProcessGroups(sandbox, sessionId)
let groups = await sessionProcessGroups(sandbox, sessionId, envs)
if (groups.length > 0) {
await signalGroups(sandbox, groups, 'TERM')
groups = await awaitSessionEmpty(sandbox, sessionId, graceMs)
await signalGroups(sandbox, groups, 'TERM', envs)
groups = await awaitSessionEmpty(sandbox, sessionId, envs, graceMs)
}
if (groups.length > 0) {
await signalGroups(sandbox, groups, 'KILL')
await awaitSessionEmpty(sandbox, sessionId, graceMs, true)
await signalGroups(sandbox, groups, 'KILL', envs)
await awaitSessionEmpty(sandbox, sessionId, envs, graceMs, true)
}
} catch (error: unknown) {
attemptFailures.push(asError(error))
@@ -279,7 +311,7 @@ async function rollbackUnpublishedTerminal(
const proofFailures: Error[] = []
if (sessionId !== undefined) {
try {
const groups = await awaitSessionEmpty(sandbox, sessionId, graceMs, true)
const groups = await awaitSessionEmpty(sandbox, sessionId, envs, graceMs, true)
if (groups.length > 0) {
proofFailures.push(new Error(
`subprocess-e2b: terminal setup rollback failed; surviving process groups: ${groups.join(', ')}`,
@@ -322,6 +354,7 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
readonly output: PassThrough,
private readonly completion: Promise<CommandResult>,
private readonly sessionId: number,
private readonly controlEnvs: Record<string, string>,
private readonly stateDir: string,
private readonly graceMs: number,
signal?: AbortSignal,
@@ -344,7 +377,10 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
/** @inheritdoc */
async inspectForeground(): Promise<SubprocessTerminalForeground | undefined> {
try {
const result = await this.sandbox.commands.run(`ps -o tpgid= -p ${this.pid}`)
const result = await this.sandbox.commands.run(
`ps -o tpgid= -p ${this.pid}`,
commandOpts(this.controlEnvs),
)
return {
processGroupId: parsePositiveId(
result.stdout,
@@ -369,7 +405,10 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
if (signal === 'SIGKILL' && foreground.processGroupId === this.pid) {
throw new Error('refusing to SIGKILL the terminal shell; terminate the terminal session instead')
}
await this.sandbox.commands.run(`kill -${signal.slice(3)} -- -${foreground.processGroupId}`)
await this.sandbox.commands.run(
`kill -${signal.slice(3)} -- -${foreground.processGroupId}`,
commandOpts(this.controlEnvs),
)
return foreground.processGroupId
}
@@ -402,11 +441,11 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
}
private async closeOnce(): Promise<void> {
let groups = await sessionProcessGroups(this.sandbox, this.sessionId)
let groups = await sessionProcessGroups(this.sandbox, this.sessionId, this.controlEnvs)
if (groups.length > 0) {
this.terminationSignal = 'SIGTERM'
await signalGroups(this.sandbox, groups, 'TERM')
groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.graceMs)
await signalGroups(this.sandbox, groups, 'TERM', this.controlEnvs)
groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.controlEnvs, this.graceMs)
}
if (groups.length === 0 && !this.topLevelExited) {
await Promise.race([this.done.catch(() => undefined), delay(this.graceMs)])
@@ -421,7 +460,7 @@ export class E2BTerminalHandle implements SubprocessTerminalHandle {
throw error
}
}
groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.graceMs, true)
groups = await awaitSessionEmpty(this.sandbox, this.sessionId, this.controlEnvs, this.graceMs, true)
if (!this.topLevelExited) await Promise.race([this.done.catch(() => undefined), delay(this.graceMs)])
}
if (groups.length > 0) {
@@ -469,13 +508,18 @@ export async function spawnE2BTerminal(
let handle: CommandHandle | undefined
let completion: Promise<CommandResult> | undefined
let stateDirectoryCreated = false
let controlEnvs: Record<string, string> = {}
try {
const ambient = await readRemoteEnvironment(sandbox, spec.signal)
controlEnvs = bootstrapEnvironment(ambient)
const environment = serializeRemoteEnvironment(ambient, spec.env)
const argv = serializeValues(spec.argv, 'argv')
await sandbox.files.makeDir(stateDir)
stateDirectoryCreated = true
await sandbox.commands.run(`chmod 700 -- ${quoteE2BShellArg(stateDir)}`, signalOpts(spec.signal))
await sandbox.files.makeDir(stateDir, signalOpts(spec.signal))
await sandbox.commands.run(
`chmod 700 -- ${quoteE2BShellArg(stateDir)}`,
commandOpts(controlEnvs, spec.signal),
)
await sandbox.files.write([
{ path: paths.runner, data: TERMINAL_RUNNER_SOURCE },
{ path: paths.environment, data: environment },
@@ -484,13 +528,13 @@ export async function spawnE2BTerminal(
], signalOpts(spec.signal))
await sandbox.commands.run(
`chmod 600 -- ${quoteE2BShellArg(paths.runner)} ${quoteE2BShellArg(paths.environment)} ${quoteE2BShellArg(paths.argv)} ${quoteE2BShellArg(paths.outputMarker)}`,
signalOpts(spec.signal),
commandOpts(controlEnvs, spec.signal),
)
handle = await sandbox.pty.create({
rows: spec.rows,
cols: spec.cols,
cwd: spec.cwd,
envs: { TERM: 'dumb' },
envs: e2bControlEnvs(controlEnvs),
timeoutMs: 0,
onData: (data) => { outputFilter.push(data) },
})
@@ -504,13 +548,14 @@ export async function spawnE2BTerminal(
await sandbox.pty.sendInput(handle.pid, Buffer.from(command), signalOpts(spec.signal))
await waitUntilReady(sandbox, paths, completion, spec.signal)
await waitForBootstrapOutput(outputFilter.ready, completion, spec.signal)
const sessionId = await terminalSessionId(sandbox, handle.pid, spec.signal)
const sessionId = await terminalSessionId(sandbox, handle.pid, controlEnvs, spec.signal)
return new E2BTerminalHandle(
sandbox,
handle,
output,
completion,
sessionId,
controlEnvs,
stateDir,
spec.graceMs,
spec.signal,
@@ -524,7 +569,7 @@ export async function spawnE2BTerminal(
if (!terminalQuiescent && handle !== undefined) {
try {
if (completion === undefined) await handle.kill()
else await rollbackUnpublishedTerminal(sandbox, handle, completion, spec.graceMs)
else await rollbackUnpublishedTerminal(sandbox, handle, completion, controlEnvs, spec.graceMs)
terminalQuiescent = true
} catch (cleanupError: unknown) {
if (cleanupError instanceof SandboxNotFoundError) terminalQuiescent = true