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:
@@ -111,6 +111,7 @@ class FakeSandbox {
|
||||
alive = true
|
||||
zombieOnly = false
|
||||
ambient = 'PATH=/ambient/bin\0KEEP=safe\0UNICODE=你好\0NPM_TOKEN=secret\0DSH_STALE=old\0BROKEN\0=bad\0'
|
||||
environmentHome = '/home/user'
|
||||
environmentWire: string | undefined
|
||||
environmentRequest: ((signal: AbortSignal | undefined) => Promise<void>) | undefined
|
||||
processGroupId = '4242\n'
|
||||
@@ -241,7 +242,9 @@ class FakeSandbox {
|
||||
if (this.envError !== undefined) throw this.envError
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: this.environmentWire ?? Buffer.from(this.ambient).toString('base64'),
|
||||
stdout: this.environmentWire ?? [this.environmentHome, this.ambient]
|
||||
.map(value => Buffer.from(value).toString('base64'))
|
||||
.join('\n'),
|
||||
stderr: '',
|
||||
}
|
||||
}
|
||||
@@ -396,7 +399,14 @@ describe('E2BSubprocessHandle', () => {
|
||||
expect(handle.pid).toBe(4343)
|
||||
expect(fake.handle.sent.map(value => String(value))).toEqual(['hello'])
|
||||
expect(fake.handle.closes).toBe(1)
|
||||
expect(fake.startOptions?.envs).toBeUndefined()
|
||||
const controlEnvs = fake.startOptions?.envs
|
||||
expect(controlEnvs?.HOME).toMatch(/^\/\.dsh-e2b-control-/)
|
||||
expect(controlEnvs).toEqual({
|
||||
TERM: 'dumb',
|
||||
NPM_TOKEN: '',
|
||||
DSH_STALE: '',
|
||||
HOME: controlEnvs?.HOME,
|
||||
})
|
||||
const command = fake.commandsSeen.find(value => value.includes('exec "$dsh_e2b_env_bin" -i'))!
|
||||
expect(command).toContain('"$dsh_e2b_setsid" --wait -- "$dsh_e2b_bash" -c')
|
||||
expect(command).not.toContain('DEEPSEEK_API_KEY')
|
||||
@@ -405,7 +415,9 @@ describe('E2BSubprocessHandle', () => {
|
||||
expect(command).not.toContain('explicit-secret')
|
||||
expect(command).not.toContain('hyphen-value')
|
||||
expect(command).not.toContain('${!dsh_e2b_name}')
|
||||
expect(fake.commandsSeen).toContain('set -o pipefail; env -0 | base64 -w 0')
|
||||
expect(fake.commandsSeen).toContain(
|
||||
'set -o pipefail; printf \'%s\' "$PWD" | base64 -w 0; printf \'\\n\'; env -0 | base64 -w 0',
|
||||
)
|
||||
expect(command).toContain('mapfile -d')
|
||||
expect(command).toContain('dsh_e2b_node="$(command -v node)"')
|
||||
expect(command).toContain('"$dsh_e2b_env_bin" -i "$dsh_e2b_node" -e')
|
||||
@@ -421,7 +433,7 @@ describe('E2BSubprocessHandle', () => {
|
||||
'/workspace/.dsh-e2b/processes/one/stderr.log',
|
||||
])
|
||||
expect(fake.writtenFileData.get('/workspace/.dsh-e2b/processes/one/environment')).toBe(
|
||||
'PATH=/bin\0KEEP=safe\0UNICODE=你好\0FOO-BAR=hyphen-value\0--split-string=literal-value\0DEEPSEEK_API_KEY=explicit-secret\0DSH_MODE=test\0',
|
||||
'PATH=/bin\0KEEP=safe\0UNICODE=你好\0HOME=/home/user\0FOO-BAR=hyphen-value\0--split-string=literal-value\0DEEPSEEK_API_KEY=explicit-secret\0DSH_MODE=test\0',
|
||||
)
|
||||
|
||||
let piped = ''
|
||||
@@ -560,6 +572,7 @@ describe('E2BSubprocessHandle', () => {
|
||||
await expect(handle.done).resolves.toEqual({ exitCode: 0, signal: null })
|
||||
expect(fake.handle.disconnects).toBe(1)
|
||||
fake.alive = false
|
||||
handle.terminate()
|
||||
await expect(handle.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
@@ -783,7 +796,7 @@ describe('E2BSubprocessHandle', () => {
|
||||
expect(fake.handle.kills).toBe(1)
|
||||
})
|
||||
|
||||
it('does not treat successful termination transport as observed quiescence', async () => {
|
||||
it('keeps force cleanup retryable until quiescence is proven', async () => {
|
||||
const fake = new FakeSandbox()
|
||||
fake.trapsTerm = true
|
||||
fake.delaysKill = true
|
||||
@@ -793,16 +806,15 @@ describe('E2BSubprocessHandle', () => {
|
||||
await flush()
|
||||
handle.terminate()
|
||||
await vi.waitFor(() => { expect(fake.handle.kills).toBe(1) })
|
||||
await expect(handle.waitForExit()).rejects.toThrow('force termination failed through both')
|
||||
expect(fake.alive).toBe(true)
|
||||
|
||||
let quiescent = false
|
||||
const waiting = handle.waitForExit().then((value) => { quiescent = value })
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
expect(quiescent).toBe(false)
|
||||
|
||||
fake.alive = false
|
||||
fake.finish()
|
||||
await waiting
|
||||
expect(quiescent).toBe(true)
|
||||
fake.delaysKill = false
|
||||
fake.delaysKillCompletion = false
|
||||
fake.sdkKillStops = true
|
||||
handle.terminate()
|
||||
await expect(handle.waitForExit()).resolves.toBe(true)
|
||||
await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
|
||||
})
|
||||
|
||||
it('honors termination requested before asynchronous startup finishes', async () => {
|
||||
@@ -870,14 +882,10 @@ describe('E2BSubprocessHandle', () => {
|
||||
|
||||
handle.terminate()
|
||||
await vi.waitFor(() => { expect(fake.handle.kills).toBe(1) })
|
||||
let quiescent = false
|
||||
const waiting = handle.waitForExit().then((value) => { quiescent = value })
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
expect(quiescent).toBe(false)
|
||||
|
||||
await expect(handle.waitForExit()).rejects.toThrow('force termination failed through both')
|
||||
fake.alive = false
|
||||
await waiting
|
||||
expect(quiescent).toBe(true)
|
||||
handle.terminate()
|
||||
await expect(handle.waitForExit()).resolves.toBe(true)
|
||||
fake.releaseProcessGroupRead()
|
||||
fake.finish()
|
||||
await handle.done
|
||||
@@ -1102,23 +1110,36 @@ describe('E2BSubprocessHandle', () => {
|
||||
await expect(envHandle.done).rejects.toThrow('ambient lookup failed')
|
||||
expect(envFailure.removed).toEqual([])
|
||||
|
||||
const malformedEnvironment = new FakeSandbox()
|
||||
malformedEnvironment.environmentWire = '%'
|
||||
const malformedEnvironmentHandle = new E2BSubprocessHandle(
|
||||
runtime(malformedEnvironment),
|
||||
spec(),
|
||||
'/runtime/malformed-environment',
|
||||
const expectEnvironmentFailure = async (name: string, wire: string, message: string): Promise<void> => {
|
||||
const fake = new FakeSandbox()
|
||||
fake.environmentWire = wire
|
||||
const failed = new E2BSubprocessHandle(runtime(fake), spec(), `/runtime/${name}`)
|
||||
await expect(failed.done).rejects.toThrow(message)
|
||||
}
|
||||
const encodedEnvironment = Buffer.from('PATH=/bin\0').toString('base64')
|
||||
const encodedHome = Buffer.from('/home/user').toString('base64')
|
||||
await expectEnvironmentFailure('malformed-frame', '%', 'invalid base64')
|
||||
await expectEnvironmentFailure('malformed-base64', `${encodedHome}\n%`, 'invalid base64')
|
||||
await expectEnvironmentFailure(
|
||||
'invalid-utf8-home',
|
||||
`${Buffer.from([0xff]).toString('base64')}\n${encodedEnvironment}`,
|
||||
'not valid UTF-8',
|
||||
)
|
||||
await expect(malformedEnvironmentHandle.done).rejects.toThrow('invalid base64')
|
||||
|
||||
const invalidUtf8Environment = new FakeSandbox()
|
||||
invalidUtf8Environment.environmentWire = Buffer.from([0xff]).toString('base64')
|
||||
const invalidUtf8EnvironmentHandle = new E2BSubprocessHandle(
|
||||
runtime(invalidUtf8Environment),
|
||||
spec(),
|
||||
'/runtime/invalid-utf8-environment',
|
||||
await expectEnvironmentFailure(
|
||||
'invalid-utf8-environment',
|
||||
`${encodedHome}\n${Buffer.from([0xff]).toString('base64')}`,
|
||||
'not valid UTF-8',
|
||||
)
|
||||
await expectEnvironmentFailure(
|
||||
'relative-home',
|
||||
`${Buffer.from('home/user').toString('base64')}\n${encodedEnvironment}`,
|
||||
'remote login home is invalid',
|
||||
)
|
||||
await expectEnvironmentFailure(
|
||||
'nul-home',
|
||||
`${Buffer.from('/home/user\0tail').toString('base64')}\n${encodedEnvironment}`,
|
||||
'remote login home is invalid',
|
||||
)
|
||||
await expect(invalidUtf8EnvironmentHandle.done).rejects.toThrow('not valid UTF-8')
|
||||
|
||||
const cleanupFailure = new FakeSandbox()
|
||||
cleanupFailure.backgroundError = new Error('start failed before credential consumption')
|
||||
@@ -1318,10 +1339,11 @@ describe('E2BSubprocessHandle', () => {
|
||||
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[1]).toBeInstanceOf(AggregateError)
|
||||
if (!(failures[0] instanceof Error) || !(failures[1] instanceof AggregateError)) throw new Error('expected nested errors')
|
||||
expect(failures[0].message).toContain('invalid process-group id')
|
||||
expect(failures[1].message).toBe('rollback signal failed')
|
||||
expect(failures[1].message).toBe('subprocess-e2b: force termination failed through both process-group and SDK transports')
|
||||
expect(Array.from(failures[1].errors as Iterable<unknown>)).toContainEqual(new Error('rollback signal failed'))
|
||||
expect(fake.handle.kills).toBe(1)
|
||||
const bounded = new AbortController()
|
||||
const waiting = handle.waitForExit(bounded.signal)
|
||||
@@ -1330,6 +1352,15 @@ describe('E2BSubprocessHandle', () => {
|
||||
handle.terminate()
|
||||
await expect(handle.waitForExit()).resolves.toBe(true)
|
||||
expect(fake.commandsSeen).toContain('kill -TERM -- -4242')
|
||||
|
||||
const naturallyGone = new FakeSandbox()
|
||||
naturallyGone.processGroupId = 'not-a-pid\n'
|
||||
naturallyGone.signalError = new Error('rollback signal failed')
|
||||
naturallyGone.handle.killError = new Error('SDK kill failed')
|
||||
const observed = new E2BSubprocessHandle(runtime(naturallyGone), spec(), '/runtime/failed-rollback-observed')
|
||||
await expect(observed.done).rejects.toThrow('process-group publication failed')
|
||||
naturallyGone.alive = false
|
||||
await expect(observed.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('waits for delayed process-group publication', async () => {
|
||||
@@ -1479,6 +1510,24 @@ describe('E2BSubprocessHandle', () => {
|
||||
raced.terminate()
|
||||
await expect(raced.waitForExit()).resolves.toBe(true)
|
||||
})
|
||||
|
||||
it('rejects an optimistic SDK kill while descendants survive a failed group KILL', async () => {
|
||||
const fake = new FakeSandbox()
|
||||
fake.trapsTerm = true
|
||||
fake.sdkKillStops = false
|
||||
fake.signalErrors.push(undefined, new Error('KILL transport failed'))
|
||||
const handle = new E2BSubprocessHandle(runtime(fake), spec({ graceMs: 1 }), '/runtime/optimistic-sdk-kill')
|
||||
await flush()
|
||||
|
||||
handle.terminate()
|
||||
await expect(handle.waitForExit()).rejects.toThrow('force termination failed through both')
|
||||
expect(fake.alive).toBe(true)
|
||||
|
||||
fake.sdkKillStops = true
|
||||
handle.terminate()
|
||||
await expect(handle.waitForExit()).resolves.toBe(true)
|
||||
await expect(handle.done).resolves.toEqual({ exitCode: null, signal: 'SIGKILL' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('E2BSubprocessService', () => {
|
||||
|
||||
@@ -23,6 +23,7 @@ function commandError(exitCode: number): CommandExitError {
|
||||
interface CommandOptions {
|
||||
signal?: AbortSignal
|
||||
cwd?: string
|
||||
envs?: Record<string, string>
|
||||
}
|
||||
|
||||
class FakeTerminalCommandHandle {
|
||||
@@ -101,6 +102,7 @@ class FakeTerminalSandbox {
|
||||
writeError: unknown
|
||||
sendError: unknown
|
||||
commandFailure: unknown
|
||||
makeDirRequest: ((signal: AbortSignal | undefined) => Promise<void>) | undefined
|
||||
sessionGroupsFailure: unknown
|
||||
foregroundFailure: unknown
|
||||
termFailure: unknown
|
||||
@@ -129,8 +131,10 @@ class FakeTerminalSandbox {
|
||||
|
||||
readonly sandbox = {
|
||||
files: {
|
||||
makeDir: async (path: string): Promise<boolean> => {
|
||||
makeDir: async (path: string, options?: CommandOptions): Promise<boolean> => {
|
||||
this.directories.push(path)
|
||||
await this.makeDirRequest?.(options?.signal)
|
||||
options?.signal?.throwIfAborted()
|
||||
return true
|
||||
},
|
||||
write: async (files: Array<{ path: string; data: string }>): Promise<object[]> => {
|
||||
@@ -163,7 +167,11 @@ class FakeTerminalSandbox {
|
||||
throw error
|
||||
}
|
||||
if (command.includes('env -0 | base64')) {
|
||||
return { exitCode: 0, stdout: Buffer.from(this.ambient).toString('base64'), stderr: '' }
|
||||
return {
|
||||
exitCode: 0,
|
||||
stdout: ['/home/user', this.ambient].map(value => Buffer.from(value).toString('base64')).join('\n'),
|
||||
stderr: '',
|
||||
}
|
||||
}
|
||||
if (command.includes('command -v -- ')) {
|
||||
return { exitCode: 0, stdout: this.resolvedExecutable, stderr: '' }
|
||||
@@ -262,7 +270,15 @@ describe('E2B terminal allocation', () => {
|
||||
expect(output).toBe('requested-shell$ ')
|
||||
expect(output).not.toContain('buffered banner')
|
||||
expect(output).not.toContain('runner.bash')
|
||||
expect(fake.createOptions).toMatchObject({ rows: 24, cols: 80, cwd: '/workspace', timeoutMs: 0, envs: { TERM: 'dumb' } })
|
||||
expect(fake.createOptions).toMatchObject({ rows: 24, cols: 80, cwd: '/workspace', timeoutMs: 0 })
|
||||
const controlEnvs = fake.createOptions?.envs
|
||||
expect(controlEnvs?.HOME).toMatch(/^\/\.dsh-e2b-control-/)
|
||||
expect(controlEnvs).toEqual({
|
||||
TERM: 'dumb',
|
||||
NPM_TOKEN: '',
|
||||
DSH_STALE: '',
|
||||
HOME: controlEnvs?.HOME,
|
||||
})
|
||||
expect(fake.inputs[0]?.data.toString()).toContain("exec /bin/bash '/runtime/terminal-one/runner.bash'")
|
||||
expect(fake.writes.get('/runtime/terminal-one/environment')).toContain('KEEP=visible\0')
|
||||
expect(fake.writes.get('/runtime/terminal-one/environment')).toContain('UNICODE=你好\0')
|
||||
@@ -554,6 +570,7 @@ describe('E2B terminal lifecycle', () => {
|
||||
new PassThrough(),
|
||||
fake.handle.wait(),
|
||||
123,
|
||||
{ TERM: 'dumb' },
|
||||
'/runtime/pre-aborted',
|
||||
1,
|
||||
controller.signal,
|
||||
@@ -807,7 +824,10 @@ describe('E2B subprocess terminal service', () => {
|
||||
fake.resolvedExecutable = 'tools/bin/node\n'
|
||||
await expect(ctx.subprocess.resolveExecutable('node', { PATH: 'tools/bin' }))
|
||||
.resolves.toBe('/workspace/tools/bin/node')
|
||||
expect(fake.commandOptions.at(-1)).toMatchObject({ cwd: '/workspace' })
|
||||
const commandOptions = fake.commandOptions.at(-1)
|
||||
expect(commandOptions).toMatchObject({ cwd: '/workspace' })
|
||||
expect(commandOptions?.envs?.HOME).toMatch(/^\/\.dsh-e2b-control-/)
|
||||
expect(commandOptions?.envs).toEqual({ HOME: commandOptions?.envs?.HOME })
|
||||
expect((ctx.e2b)).toBeDefined()
|
||||
})
|
||||
|
||||
@@ -867,6 +887,29 @@ describe('E2B subprocess terminal service', () => {
|
||||
expect(fake.handle.disconnects).toBe(1)
|
||||
})
|
||||
|
||||
it('owns and cancels terminal state-directory creation during disposal', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.makeDirRequest = async (signal) => {
|
||||
await new Promise<never>((_resolve, reject) => {
|
||||
const onAbort = (): void => {
|
||||
const reason: unknown = signal?.reason
|
||||
reject(reason instanceof Error ? reason : new Error(String(reason)))
|
||||
}
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
if (signal?.aborted === true) onAbort()
|
||||
})
|
||||
}
|
||||
const { ctx, fiber } = await service(fake)
|
||||
const spawning = ctx.subprocess.spawnTerminal(spec())
|
||||
const rejected = expect(spawning).rejects.toThrow('service disposed during terminal setup')
|
||||
await vi.waitFor(() => { expect(fake.directories.some(path => path.includes('/terminals/'))).toBe(true) })
|
||||
|
||||
await fiber.dispose()
|
||||
await rejected
|
||||
expect(fake.removed.some(path => path.includes('/terminals/'))).toBe(true)
|
||||
expect(fake.createOptions).toBeUndefined()
|
||||
})
|
||||
|
||||
it('retains failed terminal setup cleanup for disposal retry', async () => {
|
||||
const fake = new FakeTerminalSandbox()
|
||||
fake.sendError = new Error('bootstrap failed')
|
||||
|
||||
Reference in New Issue
Block a user