refactor(e2b): group remote providers
This commit is contained in:
6
packages/e2b/pty-e2b/README.i18n.yaml
Normal file
6
packages/e2b/pty-e2b/README.i18n.yaml
Normal file
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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
|
||||
54
packages/e2b/pty-e2b/README.md
Normal file
54
packages/e2b/pty-e2b/README.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# @deepseek-ai/dsh-pty-e2b
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
E2B byte-PTY backend for [`ctx.pty`](../../pty/pty/README.md). It creates persistent interactive shells inside the shared `ctx.e2b` sandbox while the PTY registry keeps session identity, exact-Agent ownership, and cleanup policy on the host.
|
||||
|
||||
## Plugin and configuration
|
||||
|
||||
The `pty-e2b` plugin injects `e2b` and `pty`, then registers one backend under `backendType`.
|
||||
|
||||
| Key | Default | Meaning |
|
||||
|---|---|---|
|
||||
| `backendType` | `shell` | Registry type selected by `terminal_open`. |
|
||||
| `rows` / `cols` | `40` / `160` | Initial remote PTY size. |
|
||||
| `scrollbackLines` | `10000` | Maximum retained logical lines. |
|
||||
| `scrollbackMaxBytes` | `4194304` | Maximum retained UTF-8 scrollback bytes. |
|
||||
| `maxReadBytes` | `262144` | Maximum bytes returned by one read or settled send. |
|
||||
| `pollIntervalMs` | `50` | Host readiness-poll interval. |
|
||||
| `idleSilenceMs` | `3000` | Output silence that yields `inferred_idle`. |
|
||||
| `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.
|
||||
|
||||
## 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.
|
||||
|
||||
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.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Indirect consumer
|
||||
|
||||
#### What the model sees
|
||||
|
||||
Nothing directly. Through `@deepseek-ai/dsh-tool-pty`, the model may receive bounded MOTD, send deltas, scrollback pages, readiness reasons, signal results, and cleanup failures.
|
||||
|
||||
#### Token effect
|
||||
|
||||
None until a consumer returns bounded backend output. Retained host PTY scrollback is not placed in model history by this package.
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
No direct invalidation; the consumer owns prompts, schemas, and appended results.
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **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.
|
||||
- **No reconnectable terminal handles** — retaining an E2B sandbox preserves remote files, not host ownership, buffers, callbacks, or live PTY sessions.
|
||||
54
packages/e2b/pty-e2b/README.zh.md
Normal file
54
packages/e2b/pty-e2b/README.zh.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# @deepseek-ai/dsh-pty-e2b
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
用于 [`ctx.pty`](../../pty/pty/README.md) 的 E2B 字节 PTY 后端。它在共享的 `ctx.e2b` 沙箱内创建持久交互式 shell;PTY 注册表则在宿主侧维护会话身份、精确的 Agent 所有权和清理策略。
|
||||
|
||||
## 插件与配置
|
||||
|
||||
`pty-e2b` 插件注入 `e2b` 和 `pty`,然后以 `backendType` 注册一个后端。
|
||||
|
||||
| 配置键 | 默认值 | 含义 |
|
||||
|---|---|---|
|
||||
| `backendType` | `shell` | `terminal_open` 选择的注册表类型。 |
|
||||
| `rows` / `cols` | `40` / `160` | 远程 PTY 的初始尺寸。 |
|
||||
| `scrollbackLines` | `10000` | 保留的逻辑行数上限。 |
|
||||
| `scrollbackMaxBytes` | `4194304` | 保留的 UTF-8 scrollback 字节数上限。 |
|
||||
| `maxReadBytes` | `262144` | 单次读取或发送结算时返回的字节数上限。 |
|
||||
| `pollIntervalMs` | `50` | 宿主就绪轮询间隔。 |
|
||||
| `idleSilenceMs` | `3000` | 触发 `inferred_idle` 的输出静默时长。 |
|
||||
| `timeoutMs` | `30000` | 启动与发送等待的绝对上限。 |
|
||||
| `disposeGraceMs` | `3000` | TERM 到 KILL 的清理宽限期。 |
|
||||
|
||||
数值必须是正的安全整数,`backendType` 必须非空,且 `maxReadBytes` 不得超过 `scrollbackMaxBytes`。相对的 spawn cwd 以 `ctx.e2b.cwd` 为基准解析;绝对远程路径保持不变。
|
||||
|
||||
## 运行时契约
|
||||
|
||||
该后端为 E2B 面向字节的 PTY 回调配备流式、遇到无效序列即失败的 UTF-8 解码器,随后使用 `dsh-pty` 提供的后端无关行清理器与有界缓冲区。它会安装受控的 Bash 提示符标记,并等待可打印的提示符文本;若该标记不可用,系统会在已经观察到输出且达到已配置的静默上限时得出 `inferred_idle`。零输出的启动过程会达到绝对超时并失败,不会发布空会话。
|
||||
|
||||
每次发送都会写入 UTF-8 字节,并可选写入回车提交序列。取消与显式信号会通过 `ps` 确定远程终端的前台进程组,再向该组发送信号;发送 `SIGKILL` 时拒绝以 shell 本身为目标。关闭操作向 PTY 进程组发送 `SIGTERM`,等待后通过 E2B 的 PTY kill 操作升级,并且直到 SDK 句柄报告退出才结算。如果启动失败,系统会关闭尚未发布的 PTY;若清理同时失败,`PtyBackendCleanupError` 会保留这项失败。
|
||||
|
||||
远程 PTY 进程及其子进程位于 E2B。提示符/就绪状态、scrollback、操作句柄、所有者权限和 SDK 事件交付仍保留在宿主内存中。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### 间接消费方
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
没有直接可见内容。模型通过 `@deepseek-ai/dsh-tool-pty` 可能收到有界的 MOTD、发送增量、scrollback 页、就绪原因、信号结果和清理失败。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
消费方返回有界的后端输出前没有影响。本包不会把宿主保留的 PTY scrollback 放入模型历史。
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
不会直接失效;提示词、schema 和追加结果由消费方负责。
|
||||
|
||||
## 已知限制与暂缓工作
|
||||
|
||||
- **面向行的终端模型**:CSI/OSC 控制序列会被移除;备用屏幕与完整终端仿真仍不受支持。
|
||||
- **就绪判断基于标记或静默**:E2B 会公开前台进程组,但不提供本地后端使用的 Linux syscall 检查,因此系统有意保留返回 `inferred_idle` 的可能性。
|
||||
- **仅支持 UTF-8**:无效字节序列会使会话失败,而不是返回有损文本。
|
||||
- **没有可重连的终端句柄**:保留 E2B 沙箱会保留远程文件,但不会保留宿主所有权、缓冲区、回调或实时 PTY 会话。
|
||||
46
packages/e2b/pty-e2b/package.json
Normal file
46
packages/e2b/pty-e2b/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"name": "@deepseek-ai/dsh-pty-e2b",
|
||||
"description": "E2B PTY provider for DeepSeek Harness",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "lib/index.js",
|
||||
"types": "lib/types/index.d.ts",
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"default": "./lib/index.js"
|
||||
},
|
||||
"./invariant": {
|
||||
"types": "./lib/types/invariant.d.ts",
|
||||
"default": "./lib/invariant.js"
|
||||
},
|
||||
"./src/*": "./src/*",
|
||||
"./package.json": "./package.json"
|
||||
},
|
||||
"files": [
|
||||
"lib/index.js",
|
||||
"lib/invariant.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"lib/types/**/*.d.ts.map",
|
||||
"src"
|
||||
],
|
||||
"license": "BSD-3-Clause",
|
||||
"peerDependencies": {
|
||||
"@deepseek-ai/dsh-e2b": "^0.0.1",
|
||||
"@deepseek-ai/dsh-invariants": "^0.0.1",
|
||||
"@deepseek-ai/dsh-pty": "^0.0.1",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
},
|
||||
"dependencies": {
|
||||
"schemastery": "^3.18.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@deepseek-ai/dsh-agent": "workspace:^",
|
||||
"@deepseek-ai/dsh-e2b": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-pty": "workspace:^",
|
||||
"@deepseek-ai/dsh-session": "workspace:^",
|
||||
"cordis": "^4.0.0-rc.7"
|
||||
}
|
||||
}
|
||||
64
packages/e2b/pty-e2b/src/config.ts
Normal file
64
packages/e2b/pty-e2b/src/config.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/** Validated configuration for the E2B PTY backend. */
|
||||
|
||||
import z from 'schemastery'
|
||||
|
||||
/** Public plugin configuration. */
|
||||
export interface Config {
|
||||
/** Backend registry type. */
|
||||
backendType?: string
|
||||
/** Initial terminal rows. */
|
||||
rows?: number
|
||||
/** Initial terminal columns. */
|
||||
cols?: number
|
||||
/** Maximum retained logical lines. */
|
||||
scrollbackLines?: number
|
||||
/** Maximum retained UTF-8 bytes. */
|
||||
scrollbackMaxBytes?: number
|
||||
/** Maximum bytes returned by one read or settled viewport. */
|
||||
maxReadBytes?: number
|
||||
/** Readiness polling interval. */
|
||||
pollIntervalMs?: number
|
||||
/** Output silence duration that yields `inferred_idle`. */
|
||||
idleSilenceMs?: number
|
||||
/** Absolute send and startup wait bound. */
|
||||
timeoutMs?: number
|
||||
/** Grace before PTY teardown escalates from TERM to KILL. */
|
||||
disposeGraceMs?: number
|
||||
}
|
||||
|
||||
/** Configuration after Schemastery defaults. */
|
||||
export type ResolvedConfig = Required<Config>
|
||||
|
||||
/* jscpd:ignore-start -- Loader requires a backend-local schema and load-time diagnostics. */
|
||||
/** Schemastery config exposed by the plugin. */
|
||||
export const Config: z<Config> = z.object({
|
||||
backendType: z.string().default('shell'),
|
||||
rows: z.number().default(40),
|
||||
cols: z.number().default(160),
|
||||
scrollbackLines: z.number().default(10_000),
|
||||
scrollbackMaxBytes: z.number().default(4 * 1024 * 1024),
|
||||
maxReadBytes: z.number().default(256 * 1024),
|
||||
pollIntervalMs: z.number().default(50),
|
||||
idleSilenceMs: z.number().default(3_000),
|
||||
timeoutMs: z.number().default(30_000),
|
||||
disposeGraceMs: z.number().default(3_000),
|
||||
})
|
||||
|
||||
/**
|
||||
* Validate the resolved configuration before publishing the backend.
|
||||
* @param config - Schemastery-resolved plugin configuration.
|
||||
* @returns Nothing; success narrows every optional field to its resolved value.
|
||||
*/
|
||||
export function validateConfig(config: Config): asserts config is ResolvedConfig {
|
||||
const resolved = config as ResolvedConfig
|
||||
if (resolved.backendType.length === 0) throw new Error('pty-e2b: backendType must be non-empty')
|
||||
for (const [name, value] of Object.entries(resolved)) {
|
||||
if (typeof value === 'number' && (!Number.isSafeInteger(value) || value <= 0)) {
|
||||
throw new Error(`pty-e2b: ${name} must be a positive safe integer`)
|
||||
}
|
||||
}
|
||||
if (resolved.maxReadBytes > resolved.scrollbackMaxBytes) {
|
||||
throw new Error('pty-e2b: maxReadBytes must not exceed scrollbackMaxBytes')
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
93
packages/e2b/pty-e2b/src/index.ts
Normal file
93
packages/e2b/pty-e2b/src/index.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/** E2B byte-PTY backend for persistent interactive terminal sessions. */
|
||||
|
||||
import { posix } from 'node:path'
|
||||
import type { Context } from 'cordis'
|
||||
import type { CommandHandle, Sandbox } from '@deepseek-ai/dsh-e2b'
|
||||
import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty'
|
||||
import type { PtyBackend, PtyBackendSpawnSpec } from '@deepseek-ai/dsh-pty'
|
||||
import { type Config, type ResolvedConfig, validateConfig } from './config.ts'
|
||||
import { E2BPtySession } from './session.ts'
|
||||
|
||||
export { Config } from './config.ts'
|
||||
export type { Config as PtyE2BConfig } from './config.ts'
|
||||
export { E2BPtySession } from './session.ts'
|
||||
|
||||
/** Cordis plugin name. */
|
||||
export const name = 'pty-e2b'
|
||||
/** Required shared sandbox owner and PTY registry. */
|
||||
export const inject = ['e2b', 'pty']
|
||||
|
||||
function terminalEnvironment(spec: PtyBackendSpawnSpec): Record<string, string> {
|
||||
return {
|
||||
TERM: 'dumb',
|
||||
PAGER: 'cat',
|
||||
GIT_PAGER: 'cat',
|
||||
PS1: 'dsh> ',
|
||||
PROMPT_COMMAND: 'printf "\\033]133;D;%s\\007" "$?"',
|
||||
BASH_SILENCE_DEPRECATION_WARNING: '1',
|
||||
DSH_SHELL: '1',
|
||||
DSH_SESSION_ID: spec.owner.id,
|
||||
DSH_PTY_SESSION_ID: spec.sessionId,
|
||||
}
|
||||
}
|
||||
|
||||
/** E2B backend registered under the configured terminal type. */
|
||||
export class E2BPtyBackend implements PtyBackend {
|
||||
readonly type: string
|
||||
|
||||
constructor(
|
||||
private readonly ctx: Context,
|
||||
private readonly config: ResolvedConfig,
|
||||
private readonly createPty: (
|
||||
sandbox: Sandbox,
|
||||
options: Parameters<Sandbox['pty']['create']>[0],
|
||||
) => Promise<CommandHandle> = (sandbox, options) => sandbox.pty.create(options),
|
||||
) {
|
||||
this.type = config.backendType
|
||||
}
|
||||
|
||||
/** Create, initialize, and publish one remote PTY session. */
|
||||
async spawn(spec: PtyBackendSpawnSpec): Promise<E2BPtySession> {
|
||||
spec.signal?.throwIfAborted()
|
||||
const sandbox = await this.ctx.e2b.getSandbox()
|
||||
spec.signal?.throwIfAborted()
|
||||
const pending: Uint8Array[] = []
|
||||
const created: { session?: E2BPtySession } = {}
|
||||
const handle = await this.createPty(sandbox, {
|
||||
rows: this.config.rows,
|
||||
cols: this.config.cols,
|
||||
cwd: posix.resolve(this.ctx.e2b.cwd, spec.cwd ?? this.ctx.e2b.cwd),
|
||||
envs: terminalEnvironment(spec),
|
||||
timeoutMs: 0,
|
||||
...spec.signal === undefined ? {} : { signal: spec.signal },
|
||||
onData: (data) => {
|
||||
if (created.session === undefined) pending.push(Uint8Array.from(data))
|
||||
else created.session.onData(data)
|
||||
},
|
||||
})
|
||||
if (!Number.isSafeInteger(handle.pid) || handle.pid <= 0) {
|
||||
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)
|
||||
created.session = session
|
||||
for (const data of pending) session.onData(data)
|
||||
try {
|
||||
await session.initialize(spec.signal)
|
||||
return session
|
||||
} catch (error: unknown) {
|
||||
try {
|
||||
await session.close('E2B PTY startup failed')
|
||||
} catch (cleanupError: unknown) {
|
||||
throw new PtyBackendCleanupError(error, cleanupError)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Register the E2B PTY backend. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
validateConfig(config)
|
||||
ctx.pty.registerBackend(new E2BPtyBackend(ctx, config))
|
||||
}
|
||||
20
packages/e2b/pty-e2b/src/invariant.ts
Normal file
20
packages/e2b/pty-e2b/src/invariant.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/** Package-owned invariant companion for `@deepseek-ai/dsh-pty-e2b`. */
|
||||
|
||||
/* jscpd:ignore-start */
|
||||
import type { Context } from 'cordis'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
const PACKAGE_NAME = '@deepseek-ai/dsh-pty-e2b'
|
||||
|
||||
/** Cordis companion plugin name. */
|
||||
export const name = 'pty-e2b-invariant'
|
||||
/** Service required before the companion can reserve package ownership. */
|
||||
export const inject = ['invariants']
|
||||
|
||||
/** No runtime invariant: the PTY registry owns publication and cleanup. */
|
||||
const install: InvariantInstaller = () => {}
|
||||
|
||||
/** Register this package's invariant companion. */
|
||||
export const apply = (ctx: Context): Promise<() => void> =>
|
||||
Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
|
||||
/* jscpd:ignore-end */
|
||||
367
packages/e2b/pty-e2b/src/session.ts
Normal file
367
packages/e2b/pty-e2b/src/session.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
/** One byte-oriented E2B PTY session projected onto the harness PTY seam. */
|
||||
|
||||
import { Buffer } from 'node:buffer'
|
||||
import type { CommandHandle, Sandbox } from '@deepseek-ai/dsh-e2b'
|
||||
import { CommandExitError } from '@deepseek-ai/dsh-e2b'
|
||||
import {
|
||||
PtyTerminalSanitizer,
|
||||
PtyTextBuffer,
|
||||
ptySignalName,
|
||||
ptyUtf8Tail,
|
||||
} from '@deepseek-ai/dsh-pty'
|
||||
import type {
|
||||
PtyBackendSession,
|
||||
PtyReadRequest,
|
||||
PtyReadResult,
|
||||
PtySendOperation,
|
||||
PtySendRead,
|
||||
PtySendRequest,
|
||||
PtySendResult,
|
||||
PtySessionStatus,
|
||||
PtySignal,
|
||||
PtySignalResult,
|
||||
PtyWaitReason,
|
||||
} from '@deepseek-ai/dsh-pty'
|
||||
import type { ResolvedConfig } from './config.ts'
|
||||
|
||||
function delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- Operation state stays backend-local because process readiness and cleanup identities diverge. */
|
||||
class E2BSendOperation implements PtySendOperation {
|
||||
private readonly output: PtyTextBuffer
|
||||
private readonly result = Promise.withResolvers<PtySendResult>()
|
||||
private finished = false
|
||||
|
||||
constructor(
|
||||
maxBytes: number,
|
||||
readonly startedAt: number,
|
||||
private readonly onCancel: () => void,
|
||||
) {
|
||||
this.output = new PtyTextBuffer(maxBytes)
|
||||
}
|
||||
|
||||
get done(): Promise<PtySendResult> {
|
||||
return this.result.promise
|
||||
}
|
||||
|
||||
append(text: string): void {
|
||||
if (!this.finished) this.output.append(text)
|
||||
}
|
||||
|
||||
settle(waitReason: PtyWaitReason, sessionStatus: PtySessionStatus, inheritedTruncation: boolean): void {
|
||||
if (this.finished) return
|
||||
this.finished = true
|
||||
const read = this.output.snapshot()
|
||||
this.result.resolve({
|
||||
viewport: read.text,
|
||||
waitReason,
|
||||
sessionStatus,
|
||||
truncated: read.truncated || inheritedTruncation,
|
||||
})
|
||||
}
|
||||
|
||||
fail(error: unknown): void {
|
||||
if (this.finished) return
|
||||
this.finished = true
|
||||
this.result.reject(error)
|
||||
}
|
||||
|
||||
readOutput(): PtySendRead {
|
||||
return this.output.consume()
|
||||
}
|
||||
|
||||
cancel(): boolean {
|
||||
if (this.finished) return false
|
||||
this.onCancel()
|
||||
return true
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/** Live session around one E2B SDK PTY handle. */
|
||||
export class E2BPtySession implements PtyBackendSession {
|
||||
motd = ''
|
||||
readonly pid: number
|
||||
private readonly decoder = new TextDecoder('utf-8', { fatal: true })
|
||||
private readonly sanitizer: PtyTerminalSanitizer
|
||||
private readonly scrollback: PtyTextBuffer
|
||||
private readonly exited = Promise.withResolvers<void>()
|
||||
private statusValue: PtySessionStatus = { kind: 'running' }
|
||||
private active: E2BSendOperation | undefined
|
||||
private activeTimer: NodeJS.Timeout | undefined
|
||||
private activeAbort: (() => void) | undefined
|
||||
private promptSeen = false
|
||||
private promptTextSeen = false
|
||||
private initializing = false
|
||||
private lastOutputAt = Date.now()
|
||||
private closing = false
|
||||
private closePromise: Promise<void> | undefined
|
||||
private closeSignal: NodeJS.Signals | null = null
|
||||
private transportFailure: Error | undefined
|
||||
private remoteExited = false
|
||||
|
||||
constructor(
|
||||
private readonly sandbox: Sandbox,
|
||||
private readonly handle: CommandHandle,
|
||||
private readonly config: ResolvedConfig,
|
||||
) {
|
||||
this.pid = handle.pid
|
||||
this.sanitizer = new PtyTerminalSanitizer(config.maxReadBytes)
|
||||
this.scrollback = new PtyTextBuffer(config.scrollbackMaxBytes, config.scrollbackLines)
|
||||
const completion = handle.wait()
|
||||
void completion.then(
|
||||
(result) => { this.onExit(result.exitCode) },
|
||||
(error: unknown) => {
|
||||
if (error instanceof CommandExitError) this.onExit(error.exitCode)
|
||||
else this.onTransportFailure(error)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consume bytes received by the SDK's PTY callback.
|
||||
* @param data - Exact callback bytes in delivery order.
|
||||
*/
|
||||
onData(data: Uint8Array): void {
|
||||
let decoded: string
|
||||
try {
|
||||
decoded = this.decoder.decode(data, { stream: true })
|
||||
} catch (error: unknown) {
|
||||
this.onTransportFailure(new Error('pty-e2b: PTY emitted invalid UTF-8', { cause: error }))
|
||||
return
|
||||
}
|
||||
const sanitized = this.sanitizer.push(decoded)
|
||||
this.appendOutput(sanitized.text)
|
||||
if (sanitized.prompt) {
|
||||
this.promptSeen = true
|
||||
this.promptTextSeen = sanitized.promptText === true
|
||||
this.lastOutputAt = Date.now()
|
||||
} else if (this.promptSeen && sanitized.promptText === true) {
|
||||
this.promptTextSeen = true
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Await the first prompt or bounded startup fallback.
|
||||
* @param signal - Optional startup cancellation signal.
|
||||
*/
|
||||
async initialize(signal?: AbortSignal): Promise<void> {
|
||||
this.initializing = true
|
||||
try {
|
||||
const operation = this.startSend({ text: '', submit: false, ...signal === undefined ? {} : { signal } })
|
||||
const result = await operation.done
|
||||
if (result.waitReason === 'session_exit') throw new Error('E2B PTY shell exited during startup')
|
||||
if (result.waitReason === 'timeout') throw new Error('E2B PTY shell did not reach readiness before startup timeout')
|
||||
this.motd = result.viewport
|
||||
} catch (error: unknown) {
|
||||
signal?.throwIfAborted()
|
||||
throw error
|
||||
} finally {
|
||||
this.initializing = false
|
||||
}
|
||||
}
|
||||
|
||||
/* jscpd:ignore-start -- PTY backends share request admission while owning distinct input and readiness transports. */
|
||||
startSend(request: PtySendRequest): PtySendOperation {
|
||||
if (this.closing) throw new Error('E2B PTY session is closing')
|
||||
if (this.statusValue.kind === 'exited') throw new Error('E2B PTY session has exited')
|
||||
if (this.active !== undefined) throw new Error('E2B PTY session already has an active send')
|
||||
if (request.signal?.aborted === true) throw new Error('E2B PTY send aborted before write')
|
||||
|
||||
const operation = new E2BSendOperation(
|
||||
this.config.maxReadBytes,
|
||||
Date.now(),
|
||||
() => { this.interrupt(operation) },
|
||||
)
|
||||
this.active = operation
|
||||
this.lastOutputAt = Date.now()
|
||||
this.promptSeen = false
|
||||
this.promptTextSeen = false
|
||||
if (request.signal !== undefined) {
|
||||
const onAbort = (): void => { operation.cancel() }
|
||||
request.signal.addEventListener('abort', onAbort, { once: true })
|
||||
this.activeAbort = () => request.signal?.removeEventListener('abort', onAbort)
|
||||
}
|
||||
|
||||
const input = `${request.text}${request.submit ? '\r' : ''}`
|
||||
if (input.length > 0) {
|
||||
void this.sandbox.pty.sendInput(this.pid, Buffer.from(input)).catch((error: unknown) => {
|
||||
if (this.active === operation) this.failActive(error)
|
||||
})
|
||||
}
|
||||
this.activeTimer = setInterval(() => { this.pollReadiness(operation) }, this.config.pollIntervalMs)
|
||||
return operation
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/* jscpd:ignore-start -- The seam requires identical bounded-read coordinates across backend buffers. */
|
||||
read(request: PtyReadRequest): PtyReadResult {
|
||||
const snapshot = this.scrollback.snapshot()
|
||||
const lines = snapshot.text.split('\n')
|
||||
const totalLines = snapshot.text.length === 0 ? 0 : lines.length
|
||||
const offset = request.offset ?? 0
|
||||
const count = request.count ?? 500
|
||||
if (!Number.isSafeInteger(offset) || offset < 0) throw new Error('PTY read offset must be a non-negative safe integer')
|
||||
if (!Number.isSafeInteger(count) || count <= 0) throw new Error('PTY read count must be a positive safe integer')
|
||||
if (offset >= totalLines) {
|
||||
return { text: '', totalLines, lineBegin: offset, lineEnd: offset, truncated: snapshot.truncated }
|
||||
}
|
||||
const end = totalLines - offset
|
||||
const start = Math.max(0, end - count)
|
||||
const bounded = ptyUtf8Tail(lines.slice(start, end).join('\n'), this.config.maxReadBytes)
|
||||
const returnedLines = bounded.text.length === 0 ? 0 : bounded.text.split('\n').length
|
||||
return {
|
||||
text: bounded.text,
|
||||
totalLines,
|
||||
lineBegin: offset,
|
||||
lineEnd: offset + returnedLines,
|
||||
truncated: snapshot.truncated || bounded.truncated,
|
||||
}
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
/* jscpd:ignore-start -- Signal, status, and close methods preserve the seam shape around remote identities. */
|
||||
async signal(signal: PtySignal): Promise<PtySignalResult> {
|
||||
const pgid = await this.foregroundPgid()
|
||||
if (signal === 'SIGKILL' && pgid === this.pid) {
|
||||
throw new Error('refusing to SIGKILL the E2B PTY shell; use terminal_close')
|
||||
}
|
||||
await this.sandbox.commands.run(`kill -${signal.slice(3)} -- -${pgid}`)
|
||||
return { delivered: true, targetPgid: pgid }
|
||||
}
|
||||
|
||||
status(): PtySessionStatus {
|
||||
return this.statusValue
|
||||
}
|
||||
|
||||
close(reason: string): Promise<void> {
|
||||
this.closing = true
|
||||
if (this.closePromise !== undefined) return this.closePromise
|
||||
const closing = this.closeOnce(reason).catch((error: unknown) => {
|
||||
this.closePromise = undefined
|
||||
this.failActive(error)
|
||||
throw error
|
||||
})
|
||||
this.closePromise = closing
|
||||
return closing
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
|
||||
private appendOutput(text: string): void {
|
||||
if (text.length === 0) return
|
||||
this.lastOutputAt = Date.now()
|
||||
this.scrollback.append(text)
|
||||
this.active?.append(text)
|
||||
}
|
||||
|
||||
private pollReadiness(operation: E2BSendOperation): void {
|
||||
if (this.active !== operation) return
|
||||
if (this.statusValue.kind === 'exited') {
|
||||
this.settleActive('session_exit')
|
||||
return
|
||||
}
|
||||
const elapsed = Date.now() - operation.startedAt
|
||||
const idleFor = Date.now() - this.lastOutputAt
|
||||
if (this.promptSeen && this.promptTextSeen && idleFor >= this.config.pollIntervalMs) {
|
||||
this.settleActive('stdin_read')
|
||||
return
|
||||
}
|
||||
const startupHasOutput = !this.initializing || this.scrollback.snapshot().text.length > 0
|
||||
if (startupHasOutput && idleFor >= this.config.idleSilenceMs) {
|
||||
this.settleActive('inferred_idle')
|
||||
return
|
||||
}
|
||||
if (elapsed >= this.config.timeoutMs) this.settleActive('timeout')
|
||||
}
|
||||
|
||||
private settleActive(waitReason: PtyWaitReason): void {
|
||||
const operation = this.active
|
||||
if (operation === undefined) return
|
||||
const inherited = this.scrollback.snapshot().truncated
|
||||
this.clearActive()
|
||||
operation.settle(waitReason, this.statusValue, inherited)
|
||||
}
|
||||
|
||||
private clearActive(): void {
|
||||
if (this.activeTimer !== undefined) clearInterval(this.activeTimer)
|
||||
this.activeTimer = undefined
|
||||
this.activeAbort?.()
|
||||
this.activeAbort = undefined
|
||||
this.active = undefined
|
||||
}
|
||||
|
||||
private failActive(error: unknown): void {
|
||||
const operation = this.active
|
||||
if (operation === undefined) return
|
||||
this.clearActive()
|
||||
operation.fail(error)
|
||||
}
|
||||
|
||||
private interrupt(operation: E2BSendOperation): void {
|
||||
if (this.active !== operation) return
|
||||
void this.signal('SIGINT').catch((error: unknown) => { this.failActive(error) })
|
||||
}
|
||||
|
||||
private async foregroundPgid(): Promise<number> {
|
||||
const result = await this.sandbox.commands.run(`ps -o tpgid= -p ${this.pid}`)
|
||||
const raw = result.stdout.trim()
|
||||
const pgid = Number(raw)
|
||||
if (!/^[1-9][0-9]*$/.test(raw) || !Number.isSafeInteger(pgid)) {
|
||||
throw new Error(`cannot resolve foreground process group for E2B PTY ${this.pid}`)
|
||||
}
|
||||
return pgid
|
||||
}
|
||||
|
||||
private onExit(exitCode: number): void {
|
||||
this.remoteExited = true
|
||||
let tail = ''
|
||||
try {
|
||||
tail = this.decoder.decode()
|
||||
} catch (error: unknown) {
|
||||
this.transportFailure ??= new Error('pty-e2b: PTY ended with invalid UTF-8', { cause: error })
|
||||
}
|
||||
this.appendOutput(this.sanitizer.push(tail).text)
|
||||
this.appendOutput(this.sanitizer.flush())
|
||||
const inferredSignal = this.closeSignal ?? (exitCode > 128 ? ptySignalName(exitCode - 128) : null)
|
||||
this.statusValue = {
|
||||
kind: 'exited',
|
||||
exitCode: inferredSignal === null ? exitCode : null,
|
||||
signal: inferredSignal,
|
||||
}
|
||||
if (this.transportFailure === undefined) this.settleActive('session_exit')
|
||||
else this.failActive(this.transportFailure)
|
||||
this.exited.resolve()
|
||||
}
|
||||
|
||||
private onTransportFailure(error: unknown): void {
|
||||
const failure = error instanceof Error ? error : new Error(String(error))
|
||||
this.transportFailure ??= failure
|
||||
this.statusValue = { kind: 'exited', exitCode: null, signal: null }
|
||||
this.failActive(failure)
|
||||
}
|
||||
|
||||
private async closeOnce(reason: string): Promise<void> {
|
||||
if (!this.remoteExited) {
|
||||
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)])
|
||||
}
|
||||
if (!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) {
|
||||
throw new Error(`E2B PTY cleanup failed (${reason}); surviving pid: ${this.pid}`)
|
||||
}
|
||||
this.settleActive('session_exit')
|
||||
await this.handle.disconnect().catch(() => {})
|
||||
if (this.transportFailure !== undefined) throw this.transportFailure
|
||||
}
|
||||
}
|
||||
195
packages/e2b/pty-e2b/tests/index.spec.ts
Normal file
195
packages/e2b/pty-e2b/tests/index.spec.ts
Normal file
@@ -0,0 +1,195 @@
|
||||
import { Context } from 'cordis'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { CommandHandle, Sandbox } from '@deepseek-ai/dsh-e2b'
|
||||
import type E2BSandboxService from '@deepseek-ai/dsh-e2b'
|
||||
import PtyService, { PtyBackendCleanupError, PtySessionId } from '@deepseek-ai/dsh-pty'
|
||||
import { E2BPtyBackend, apply } from '@deepseek-ai/dsh-pty-e2b'
|
||||
import { validateConfig } from '@deepseek-ai/dsh-pty-e2b/src/config.ts'
|
||||
import * as E2BPtyInvariant from '../src/invariant.ts'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import { AgentMessageId, type Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
function config() {
|
||||
return {
|
||||
backendType: 'shell', rows: 24, cols: 80,
|
||||
scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64,
|
||||
pollIntervalMs: 1, idleSilenceMs: 2, timeoutMs: 5, disposeGraceMs: 1,
|
||||
}
|
||||
}
|
||||
|
||||
function owner(ctx: Context): Agent {
|
||||
const id = SessionId('owner')
|
||||
return {
|
||||
id, options: {}, session: new Session(id), status: 'idle', acceptsNextStep: false, ctx,
|
||||
followup: () => AgentMessageId('unused'), steer: () => AgentMessageId('unused'),
|
||||
inject: () => AgentMessageId('unused'), send: () => AgentMessageId('unused'),
|
||||
cancel() {}, whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
}
|
||||
|
||||
function handle(pid = 123, kill = vi.fn().mockResolvedValue(true)): CommandHandle {
|
||||
const result = Promise.withResolvers<{ exitCode: number; stdout: string; stderr: string }>()
|
||||
return {
|
||||
pid,
|
||||
wait: () => result.promise,
|
||||
kill,
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as CommandHandle
|
||||
}
|
||||
|
||||
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
|
||||
ctx.provide('e2b', {
|
||||
cwd: '/workspace',
|
||||
getSandbox: async () => sandbox,
|
||||
} as E2BSandboxService)
|
||||
const created = handle()
|
||||
let options: Parameters<Sandbox['pty']['create']>[0] | undefined
|
||||
const backend = new E2BPtyBackend(ctx, config(), async (_sandbox, received) => {
|
||||
options = received
|
||||
void received.onData(Buffer.from('banner\n'))
|
||||
setTimeout(() => { void received.onData(Buffer.from('\x1b]133;D;0\x07dsh> ')) }, 0)
|
||||
return created
|
||||
})
|
||||
const pending = backend.spawn({
|
||||
sessionId: PtySessionId('pty-1'), owner: owner(ctx), type: 'shell', cwd: 'project',
|
||||
signal: new AbortController().signal,
|
||||
})
|
||||
await vi.advanceTimersByTimeAsync(2)
|
||||
const session = await pending
|
||||
|
||||
expect(session.motd).toBe('dsh> ')
|
||||
expect(options).toMatchObject({ rows: 24, cols: 80, cwd: '/workspace/project', timeoutMs: 0 })
|
||||
expect(options?.envs).toMatchObject({
|
||||
TERM: 'dumb', PAGER: 'cat', GIT_PAGER: 'cat', PS1: 'dsh> ',
|
||||
DSH_SHELL: '1', DSH_SESSION_ID: 'owner', DSH_PTY_SESSION_ID: 'pty-1',
|
||||
})
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('uses the SDK PTY create method and the shared cwd by default', async () => {
|
||||
vi.useFakeTimers()
|
||||
const ctx = new Context()
|
||||
const created = handle()
|
||||
const create = vi.fn(async (received: Parameters<Sandbox['pty']['create']>[0]) => {
|
||||
setTimeout(() => { void received.onData(Buffer.from('\x1b]133;D;0\x07dsh> ')) }, 0)
|
||||
return created
|
||||
})
|
||||
const sandbox = { 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' })
|
||||
await vi.advanceTimersByTimeAsync(2)
|
||||
await pending
|
||||
expect(create).toHaveBeenCalledWith(expect.objectContaining({ cwd: '/workspace' }))
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('rejects aborts and invalid pids, killing a malformed SDK handle', async () => {
|
||||
const ctx = new Context()
|
||||
const sandbox = {} 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)
|
||||
const aborted = AbortSignal.abort(new Error('stop'))
|
||||
await expect(backend.spawn({ sessionId: PtySessionId('one'), owner: owner(ctx), type: 'shell', signal: aborted })).rejects.toThrow('stop')
|
||||
expect(create).not.toHaveBeenCalled()
|
||||
|
||||
const malformedKill = vi.fn().mockResolvedValue(true)
|
||||
const malformed = handle(0, malformedKill)
|
||||
const invalid = new E2BPtyBackend(ctx, config(), async () => malformed)
|
||||
await expect(invalid.spawn({ sessionId: PtySessionId('two'), owner: owner(ctx), type: 'shell' })).rejects.toThrow('invalid PTY pid')
|
||||
expect(malformedKill).toHaveBeenCalledOnce()
|
||||
|
||||
const killFailureKill = vi.fn().mockRejectedValue(new Error('already gone'))
|
||||
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')
|
||||
})
|
||||
|
||||
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: '' }) },
|
||||
pty: { kill: vi.fn().mockRejectedValue(new Error('cleanup failed')) },
|
||||
} as unknown as Sandbox
|
||||
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => sandbox } as E2BSandboxService)
|
||||
const failedHandle = handle()
|
||||
const backend = new E2BPtyBackend(ctx, config(), async () => failedHandle)
|
||||
const pending = backend.spawn({ sessionId: PtySessionId('failed'), owner: owner(ctx), type: 'shell' })
|
||||
const rejected = expect(pending).rejects.toMatchObject({
|
||||
name: 'PtyBackendCleanupError',
|
||||
cleanupError: expect.objectContaining({ message: 'cleanup failed' }),
|
||||
} satisfies Partial<PtyBackendCleanupError>)
|
||||
await vi.advanceTimersByTimeAsync(6)
|
||||
await vi.advanceTimersByTimeAsync(2)
|
||||
await rejected
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('preserves startup failure when cleanup succeeds', async () => {
|
||||
vi.useFakeTimers()
|
||||
const ctx = new Context()
|
||||
const completion = Promise.withResolvers<{ exitCode: number; stdout: string; stderr: string }>()
|
||||
const created = {
|
||||
pid: 123,
|
||||
wait: () => completion.promise,
|
||||
disconnect: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as CommandHandle
|
||||
const sandbox = {
|
||||
commands: {
|
||||
run: vi.fn(async (command: string) => {
|
||||
if (command.startsWith('kill -TERM')) completion.resolve({ exitCode: 143, stdout: '', stderr: '' })
|
||||
return { exitCode: 0, stdout: '', stderr: '' }
|
||||
}),
|
||||
},
|
||||
pty: { kill: vi.fn().mockResolvedValue(true) },
|
||||
} as unknown as Sandbox
|
||||
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => sandbox } as unknown as E2BSandboxService)
|
||||
const backend = new E2BPtyBackend(ctx, config(), async () => created)
|
||||
const rejected = expect(backend.spawn({ sessionId: PtySessionId('failed-clean'), owner: owner(ctx), type: 'shell' }))
|
||||
.rejects.toThrow('startup timeout')
|
||||
await vi.advanceTimersByTimeAsync(6)
|
||||
await rejected
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('validates configuration and registers the selected backend type', async () => {
|
||||
const valid = config()
|
||||
expect(() => { validateConfig(valid) }).not.toThrow()
|
||||
for (const invalid of [
|
||||
{ ...valid, backendType: '' },
|
||||
{ ...valid, rows: 0 },
|
||||
{ ...valid, rows: 1.5 },
|
||||
{ ...valid, maxReadBytes: 129 },
|
||||
]) {
|
||||
expect(() => { validateConfig(invalid) }).toThrow()
|
||||
}
|
||||
|
||||
const registerBackend = vi.fn()
|
||||
apply({ pty: { registerBackend } } as unknown as Context, valid)
|
||||
expect(registerBackend).toHaveBeenCalledWith(expect.objectContaining({ type: 'shell' }))
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(PtyService)
|
||||
ctx.provide('e2b', { cwd: '/workspace', getSandbox: async () => ({}) } as never)
|
||||
const fiber = await ctx.plugin({
|
||||
inject: ['pty', 'e2b'],
|
||||
apply: (pluginCtx: Context) => { apply(pluginCtx, valid) },
|
||||
})
|
||||
expect(ctx.pty.listBackends()).toEqual(['shell'])
|
||||
await fiber.dispose()
|
||||
})
|
||||
|
||||
it('registers the package-owned invariant companion', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
const fiber = await ctx.plugin(E2BPtyInvariant).await()
|
||||
await fiber.dispose()
|
||||
})
|
||||
})
|
||||
398
packages/e2b/pty-e2b/tests/session.spec.ts
Normal file
398
packages/e2b/pty-e2b/tests/session.spec.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
CommandExitError,
|
||||
type CommandHandle,
|
||||
type CommandResult,
|
||||
type Sandbox,
|
||||
} from '@deepseek-ai/dsh-e2b'
|
||||
import type { PtySendOperation, PtySessionStatus } from '@deepseek-ai/dsh-pty'
|
||||
import { E2BPtySession } from '@deepseek-ai/dsh-pty-e2b'
|
||||
import type { ResolvedConfig } from '@deepseek-ai/dsh-pty-e2b/src/config.ts'
|
||||
|
||||
function commandError(exitCode: number): CommandExitError {
|
||||
return new CommandExitError({ exitCode, stdout: '', stderr: '', error: `exit ${exitCode}` })
|
||||
}
|
||||
|
||||
class FakePtyHandle {
|
||||
pid = 123
|
||||
readonly result = Promise.withResolvers<CommandResult>()
|
||||
disconnects = 0
|
||||
kills = 0
|
||||
disconnectError: unknown
|
||||
private settled = false
|
||||
|
||||
wait(): Promise<CommandResult> {
|
||||
return this.result.promise
|
||||
}
|
||||
|
||||
async disconnect(): Promise<void> {
|
||||
this.disconnects += 1
|
||||
if (this.disconnectError !== undefined) throw this.disconnectError
|
||||
}
|
||||
|
||||
async kill(): Promise<boolean> {
|
||||
this.kills += 1
|
||||
return true
|
||||
}
|
||||
|
||||
exit(exitCode = 0): void {
|
||||
if (this.settled) return
|
||||
this.settled = true
|
||||
this.result.resolve({ exitCode, stdout: '', stderr: '' })
|
||||
}
|
||||
|
||||
failExit(exitCode: number): void {
|
||||
if (this.settled) return
|
||||
this.settled = true
|
||||
this.result.reject(commandError(exitCode))
|
||||
}
|
||||
|
||||
crash(error: unknown): void {
|
||||
if (this.settled) return
|
||||
this.settled = true
|
||||
this.result.reject(error)
|
||||
}
|
||||
|
||||
asHandle(): CommandHandle {
|
||||
return this as unknown as CommandHandle
|
||||
}
|
||||
}
|
||||
|
||||
class FakeSandbox {
|
||||
readonly sent: Array<{ pid: number; data: Buffer }> = []
|
||||
readonly commands: string[] = []
|
||||
readonly killed: number[] = []
|
||||
pgid = '456\n'
|
||||
sendError: unknown
|
||||
commandError: unknown
|
||||
killError: unknown
|
||||
onTerm: (() => void) | undefined
|
||||
onKill: (() => void) | undefined
|
||||
|
||||
readonly sandbox = {
|
||||
pty: {
|
||||
sendInput: async (pid: number, data: Uint8Array): Promise<void> => {
|
||||
this.sent.push({ pid, data: Buffer.from(data) })
|
||||
if (this.sendError !== undefined) throw this.sendError
|
||||
},
|
||||
kill: async (pid: number): Promise<boolean> => {
|
||||
this.killed.push(pid)
|
||||
if (this.killError !== undefined) throw this.killError
|
||||
this.onKill?.()
|
||||
return true
|
||||
},
|
||||
},
|
||||
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 ')) return { exitCode: 0, stdout: this.pgid, stderr: '' }
|
||||
if (command.startsWith('kill -TERM')) this.onTerm?.()
|
||||
return { exitCode: 0, stdout: '', stderr: '' }
|
||||
},
|
||||
},
|
||||
} as unknown as Sandbox
|
||||
}
|
||||
|
||||
function config(overrides: Partial<ResolvedConfig> = {}): ResolvedConfig {
|
||||
return {
|
||||
backendType: 'shell', rows: 24, cols: 80,
|
||||
scrollbackLines: 10, scrollbackMaxBytes: 128, maxReadBytes: 64,
|
||||
pollIntervalMs: 10, idleSilenceMs: 40, timeoutMs: 100, disposeGraceMs: 20,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function initialize(session: E2BPtySession): Promise<void> {
|
||||
const pending = session.initialize()
|
||||
session.onData(Buffer.from('\x1b]133;D;0\x07dsh> '))
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await pending
|
||||
}
|
||||
|
||||
afterEach(() => { vi.useRealTimers() })
|
||||
|
||||
describe('E2BPtySession readiness, output, and signals', () => {
|
||||
it('initializes, sends UTF-8 input, settles at a prompt, and reads bounded scrollback', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new FakePtyHandle()
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config({ maxReadBytes: 12 }))
|
||||
expect(session.read({})).toMatchObject({ text: '', totalLines: 0 })
|
||||
await initialize(session)
|
||||
expect(session.motd).toBe('dsh> ')
|
||||
|
||||
const operation = session.startSend({ text: 'printf 你好', submit: true })
|
||||
expect(fake.sent).toEqual([{ pid: 123, data: Buffer.from('printf 你好\r') }])
|
||||
session.onData(Buffer.from('一\n二\n三\x1b]133;D;0\x07dsh> '))
|
||||
const bounded = operation.readOutput()
|
||||
expect(bounded.delta).toContain('三')
|
||||
expect(bounded.truncated).toBe(true)
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect(await operation.done).toMatchObject({ waitReason: 'stdin_read', sessionStatus: { kind: 'running' } })
|
||||
expect(operation.cancel()).toBe(false)
|
||||
expect(session.read({ count: 2 }).text).toContain('dsh>')
|
||||
expect(session.read({ offset: 99 })).toMatchObject({ text: '', lineBegin: 99, lineEnd: 99 })
|
||||
expect(() => session.read({ offset: -1 })).toThrow('non-negative safe integer')
|
||||
expect(() => session.read({ offset: 1.5 })).toThrow('non-negative safe integer')
|
||||
expect(() => session.read({ count: 0 })).toThrow('positive safe integer')
|
||||
expect(() => session.read({ count: 1.5 })).toThrow('positive safe integer')
|
||||
|
||||
await expect(session.signal('SIGTERM')).resolves.toEqual({ delivered: true, targetPgid: 456 })
|
||||
expect(fake.commands).toContain('kill -TERM -- -456')
|
||||
expect(session.status()).toEqual({ kind: 'running' })
|
||||
})
|
||||
|
||||
it('distinguishes inferred idle, timeout, session exit, and no-output startup timeout', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new FakePtyHandle()
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
|
||||
await initialize(session)
|
||||
|
||||
const inferred = session.startSend({ text: '', submit: false })
|
||||
await vi.advanceTimersByTimeAsync(40)
|
||||
expect((await inferred.done).waitReason).toBe('inferred_idle')
|
||||
|
||||
const timeout = session.startSend({ text: '', submit: false })
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
await vi.advanceTimersByTimeAsync(30)
|
||||
session.onData(Buffer.from('.'))
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
expect((await timeout.done).waitReason).toBe('timeout')
|
||||
|
||||
const exiting = session.startSend({ text: '', submit: false })
|
||||
handle.failExit(143)
|
||||
expect(await exiting.done).toMatchObject({
|
||||
waitReason: 'session_exit',
|
||||
sessionStatus: { kind: 'exited', exitCode: null, signal: 'SIGTERM' },
|
||||
})
|
||||
expect(() => session.startSend({ text: '', submit: false })).toThrow('has exited')
|
||||
|
||||
const startupHandle = new FakePtyHandle()
|
||||
const startup = new E2BPtySession(fake.sandbox, startupHandle.asHandle(), config())
|
||||
const timedOut = expect(startup.initialize()).rejects.toThrow('startup timeout')
|
||||
await vi.advanceTimersByTimeAsync(100)
|
||||
await timedOut
|
||||
})
|
||||
|
||||
it('handles split prompt text, stale operations, and explicit cancellation', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new FakePtyHandle()
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
|
||||
const initializing = session.initialize()
|
||||
session.onData(Buffer.from('\x1b]133;D;0\x07'))
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
session.onData(Buffer.from('dsh> '))
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await initializing
|
||||
|
||||
const operation = session.startSend({ text: 'sleep', submit: true })
|
||||
const internal = session as unknown as {
|
||||
pollReadiness(operation: PtySendOperation): void
|
||||
interrupt(operation: PtySendOperation): void
|
||||
settleActive(reason: 'timeout'): void
|
||||
failActive(error: unknown): void
|
||||
appendOutput(text: string): void
|
||||
statusValue: PtySessionStatus
|
||||
}
|
||||
internal.pollReadiness({} as PtySendOperation)
|
||||
internal.interrupt({} as PtySendOperation)
|
||||
internal.appendOutput('')
|
||||
fake.pgid = '789\n'
|
||||
expect(operation.cancel()).toBe(true)
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(fake.commands).toContain('kill -INT -- -789')
|
||||
session.onData(Buffer.from('\x1b]133;D;130\x07dsh> '))
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await operation.done
|
||||
|
||||
internal.settleActive('timeout')
|
||||
internal.failActive(new Error('ignored'))
|
||||
const operationInternal = operation as unknown as {
|
||||
append(text: string): void
|
||||
settle(reason: 'timeout', status: PtySessionStatus, inherited: boolean): void
|
||||
fail(error: unknown): void
|
||||
}
|
||||
operationInternal.append('ignored')
|
||||
operationInternal.settle('timeout', { kind: 'running' }, false)
|
||||
operationInternal.fail(new Error('ignored'))
|
||||
})
|
||||
|
||||
it('observes AbortSignal and contains send or foreground lookup failures', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new FakePtyHandle()
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
|
||||
await initialize(session)
|
||||
|
||||
const controller = new AbortController()
|
||||
const aborting = session.startSend({ text: '', submit: false, signal: controller.signal })
|
||||
expect(() => session.startSend({ text: '', submit: false })).toThrow('active send')
|
||||
fake.pgid = 'not-a-pgid\n'
|
||||
controller.abort()
|
||||
await expect(aborting.done).rejects.toThrow('cannot resolve foreground process group')
|
||||
|
||||
const already = new AbortController()
|
||||
already.abort()
|
||||
expect(() => session.startSend({ text: '', submit: false, signal: already.signal })).toThrow('aborted before write')
|
||||
|
||||
fake.sendError = new Error('send failed')
|
||||
const failed = session.startSend({ text: 'x', submit: false })
|
||||
await expect(failed.done).rejects.toThrow('send failed')
|
||||
|
||||
fake.pgid = '123\n'
|
||||
await expect(session.signal('SIGKILL')).rejects.toThrow('refusing to SIGKILL')
|
||||
fake.pgid = '0\n'
|
||||
await expect(session.signal('SIGINT')).rejects.toThrow('cannot resolve')
|
||||
|
||||
const deferred = Promise.withResolvers<undefined>()
|
||||
fake.sendError = undefined
|
||||
const sendInput = vi.spyOn(fake.sandbox.pty, 'sendInput').mockReturnValueOnce(deferred.promise)
|
||||
const late = session.startSend({ text: 'late', submit: false })
|
||||
session.onData(Buffer.from('\x1b]133;D;0\x07dsh> '))
|
||||
await vi.advanceTimersByTimeAsync(10)
|
||||
await late.done
|
||||
deferred.reject(new Error('late failure'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(sendInput).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
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 controller = new AbortController()
|
||||
const reason = new Error('startup cancelled')
|
||||
const initializing = abortSession.initialize(controller.signal)
|
||||
const rejected = expect(initializing).rejects.toBe(reason)
|
||||
controller.abort(reason)
|
||||
await rejected
|
||||
|
||||
const invalidHandle = new FakePtyHandle()
|
||||
const invalid = new E2BPtySession(fake.sandbox, invalidHandle.asHandle(), 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 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 exitedDuringStartup = expect(startupExit.initialize()).rejects.toThrow('exited during startup')
|
||||
startupExitHandle.exit(7)
|
||||
await exitedDuringStartup
|
||||
})
|
||||
|
||||
it('covers empty bounded reads and polling an exited active session', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
const tinyHandle = new FakePtyHandle()
|
||||
const tiny = new E2BPtySession(fake.sandbox, tinyHandle.asHandle(), 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 operation = session.startSend({ text: '', submit: false })
|
||||
const internal = session as unknown as {
|
||||
pollReadiness(operation: PtySendOperation): void
|
||||
clearActive(): void
|
||||
statusValue: PtySessionStatus
|
||||
}
|
||||
internal.statusValue = { kind: 'exited', exitCode: 7, signal: null }
|
||||
internal.pollReadiness(operation)
|
||||
expect((await operation.done).waitReason).toBe('session_exit')
|
||||
internal.clearActive()
|
||||
})
|
||||
})
|
||||
|
||||
describe('E2BPtySession teardown', () => {
|
||||
it('terminates the process group once, awaits exit, and disconnects', async () => {
|
||||
vi.useFakeTimers()
|
||||
const fake = new FakeSandbox()
|
||||
const handle = new FakePtyHandle()
|
||||
fake.onTerm = () => { handle.failExit(143) }
|
||||
const session = new E2BPtySession(fake.sandbox, handle.asHandle(), config())
|
||||
const first = session.close('done')
|
||||
expect(session.close('again')).toBe(first)
|
||||
await first
|
||||
expect(session.status()).toEqual({ kind: 'exited', exitCode: null, signal: 'SIGTERM' })
|
||||
expect(handle.disconnects).toBe(1)
|
||||
expect(() => session.startSend({ text: '', submit: false })).toThrow('closing')
|
||||
})
|
||||
|
||||
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.onKill = () => { goneHandle.failExit(137) }
|
||||
const goneSession = new E2BPtySession(gone.sandbox, goneHandle.asHandle(), config())
|
||||
const closingGone = goneSession.close('gone')
|
||||
await vi.advanceTimersByTimeAsync(20)
|
||||
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)
|
||||
await failed
|
||||
survivorHandle.exit()
|
||||
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())
|
||||
await expect(session.close('failure')).rejects.toThrow('TERM transport failed')
|
||||
handle.exit()
|
||||
await expect(session.close('retry')).resolves.toBeUndefined()
|
||||
|
||||
const invalidTailHandle = new FakePtyHandle()
|
||||
const invalidTail = new E2BPtySession(fake.sandbox, invalidTailHandle.asHandle(), 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())
|
||||
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('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 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) }
|
||||
await expect(session.close('transport failed')).rejects.toThrow('invalid UTF-8')
|
||||
expect(fake.commands).toContain('kill -TERM -- -123')
|
||||
expect(handle.disconnects).toBe(1)
|
||||
})
|
||||
})
|
||||
16
packages/e2b/pty-e2b/tsconfig.json
Normal file
16
packages/e2b/pty-e2b/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"extends": "../../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [
|
||||
{ "path": "../../../vendor/cosmokit" },
|
||||
{ "path": "../../../vendor/cordis" },
|
||||
{ "path": "../../../vendor/schemastery" },
|
||||
{ "path": "../e2b" },
|
||||
{ "path": "../../pty/pty" },
|
||||
{ "path": "../../support/invariants" }
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user