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/pty-e2b/README.md
|
||||
README.md: 3d363309f6bd1c4def5fbff668735958ca2de52d
|
||||
README.zh.md: 78d61cc654bf6eb46a3bec7124b3cc06cc7e34e9
|
||||
README.md: d6d8e05f13701b85cee9a9646bfdd82ac07769da
|
||||
README.zh.md: 265257c72e7bcbd2dfd805b343db5bab7173ab51
|
||||
|
||||
@@ -20,13 +20,13 @@ The `pty-e2b` plugin injects `e2b` and `pty`, then registers one backend under `
|
||||
| `timeoutMs` | `30000` | Absolute startup and send wait bound. |
|
||||
| `disposeGraceMs` | `3000` | TERM-to-KILL cleanup grace. |
|
||||
|
||||
Numeric values are positive safe integers, `backendType` is non-empty, and `maxReadBytes` cannot exceed `scrollbackMaxBytes`. A relative spawn cwd resolves against `ctx.e2b.cwd`; an absolute remote path remains absolute.
|
||||
Numeric values are positive safe integers, `backendType` is non-empty, and `maxReadBytes` cannot exceed `scrollbackMaxBytes`. A relative spawn cwd resolves against `ctx.e2b.cwd`; an absolute remote path remains absolute. Before launch, the backend enumerates sandbox-default environment names, blanks `DSH_*` and credential-shaped names, then overlays its controlled terminal values and explicit `spec.env` entries.
|
||||
|
||||
## Runtime contract
|
||||
|
||||
The backend uses E2B's byte-oriented PTY callback with a streaming fatal UTF-8 decoder, then the backend-neutral line sanitizer and bounded buffers from `dsh-pty`. It installs a controlled Bash prompt marker and waits for printable prompt text; when that marker is unavailable, observed output plus the configured silence bound yields `inferred_idle`. Startup with no output reaches the absolute timeout and fails instead of publishing an empty session.
|
||||
|
||||
Each send writes UTF-8 bytes and an optional carriage-return submit sequence. Cancellation and explicit signals resolve the remote terminal's foreground process group through `ps`, then signal that group; `SIGKILL` refuses to target the shell itself. Close sends `SIGTERM` to the PTY process group, waits, escalates through E2B's PTY kill, and does not resolve until the SDK handle reports exit. A startup failure closes the unpublished PTY, and `PtyBackendCleanupError` preserves a concurrent cleanup failure.
|
||||
Each send writes UTF-8 bytes and an optional carriage-return submit sequence. Cancellation and explicit signals resolve the remote terminal's foreground process group through `ps`, then signal that group; `SIGKILL` refuses to target the shell itself. The backend records the terminal's POSIX session id at startup. Close sends `SIGTERM` to every process group still in that session, escalates survivors to `SIGKILL`, verifies that the session is empty, and does not resolve until the SDK handle reports exit. A startup failure closes the unpublished PTY, and `PtyBackendCleanupError` preserves a concurrent cleanup failure.
|
||||
|
||||
The remote PTY process and its child processes live in E2B. Prompt/readiness state, scrollback, operation handles, owner authority, and SDK event delivery remain in host memory.
|
||||
|
||||
@@ -51,4 +51,5 @@ No direct invalidation; the consumer owns prompts, schemas, and appended results
|
||||
- **Line-oriented terminal model** — CSI/OSC control sequences are removed; alternate-screen and full terminal emulation remain unsupported.
|
||||
- **Readiness is marker-or-silence based** — E2B exposes foreground process groups but not the local backend's Linux syscall inspection, so `inferred_idle` is deliberately possible.
|
||||
- **UTF-8 only** — invalid byte sequences fail the session instead of returning lossy text.
|
||||
- **Deliberate session escape is unmanaged** — a process that calls `setsid` leaves the terminal session and is outside this backend's cleanup identity.
|
||||
- **No reconnectable terminal handles** — retaining an E2B sandbox preserves remote files, not host ownership, buffers, callbacks, or live PTY sessions.
|
||||
|
||||
@@ -20,13 +20,13 @@
|
||||
| `timeoutMs` | `30000` | 启动与发送等待的绝对上限。 |
|
||||
| `disposeGraceMs` | `3000` | TERM 到 KILL 的清理宽限期。 |
|
||||
|
||||
数值必须是正的安全整数,`backendType` 必须非空,且 `maxReadBytes` 不得超过 `scrollbackMaxBytes`。相对的 spawn cwd 以 `ctx.e2b.cwd` 为基准解析;绝对远程路径保持不变。
|
||||
数值必须是正的安全整数,`backendType` 必须非空,且 `maxReadBytes` 不得超过 `scrollbackMaxBytes`。相对的 spawn cwd 以 `ctx.e2b.cwd` 为基准解析;绝对远程路径保持不变。启动前,后端会枚举沙箱默认环境变量名,清空 `DSH_*` 和形似凭据的名称,再覆盖其受控终端值与显式 `spec.env` 条目。
|
||||
|
||||
## 运行时契约
|
||||
|
||||
该后端为 E2B 面向字节的 PTY 回调配备流式、遇到无效序列即失败的 UTF-8 解码器,随后使用 `dsh-pty` 提供的后端无关行清理器与有界缓冲区。它会安装受控的 Bash 提示符标记,并等待可打印的提示符文本;若该标记不可用,系统会在已经观察到输出且达到已配置的静默上限时得出 `inferred_idle`。零输出的启动过程会达到绝对超时并失败,不会发布空会话。
|
||||
|
||||
每次发送都会写入 UTF-8 字节,并可选写入回车提交序列。取消与显式信号会通过 `ps` 确定远程终端的前台进程组,再向该组发送信号;发送 `SIGKILL` 时拒绝以 shell 本身为目标。关闭操作向 PTY 进程组发送 `SIGTERM`,等待后通过 E2B 的 PTY kill 操作升级,并且直到 SDK 句柄报告退出才结算。如果启动失败,系统会关闭尚未发布的 PTY;若清理同时失败,`PtyBackendCleanupError` 会保留这项失败。
|
||||
每次发送都会写入 UTF-8 字节,并可选写入回车提交序列。取消与显式信号会通过 `ps` 确定远程终端的前台进程组,再向该组发送信号;发送 `SIGKILL` 时拒绝以 shell 本身为目标。后端会在启动时记录终端的 POSIX 会话 id。关闭操作会向该会话内仍存在的每个进程组发送 `SIGTERM`,对存活者升级为 `SIGKILL`,验证会话已经清空,并且直到 SDK 句柄报告退出才结算。如果启动失败,系统会关闭尚未发布的 PTY;若清理同时失败,`PtyBackendCleanupError` 会保留这项失败。
|
||||
|
||||
远程 PTY 进程及其子进程位于 E2B。提示符/就绪状态、scrollback、操作句柄、所有者权限和 SDK 事件交付仍保留在宿主内存中。
|
||||
|
||||
@@ -51,4 +51,5 @@
|
||||
- **面向行的终端模型**:CSI/OSC 控制序列会被移除;备用屏幕与完整终端仿真仍不受支持。
|
||||
- **就绪判断基于标记或静默**:E2B 会公开前台进程组,但不提供本地后端使用的 Linux syscall 检查,因此系统有意保留返回 `inferred_idle` 的可能性。
|
||||
- **仅支持 UTF-8**:无效字节序列会使会话失败,而不是返回有损文本。
|
||||
- **主动逃离会话的进程不受管理**:调用 `setsid` 的进程会离开终端会话,因而不属于本后端的清理身份。
|
||||
- **没有可重连的终端句柄**:保留 E2B 沙箱会保留远程文件,但不会保留宿主所有权、缓冲区、回调或实时 PTY 会话。
|
||||
|
||||
@@ -17,8 +17,22 @@ export const name = 'pty-e2b'
|
||||
/** Required shared sandbox owner and PTY registry. */
|
||||
export const inject = ['e2b', 'pty']
|
||||
|
||||
function terminalEnvironment(spec: PtyBackendSpawnSpec): Record<string, string> {
|
||||
const SENSITIVE_ENV_NAME = /KEY|SECRET|TOKEN/i
|
||||
|
||||
async function terminalEnvironment(
|
||||
sandbox: Sandbox,
|
||||
spec: PtyBackendSpawnSpec,
|
||||
): Promise<Record<string, string>> {
|
||||
const discovered = await sandbox.commands.run(
|
||||
'env -0 | cut -z -d= -f1',
|
||||
spec.signal === undefined ? {} : { signal: spec.signal },
|
||||
)
|
||||
spec.signal?.throwIfAborted()
|
||||
const scrubbed = Object.fromEntries(discovered.stdout.split('\0')
|
||||
.filter(name => name.startsWith('DSH_') || SENSITIVE_ENV_NAME.test(name))
|
||||
.map(name => [name, '']))
|
||||
return {
|
||||
...scrubbed,
|
||||
TERM: 'dumb',
|
||||
PAGER: 'cat',
|
||||
GIT_PAGER: 'cat',
|
||||
@@ -31,6 +45,20 @@ function terminalEnvironment(spec: PtyBackendSpawnSpec): Record<string, string>
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveTerminalSessionId(sandbox: Sandbox, pid: number, signal?: AbortSignal): Promise<number> {
|
||||
const result = await sandbox.commands.run(
|
||||
`ps -o sid= -p ${pid}`,
|
||||
signal === undefined ? {} : { signal },
|
||||
)
|
||||
signal?.throwIfAborted()
|
||||
const raw = result.stdout.trim()
|
||||
const sessionId = Number(raw)
|
||||
if (!/^[1-9][0-9]*$/.test(raw) || !Number.isSafeInteger(sessionId)) {
|
||||
throw new Error(`pty-e2b: cannot resolve process session for E2B PTY ${pid}`)
|
||||
}
|
||||
return sessionId
|
||||
}
|
||||
|
||||
/** E2B backend registered under the configured terminal type. */
|
||||
export class E2BPtyBackend implements PtyBackend {
|
||||
readonly type: string
|
||||
@@ -57,7 +85,7 @@ export class E2BPtyBackend implements PtyBackend {
|
||||
rows: this.config.rows,
|
||||
cols: this.config.cols,
|
||||
cwd: posix.resolve(this.ctx.e2b.cwd, spec.cwd ?? this.ctx.e2b.cwd),
|
||||
envs: terminalEnvironment(spec),
|
||||
envs: await terminalEnvironment(sandbox, spec),
|
||||
timeoutMs: 0,
|
||||
...spec.signal === undefined ? {} : { signal: spec.signal },
|
||||
onData: (data) => {
|
||||
@@ -69,7 +97,15 @@ export class E2BPtyBackend implements PtyBackend {
|
||||
await handle.kill().catch(() => false)
|
||||
throw new Error(`pty-e2b: E2B returned invalid PTY pid ${handle.pid}`)
|
||||
}
|
||||
const session = new E2BPtySession(sandbox, handle, this.config)
|
||||
let terminalSessionId: number
|
||||
try {
|
||||
terminalSessionId = await resolveTerminalSessionId(sandbox, handle.pid, spec.signal)
|
||||
} catch (error: unknown) {
|
||||
await handle.kill().catch(() => false)
|
||||
await Promise.allSettled([handle.wait()])
|
||||
throw error
|
||||
}
|
||||
const session = new E2BPtySession(sandbox, handle, terminalSessionId, this.config)
|
||||
created.session = session
|
||||
try {
|
||||
const initializing = session.initialize(spec.signal)
|
||||
|
||||
@@ -105,6 +105,7 @@ export class E2BPtySession implements PtyBackendSession {
|
||||
constructor(
|
||||
private readonly sandbox: Sandbox,
|
||||
private readonly handle: CommandHandle,
|
||||
private readonly terminalSessionId: number,
|
||||
private readonly config: ResolvedConfig,
|
||||
) {
|
||||
this.pid = handle.pid
|
||||
@@ -314,6 +315,40 @@ export class E2BPtySession implements PtyBackendSession {
|
||||
return pgid
|
||||
}
|
||||
|
||||
private async sessionProcessGroups(): Promise<number[]> {
|
||||
const result = await this.sandbox.commands.run(
|
||||
`ps -eo sid=,pgid= | awk '$1 == ${this.terminalSessionId} { print $2 }'`,
|
||||
)
|
||||
const groups = new Set<number>()
|
||||
for (const raw of result.stdout.trim().split(/\s+/)) {
|
||||
if (raw.length === 0) continue
|
||||
const pgid = Number(raw)
|
||||
if (!/^[1-9][0-9]*$/.test(raw) || !Number.isSafeInteger(pgid) || pgid <= 1) {
|
||||
throw new Error(`pty-e2b: invalid process group ${JSON.stringify(raw)} in terminal session ${this.terminalSessionId}`)
|
||||
}
|
||||
groups.add(pgid)
|
||||
}
|
||||
return [...groups]
|
||||
}
|
||||
|
||||
private async signalProcessGroups(groups: number[], signal: 'TERM' | 'KILL'): Promise<void> {
|
||||
try {
|
||||
await this.sandbox.commands.run(`kill -${signal} -- ${groups.map(pgid => `-${pgid}`).join(' ')}`)
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof CommandExitError)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async awaitSessionEmpty(timeoutMs: number, signal?: 'KILL'): Promise<number[]> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
for (;;) {
|
||||
const groups = await this.sessionProcessGroups()
|
||||
if (groups.length === 0 || Date.now() >= deadline) return groups
|
||||
if (signal !== undefined) await this.signalProcessGroups(groups, signal)
|
||||
await delay(Math.min(this.config.pollIntervalMs, deadline - Date.now()))
|
||||
}
|
||||
}
|
||||
|
||||
private onExit(exitCode: number): void {
|
||||
this.remoteExited = true
|
||||
let tail = ''
|
||||
@@ -343,19 +378,20 @@ export class E2BPtySession implements PtyBackendSession {
|
||||
}
|
||||
|
||||
private async closeOnce(reason: string): Promise<void> {
|
||||
if (!this.remoteExited) {
|
||||
let survivingGroups = await this.sessionProcessGroups()
|
||||
if (survivingGroups.length > 0) {
|
||||
this.closeSignal = 'SIGTERM'
|
||||
try {
|
||||
await this.sandbox.commands.run(`kill -TERM -- -${this.pid}`)
|
||||
} catch (error: unknown) {
|
||||
if (!(error instanceof CommandExitError)) throw error
|
||||
}
|
||||
await Promise.race([this.exited.promise, delay(this.config.disposeGraceMs)])
|
||||
await this.signalProcessGroups(survivingGroups, 'TERM')
|
||||
survivingGroups = await this.awaitSessionEmpty(this.config.disposeGraceMs)
|
||||
}
|
||||
if (!this.remoteExited) {
|
||||
if (survivingGroups.length > 0 || !this.remoteExited) {
|
||||
this.closeSignal = 'SIGKILL'
|
||||
await this.sandbox.pty.kill(this.pid)
|
||||
await Promise.race([this.exited.promise, delay(this.config.disposeGraceMs)])
|
||||
if (!this.remoteExited) await this.sandbox.pty.kill(this.pid)
|
||||
survivingGroups = await this.awaitSessionEmpty(this.config.disposeGraceMs, 'KILL')
|
||||
if (!this.remoteExited) await Promise.race([this.exited.promise, delay(this.config.disposeGraceMs)])
|
||||
}
|
||||
if (survivingGroups.length > 0) {
|
||||
throw new Error(`E2B PTY cleanup failed (${reason}); surviving process groups: ${survivingGroups.join(', ')}`)
|
||||
}
|
||||
if (!this.remoteExited) {
|
||||
throw new Error(`E2B PTY cleanup failed (${reason}); surviving pid: ${this.pid}`)
|
||||
|
||||
@@ -42,7 +42,10 @@ describe('E2BPtyBackend and plugin', () => {
|
||||
it('creates a remote PTY with isolated environment and initializes the session', async () => {
|
||||
vi.useFakeTimers()
|
||||
const ctx = new Context()
|
||||
const sandbox = {} as Sandbox
|
||||
const run = vi.fn(async (command: string) => command.startsWith('env -0')
|
||||
? { exitCode: 0, stdout: 'NPM_TOKEN\0DSH_STALE\0KEEP\0', stderr: '' }
|
||||
: { exitCode: 0, stdout: '123\n', stderr: '' })
|
||||
const sandbox = { commands: { run } } as unknown as Sandbox
|
||||
ctx.provide('e2b', {
|
||||
cwd: '/workspace',
|
||||
getSandbox: async () => sandbox,
|
||||
@@ -65,9 +68,11 @@ describe('E2BPtyBackend and plugin', () => {
|
||||
expect(session.motd).toBe('banner\ndsh> ')
|
||||
expect(options).toMatchObject({ rows: 24, cols: 80, cwd: '/workspace/project', timeoutMs: 0 })
|
||||
expect(options?.envs).toMatchObject({
|
||||
NPM_TOKEN: '', DSH_STALE: '',
|
||||
TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', PS1: 'dsh> ',
|
||||
DSH_SHELL: '1', DSH_SESSION_ID: 'owner', DSH_PTY_SESSION_ID: 'pty-1',
|
||||
})
|
||||
expect(options?.envs).not.toHaveProperty('KEEP')
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
@@ -79,7 +84,10 @@ describe('E2BPtyBackend and plugin', () => {
|
||||
setTimeout(() => { void received.onData(Buffer.from('\x1b]133;D;0\x07dsh> ')) }, 0)
|
||||
return created
|
||||
})
|
||||
const sandbox = { pty: { create } } as unknown as Sandbox
|
||||
const sandbox = {
|
||||
commands: { run: async (command: string) => ({ exitCode: 0, stdout: command.startsWith('env -0') ? '' : '123\n', stderr: '' }) },
|
||||
pty: { create },
|
||||
} as unknown as Sandbox
|
||||
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => sandbox } as unknown as E2BSandboxService)
|
||||
const backend = new E2BPtyBackend(ctx, config())
|
||||
const pending = backend.spawn({ sessionId: PtySessionId('default'), owner: owner(ctx), type: 'shell' })
|
||||
@@ -91,7 +99,9 @@ describe('E2BPtyBackend and plugin', () => {
|
||||
|
||||
it('rejects aborts and invalid pids, killing a malformed SDK handle', async () => {
|
||||
const ctx = new Context()
|
||||
const sandbox = {} as Sandbox
|
||||
const sandbox = {
|
||||
commands: { run: async () => ({ exitCode: 0, stdout: '', stderr: '' }) },
|
||||
} as unknown as Sandbox
|
||||
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => sandbox } as E2BSandboxService)
|
||||
const create = vi.fn().mockResolvedValue(handle(0))
|
||||
const backend = new E2BPtyBackend(ctx, config(), create)
|
||||
@@ -109,13 +119,47 @@ describe('E2BPtyBackend and plugin', () => {
|
||||
const killFailure = handle(0, killFailureKill)
|
||||
const raced = new E2BPtyBackend(ctx, config(), async () => killFailure)
|
||||
await expect(raced.spawn({ sessionId: PtySessionId('three'), owner: owner(ctx), type: 'shell' })).rejects.toThrow('invalid PTY pid')
|
||||
|
||||
const invalidSessionKill = vi.fn().mockRejectedValue(new Error('kill raced'))
|
||||
const invalidSessionHandle = {
|
||||
pid: 123,
|
||||
wait: vi.fn().mockRejectedValue(new Error('already exited')),
|
||||
kill: invalidSessionKill,
|
||||
disconnect: vi.fn(),
|
||||
} as unknown as CommandHandle
|
||||
const invalidSessionSandbox = {
|
||||
commands: {
|
||||
run: async (command: string) => ({
|
||||
exitCode: 0,
|
||||
stdout: command.startsWith('env -0') ? '' : '9007199254740992\n',
|
||||
stderr: '',
|
||||
}),
|
||||
},
|
||||
} as unknown as Sandbox
|
||||
const invalidSessionContext = new Context()
|
||||
invalidSessionContext.provide('e2b', {
|
||||
cwd: '/workspace',
|
||||
getSandbox: async () => invalidSessionSandbox,
|
||||
} as E2BSandboxService)
|
||||
const invalidSession = new E2BPtyBackend(invalidSessionContext, config(), async () => invalidSessionHandle)
|
||||
await expect(invalidSession.spawn({
|
||||
sessionId: PtySessionId('four'), owner: owner(invalidSessionContext), type: 'shell',
|
||||
}))
|
||||
.rejects.toThrow('cannot resolve process session')
|
||||
expect(invalidSessionKill).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('cleans failed startup and aggregates a cleanup failure', async () => {
|
||||
vi.useFakeTimers()
|
||||
const ctx = new Context()
|
||||
const sandbox = {
|
||||
commands: { run: vi.fn().mockResolvedValue({ exitCode: 0, stdout: '', stderr: '' }) },
|
||||
commands: {
|
||||
run: vi.fn(async (command: string) => ({
|
||||
exitCode: 0,
|
||||
stdout: command.startsWith('ps -o sid=') || command.startsWith('ps -eo sid=') ? '123\n' : '',
|
||||
stderr: '',
|
||||
})),
|
||||
},
|
||||
pty: { kill: vi.fn().mockRejectedValue(new Error('cleanup failed')) },
|
||||
} as unknown as Sandbox
|
||||
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => sandbox } as E2BSandboxService)
|
||||
@@ -141,10 +185,19 @@ describe('E2BPtyBackend and plugin', () => {
|
||||
wait: () => completion.promise,
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as CommandHandle
|
||||
let sessionRunning = true
|
||||
const sandbox = {
|
||||
commands: {
|
||||
run: vi.fn(async (command: string) => {
|
||||
if (command.startsWith('kill -TERM')) completion.resolve({ exitCode: 143, stdout: '', stderr: '' })
|
||||
if (command.startsWith('env -0')) return { exitCode: 0, stdout: '', stderr: '' }
|
||||
if (command.startsWith('ps -o sid=')) return { exitCode: 0, stdout: '123\n', stderr: '' }
|
||||
if (command.startsWith('ps -eo sid=')) {
|
||||
return { exitCode: 0, stdout: sessionRunning ? '123\n' : '', stderr: '' }
|
||||
}
|
||||
if (command.startsWith('kill -TERM')) {
|
||||
sessionRunning = false
|
||||
completion.resolve({ exitCode: 143, stdout: '', stderr: '' })
|
||||
}
|
||||
return { exitCode: 0, stdout: '', stderr: '' }
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -64,10 +64,12 @@ class FakeSandbox {
|
||||
readonly commands: string[] = []
|
||||
readonly killed: number[] = []
|
||||
pgid = '456\n'
|
||||
sessionGroups = [123]
|
||||
sendError: unknown
|
||||
commandError: unknown
|
||||
signalError: unknown
|
||||
killError: unknown
|
||||
onTerm: (() => void) | undefined
|
||||
onGroupKill: (() => void) | undefined
|
||||
onKill: (() => void) | undefined
|
||||
|
||||
readonly sandbox = {
|
||||
@@ -86,13 +88,19 @@ class FakeSandbox {
|
||||
commands: {
|
||||
run: async (command: string): Promise<CommandResult> => {
|
||||
this.commands.push(command)
|
||||
if (this.commandError !== undefined) {
|
||||
const error = this.commandError
|
||||
this.commandError = undefined
|
||||
throw error
|
||||
if (command.startsWith('ps -o tpgid')) return { exitCode: 0, stdout: this.pgid, stderr: '' }
|
||||
if (command.startsWith('ps -eo sid=')) {
|
||||
return { exitCode: 0, stdout: this.sessionGroups.map(value => `${value}\n`).join(''), stderr: '' }
|
||||
}
|
||||
if (command.startsWith('kill -')) {
|
||||
if (this.signalError !== undefined) {
|
||||
const error = this.signalError
|
||||
this.signalError = undefined
|
||||
throw error
|
||||
}
|
||||
if (command.startsWith('kill -TERM')) this.onTerm?.()
|
||||
if (command.startsWith('kill -KILL')) this.onGroupKill?.()
|
||||
}
|
||||
if (command.startsWith('ps ')) return { exitCode: 0, stdout: this.pgid, stderr: '' }
|
||||
if (command.startsWith('kill -TERM')) this.onTerm?.()
|
||||
return { exitCode: 0, stdout: '', stderr: '' }
|
||||
},
|
||||
},
|
||||
@@ -122,7 +130,7 @@ describe('E2BPtySession readiness, output, and signals', () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new FakePtyHandle()
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config({ maxReadBytes: 12 }))
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config({ maxReadBytes: 12 }))
|
||||
expect(session.read({})).toMatchObject({ text: '', totalLines: 0 })
|
||||
await initialize(session)
|
||||
expect(session.motd).toBe('dsh> ')
|
||||
@@ -152,7 +160,7 @@ describe('E2BPtySession readiness, output, and signals', () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new FakePtyHandle()
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config())
|
||||
await initialize(session)
|
||||
|
||||
const inferred = session.startSend({ text: '', submit: false })
|
||||
@@ -176,7 +184,7 @@ describe('E2BPtySession readiness, output, and signals', () => {
|
||||
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
|
||||
|
||||
const startupHandle = new FakePtyHandle()
|
||||
const startup = new E2BPtySession(fake.sandbox, startupHandle.asHandle(), config())
|
||||
const startup = new E2BPtySession(fake.sandbox, startupHandle.asHandle(), 123, config())
|
||||
const timedOut = expect(startup.initialize()).rejects.toThrow('startup timeout')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await timedOut
|
||||
@@ -186,7 +194,7 @@ describe('E2BPtySession readiness, output, and signals', () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new FakePtyHandle()
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config())
|
||||
const initializing = session.initialize()
|
||||
session.onData(Buffer.from('\x1b]133;D;0\x07'))
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
@@ -230,7 +238,7 @@ describe('E2BPtySession readiness, output, and signals', () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new FakePtyHandle()
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config())
|
||||
await initialize(session)
|
||||
|
||||
const controller = new AbortController()
|
||||
@@ -268,7 +276,7 @@ describe('E2BPtySession readiness, output, and signals', () => {
|
||||
it('preserves startup abort reasons and classifies invalid UTF-8 transport failures', async () => {
|
||||
const fake = new FakeSandbox()
|
||||
const abortHandle = new FakePtyHandle()
|
||||
const abortSession = new E2BPtySession(fake.sandbox, abortHandle.asHandle(), config())
|
||||
const abortSession = new E2BPtySession(fake.sandbox, abortHandle.asHandle(), 123, config())
|
||||
const controller = new AbortController()
|
||||
const reason = new Error('startup cancelled')
|
||||
const initializing = abortSession.initialize(controller.signal)
|
||||
@@ -277,20 +285,20 @@ describe('E2BPtySession readiness, output, and signals', () => {
|
||||
await rejected
|
||||
|
||||
const invalidHandle = new FakePtyHandle()
|
||||
const invalid = new E2BPtySession(fake.sandbox, invalidHandle.asHandle(), config())
|
||||
const invalid = new E2BPtySession(fake.sandbox, invalidHandle.asHandle(), 123, config())
|
||||
const pending = invalid.startSend({ text: '', submit: false })
|
||||
invalid.onData(Uint8Array.from([0xff]))
|
||||
await expect(pending.done).rejects.toThrow('invalid UTF-8')
|
||||
expect(invalid.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
|
||||
|
||||
const crashHandle = new FakePtyHandle()
|
||||
const crashed = new E2BPtySession(fake.sandbox, crashHandle.asHandle(), config())
|
||||
const crashed = new E2BPtySession(fake.sandbox, crashHandle.asHandle(), 123, config())
|
||||
const active = crashed.startSend({ text: '', submit: false })
|
||||
crashHandle.crash('transport gone')
|
||||
await expect(active.done).rejects.toEqual(new Error('transport gone'))
|
||||
|
||||
const startupExitHandle = new FakePtyHandle()
|
||||
const startupExit = new E2BPtySession(fake.sandbox, startupExitHandle.asHandle(), config())
|
||||
const startupExit = new E2BPtySession(fake.sandbox, startupExitHandle.asHandle(), 123, config())
|
||||
const exitedDuringStartup = expect(startupExit.initialize()).rejects.toThrow('exited during startup')
|
||||
startupExitHandle.exit(7)
|
||||
await exitedDuringStartup
|
||||
@@ -300,12 +308,12 @@ describe('E2BPtySession readiness, output, and signals', () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
const tinyHandle = new FakePtyHandle()
|
||||
const tiny = new E2BPtySession(fake.sandbox, tinyHandle.asHandle(), config({ maxReadBytes: 1 }))
|
||||
const tiny = new E2BPtySession(fake.sandbox, tinyHandle.asHandle(), 123, config({ maxReadBytes: 1 }))
|
||||
tiny.onData(Buffer.from('你'))
|
||||
expect(tiny.read({ count: 1 })).toMatchObject({ text: '', lineEnd: 0 })
|
||||
|
||||
const handle = new FakePtyHandle()
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config())
|
||||
const operation = session.startSend({ text: '', submit: false })
|
||||
const internal = session as unknown as {
|
||||
pollReadiness(operation: PtySendOperation): void
|
||||
@@ -324,8 +332,8 @@ describe('E2BPtySession teardown', () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new FakePtyHandle()
|
||||
fake.onTerm = () => { handle.failExit(143) }
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
|
||||
fake.onTerm = () => { fake.sessionGroups = []; handle.failExit(143) }
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config())
|
||||
const first = session.close('done')
|
||||
expect(session.close('again')).toBe(first)
|
||||
await first
|
||||
@@ -334,63 +342,100 @@ describe('E2BPtySession teardown', () => {
|
||||
expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
|
||||
})
|
||||
|
||||
it('escalates every job-control group that survives shell exit', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
fake.sessionGroups = [123, 456]
|
||||
const handle = new FakePtyHandle()
|
||||
fake.onTerm = () => { fake.sessionGroups = [456]; handle.failExit(143) }
|
||||
fake.onGroupKill = () => { fake.sessionGroups = [] }
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config())
|
||||
|
||||
const closing = session.close('tree cleanup')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await closing
|
||||
|
||||
expect(fake.commands).toContain('kill -TERM -- -123 -456')
|
||||
expect(fake.commands).toContain('kill -KILL -- -456')
|
||||
})
|
||||
|
||||
it('contains an already-gone TERM, escalates to KILL, and reports a survivor', async () => {
|
||||
vi.useFakeTimers()
|
||||
const gone = new FakeSandbox()
|
||||
const goneHandle = new FakePtyHandle()
|
||||
gone.commandError = commandError(1)
|
||||
gone.signalError = commandError(1)
|
||||
gone.onGroupKill = () => { gone.sessionGroups = [] }
|
||||
gone.onKill = () => { goneHandle.failExit(137) }
|
||||
const goneSession = new E2BPtySession(gone.sandbox, goneHandle.asHandle(), config())
|
||||
const goneSession = new E2BPtySession(gone.sandbox, goneHandle.asHandle(), 123, config())
|
||||
const closingGone = goneSession.close('gone')
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await closingGone
|
||||
expect(gone.killed).toEqual([123])
|
||||
expect(goneSession.status()).toEqual({ kind: 'exited', exitCode: null, signal: 'SIGKILL' })
|
||||
|
||||
const survivor = new FakeSandbox()
|
||||
const survivorHandle = new FakePtyHandle()
|
||||
const survivorSession = new E2BPtySession(survivor.sandbox, survivorHandle.asHandle(), config())
|
||||
const failed = expect(survivorSession.close('still alive')).rejects.toThrow('surviving pid: 123')
|
||||
await vi.advanceTimersByTimeAsync(40)
|
||||
const survivorSession = new E2BPtySession(survivor.sandbox, survivorHandle.asHandle(), 123, config())
|
||||
const failed = expect(survivorSession.close('still alive')).rejects.toThrow('surviving process groups: 123')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await failed
|
||||
survivorHandle.exit()
|
||||
survivor.sessionGroups = []
|
||||
await expect(survivorSession.close('retry')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('propagates cleanup transport failures and lets close retry', async () => {
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new FakePtyHandle()
|
||||
fake.commandError = new Error('TERM transport failed')
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
|
||||
fake.signalError = new Error('TERM transport failed')
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config())
|
||||
await expect(session.close('failure')).rejects.toThrow('TERM transport failed')
|
||||
handle.exit()
|
||||
fake.sessionGroups = []
|
||||
await expect(session.close('retry')).resolves.toBeUndefined()
|
||||
|
||||
const invalidTailHandle = new FakePtyHandle()
|
||||
const invalidTail = new E2BPtySession(fake.sandbox, invalidTailHandle.asHandle(), config())
|
||||
const invalidTail = new E2BPtySession(fake.sandbox, invalidTailHandle.asHandle(), 123, config())
|
||||
invalidTail.onData(Uint8Array.from([0xe2]))
|
||||
invalidTailHandle.exit()
|
||||
await expect(invalidTail.close('invalid tail')).rejects.toThrow('invalid UTF-8')
|
||||
|
||||
const normalHandle = new FakePtyHandle()
|
||||
normalHandle.disconnectError = new Error('disconnect raced')
|
||||
const normal = new E2BPtySession(fake.sandbox, normalHandle.asHandle(), config())
|
||||
const normal = new E2BPtySession(fake.sandbox, normalHandle.asHandle(), 123, config())
|
||||
normalHandle.exit(7)
|
||||
await Promise.resolve()
|
||||
expect(normal.status()).toEqual({ kind: 'exited', exitCode: 7, signal: null })
|
||||
await expect(normal.close('already exited')).resolves.toBeUndefined()
|
||||
})
|
||||
|
||||
it('rejects invalid session groups and a shell handle that survives SDK kill', async () => {
|
||||
const invalid = new FakeSandbox()
|
||||
invalid.sessionGroups = [1]
|
||||
const invalidSession = new E2BPtySession(invalid.sandbox, new FakePtyHandle().asHandle(), 123, config())
|
||||
await expect(invalidSession.close('invalid group')).rejects.toThrow('invalid process group')
|
||||
|
||||
vi.useFakeTimers()
|
||||
const survivor = new FakeSandbox()
|
||||
survivor.sessionGroups = []
|
||||
const survivorHandle = new FakePtyHandle()
|
||||
const survivorSession = new E2BPtySession(survivor.sandbox, survivorHandle.asHandle(), 123, config())
|
||||
const failed = expect(survivorSession.close('shell survived')).rejects.toThrow('surviving pid: 123')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await failed
|
||||
expect(survivor.killed).toEqual([123])
|
||||
})
|
||||
|
||||
it('kills a remotely live PTY after its host transport fails', async () => {
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new FakePtyHandle()
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), 123, config())
|
||||
const active = session.startSend({ text: '', submit: false })
|
||||
session.onData(Uint8Array.from([0xff]))
|
||||
await expect(active.done).rejects.toThrow('invalid UTF-8')
|
||||
expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: null })
|
||||
|
||||
fake.onTerm = () => { handle.failExit(143) }
|
||||
fake.onTerm = () => { fake.sessionGroups = []; handle.failExit(143) }
|
||||
await expect(session.close('transport failed')).rejects.toThrow('invalid UTF-8')
|
||||
expect(fake.commands).toContain('kill -TERM -- -123')
|
||||
expect(handle.disconnects).toBe(1)
|
||||
|
||||
Reference in New Issue
Block a user