Add E2B PTY, LSP, and code runtime providers

This commit is contained in:
Tianyi Cui
2026-07-28 13:59:47 +08:00
parent e7b682f1f6
commit 6667102890
82 changed files with 5462 additions and 644 deletions

View File

@@ -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/README.md
README.md: 4b28ec7c0452189a579a6d7e0cd22a0b8561784d
README.zh.md: ab254eecad82c05cd6a6021e7f8bc4eda6cf3076
README.md: 9e7f1a98ada9f56f3d1e3f74d2a906f8a1c7bc15
README.zh.md: 53fdf30eaf405ab5313274a7fadde0b044b7fba1

View File

@@ -2,12 +2,15 @@
English | [中文](README.zh.md)
An experimental provider-composition POC that places the filesystem and managed subprocess world in one E2B Linux sandbox. The shared owner is separate from the capability adapters so every remote provider awaits the same sandbox identity and lifecycle.
An experimental provider-composition POC that places the mutable coding world in one E2B Linux sandbox. The shared owner is separate from capability adapters so every remote provider awaits the same sandbox identity and lifecycle.
| Package | ctx key | Role |
|---|---|---|
| [`e2b`](e2b/README.md) (`@deepseek-ai/dsh-e2b`) | `ctx.e2b` | Create or reconnect one sandbox, create its working/runtime directories, expose the shared SDK handle, and apply the configured kill/pause/leave disposition |
| [`fs-e2b`](../fs/fs-e2b/README.md) (`@deepseek-ai/dsh-fs-e2b`) | `ctx.fs` | Implement the filesystem seam over E2B Filesystem APIs |
| [`subprocess-e2b`](../subprocess/subprocess-e2b/README.md) (`@deepseek-ai/dsh-subprocess-e2b`) | `ctx.subprocess` | Implement managed process groups, stdio projection, and remote spill files over E2B Commands |
| [`pty-e2b`](../pty/pty-e2b/README.md) (`@deepseek-ai/dsh-pty-e2b`) | `ctx.pty` backend | Run persistent interactive shells through E2B's byte PTY API |
| [`lsp-e2b`](../lsp/lsp-e2b/README.md) (`@deepseek-ai/dsh-lsp-e2b`) | `ctx.lsp` provider | Run configured language servers and read query sources inside E2B |
| [`code-runtime-e2b`](../code-runtime/code-runtime-e2b/README.md) (`@deepseek-ai/dsh-code-runtime-e2b`) | `ctx.codeRuntime` | Run model-written programs remotely while bridging bindings to the host |
The existing [`dsh-bash-local`](../bash/bash-local/README.md) needs no E2B-specific fork: it already delegates process mechanics to `ctx.subprocess`, so replacing that provider places Bash in the same remote world as `ctx.fs`. This boundary does not move the harness process, Cordis objects, model calls, agent/session state, session persistence, skills, or E2B SDK buffers. The [decision record](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md) owns the POC boundary and rejected expansion.
The existing [`dsh-bash-local`](../bash/bash-local/README.md) needs no E2B-specific fork: it delegates process mechanics to `ctx.subprocess`, so replacing that provider places Bash in the same remote world. This boundary does not move the harness process, Cordis objects, model calls, agent/session state, session persistence, skills, protocol state, or E2B SDK buffers. The [base decision](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md) and [runtime-extension decision](../../.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md) own the POC boundary.

View File

@@ -2,12 +2,15 @@
[English](README.md) | 中文
这是一个实验性提供方组合 POC,把文件系统和受管子进程环境放进同一个 E2B Linux 沙箱。共享所有者与功能适配器彼此分离,使每个远程提供方都等待同一个沙箱身份和生命周期。
这是一个实验性提供方组合 POC,把可变的编码环境放进同一个 E2B Linux 沙箱。共享所有者与功能适配器分离,使每个远程提供方都等待同一个沙箱身份和生命周期。
| 包(package) | ctx 键 | 职责 |
|---|---|---|
| [`e2b`](e2b/README.md)(`@deepseek-ai/dsh-e2b`) | `ctx.e2b` | 创建或重新连接一个沙箱,创建其工作目录与运行时目录,公开共享 SDK 句柄,并应用配置的 kill/pause/leave 处置方式 |
| [`fs-e2b`](../fs/fs-e2b/README.md)(`@deepseek-ai/dsh-fs-e2b`) | `ctx.fs` | 通过 E2B Filesystem API 实现文件系统 seam |
| [`subprocess-e2b`](../subprocess/subprocess-e2b/README.md)(`@deepseek-ai/dsh-subprocess-e2b`) | `ctx.subprocess` | 通过 E2B Commands 实现受管进程组、stdio 投影与远程 spill 文件 |
| [`pty-e2b`](../pty/pty-e2b/README.md)(`@deepseek-ai/dsh-pty-e2b`) | `ctx.pty` 后端 | 通过 E2B 的字节 PTY API 运行持久交互式 shell |
| [`lsp-e2b`](../lsp/lsp-e2b/README.md)(`@deepseek-ai/dsh-lsp-e2b`) | `ctx.lsp` 提供方 | 在 E2B 内运行已配置的语言服务器并读取查询源代码 |
| [`code-runtime-e2b`](../code-runtime/code-runtime-e2b/README.md)(`@deepseek-ai/dsh-code-runtime-e2b`) | `ctx.codeRuntime` | 远程运行模型编写的程序,同时把绑定桥接到宿主 |
现有的 [`dsh-bash-local`](../bash/bash-local/README.md) 无需 E2B 专用 fork:它已经把进程机制委托给 `ctx.subprocess`,因此替换该提供方即可让 Bash 与 `ctx.fs` 进入同一个远程环境。该边界不会迁移 harness 进程、Cordis 对象、模型调用、agent(智能体)/会话状态、会话持久化、skill(技能)或 E2B SDK 缓冲。[决策记录](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md)负责说明 POC 边界及未采纳的扩展方案。
现有的 [`dsh-bash-local`](../bash/bash-local/README.md) 无需 E2B 专用 fork:它把进程机制委托给 `ctx.subprocess`,因此替换该提供方即可让 Bash 进入同一个远程环境。该边界不会迁移 harness 进程、Cordis 对象、模型调用、agent(智能体)/会话状态、会话持久化、skill(技能)、协议状态或 E2B SDK 缓冲。[基础决策](../../.agents/notes/implemented/feature/2026-07-27-e2b-remote-runtime-poc.md)与[运行时扩展决策](../../.agents/notes/implemented/feature/2026-07-28-e2b-interactive-semantic-code-runtime-poc.md)共同界定 POC 边界。

View File

@@ -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/e2b/README.md
README.md: bf62cb7d4811ca92f263bd3d337fff9fe41ce223
README.zh.md: 8c7603aa501a2481aba6dab1f22750056e43ad26
README.md: 5f89a4bcffdfd11fef8929d2a2ceecb41af319e2
README.zh.md: b4956033bae131bb8aa236276323ecba30f00115

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
Shared lifecycle owner for one E2B sandbox. Filesystem and subprocess adapters inject `ctx.e2b`, await its single SDK handle, and therefore inhabit the same remote Linux working tree and process world. The package pins `e2b@2.29.1`.
Shared lifecycle owner for one E2B sandbox. Capability adapters inject `ctx.e2b`, await its single SDK handle, and therefore inhabit the same remote Linux working tree and process world. The package pins `e2b@2.29.1`; the [family map](../README.md) lists the opt-in adapters.
## Configuration

View File

@@ -2,7 +2,7 @@
[English](README.md) | 中文
一个 E2B 沙箱的共享生命周期所有者。文件系统与进程管理适配器注入 `ctx.e2b`,等待其唯一的 SDK 句柄,因此处于同一个远程 Linux 工作树与进程环境中。本包固定使用 `e2b@2.29.1`。
一个 E2B 沙箱的共享生命周期所有者。功能适配器注入 `ctx.e2b`,等待其唯一的 SDK 句柄,因此处于同一个远程 Linux 工作树与进程环境中。本包固定使用 `e2b@2.29.1`;可选适配器见[包族索引](../README.md)。
## 配置

View File

@@ -0,0 +1,75 @@
/** ASCII/base64 JSON framing for byte-faithful protocols over E2B text callbacks. */
import { Buffer } from 'node:buffer'
const BASE64_LINE = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
/**
* Encode one JSON-compatible value as a newline-delimited ASCII frame.
* @param value - Value accepted by `JSON.stringify`.
* @returns Base64-encoded UTF-8 JSON followed by one newline.
*/
export function encodeE2BFrame(value: unknown): string {
const json: unknown = JSON.stringify(value)
if (typeof json !== 'string') throw new Error('E2B frame value is not JSON-serializable')
return `${Buffer.from(json).toString('base64')}\n`
}
/** Incremental decoder for newline-delimited base64 JSON frames. */
export class E2BFrameDecoder {
private pending = ''
private readonly maxEncodedChars: number
/** @param maxFrameBytes - Maximum decoded UTF-8 JSON bytes in one frame. */
constructor(private readonly maxFrameBytes: number) {
if (!Number.isSafeInteger(maxFrameBytes) || maxFrameBytes <= 0) {
throw new Error('E2B frame maxFrameBytes must be a positive safe integer')
}
this.maxEncodedChars = Math.ceil(maxFrameBytes / 3) * 4
}
/**
* Consume one E2B callback chunk.
* @param chunk - ASCII text received from the remote helper.
* @returns Every complete decoded JSON value, in order.
*/
push(chunk: string): unknown[] {
if (/[^\x0a\x20-\x7e]/.test(chunk)) throw new Error('E2B frame stream contained non-ASCII data')
this.pending += chunk
const values: unknown[] = []
for (;;) {
const newline = this.pending.indexOf('\n')
if (newline < 0) {
if (this.pending.length > this.maxEncodedChars) throw new Error('E2B frame exceeded its byte limit')
return values
}
const line = this.pending.slice(0, newline)
this.pending = this.pending.slice(newline + 1)
values.push(this.decode(line))
}
}
/** Reject a truncated final frame. */
finish(): void {
if (this.pending.length !== 0) throw new Error('E2B frame stream ended mid-frame')
}
private decode(line: string): unknown {
if (line.length === 0 || line.length > this.maxEncodedChars || !BASE64_LINE.test(line)) {
throw new Error('E2B frame contained invalid base64 or exceeded its byte limit')
}
const bytes = Buffer.from(line, 'base64')
if (bytes.length > this.maxFrameBytes) throw new Error('E2B frame exceeded its byte limit')
let json: string
try {
json = new TextDecoder('utf-8', { fatal: true }).decode(bytes)
} catch (error: unknown) {
throw new Error('E2B frame contained invalid UTF-8', { cause: error })
}
try {
return JSON.parse(json) as unknown
} catch (error: unknown) {
throw new Error('E2B frame contained invalid JSON', { cause: error })
}
}
}

View File

@@ -10,6 +10,8 @@ import z from 'schemastery'
import { Sandbox } from 'e2b'
import type { Branded } from '@deepseek-ai/dsh-brand'
export { E2BFrameDecoder, encodeE2BFrame } from './frame.ts'
export {
CommandExitError,
FileNotFoundError,
@@ -19,7 +21,7 @@ export {
SandboxNotFoundError,
TimeoutError,
} from 'e2b'
export type { CommandHandle, CommandResult, EntryInfo } from 'e2b'
export type { CommandHandle, CommandResult, EntryInfo, ProcessInfo, PtyOutput } from 'e2b'
/** Opaque E2B sandbox identity used for reconnecting a later harness process. */
export type E2BSandboxId = Branded<'E2BSandboxId'>
@@ -42,6 +44,26 @@ export function quoteE2BShellArg(value: string): string {
return `'${value.replaceAll('\'', "'\"'\"'")}'`
}
/**
* Resolve one executable inside an E2B sandbox and require an absolute result.
* @param sandbox - Sandbox whose PATH and filesystem own the executable.
* @param command - Absolute path or bare executable name.
* @returns Verified absolute remote executable path.
*/
export async function resolveE2BExecutable(sandbox: Sandbox, command: string): Promise<string> {
if (command.length === 0) throw new Error('E2B executable name must be non-empty')
if (posix.isAbsolute(command)) {
await sandbox.commands.run(`test -f ${quoteE2BShellArg(command)} -a -x ${quoteE2BShellArg(command)}`)
return command
}
const result = await sandbox.commands.run(`command -v -- ${quoteE2BShellArg(command)}`)
const executable = result.stdout.trim()
if (!posix.isAbsolute(executable) || executable.includes('\n')) {
throw new Error(`E2B executable ${JSON.stringify(command)} did not resolve to one absolute path`)
}
return executable
}
/** Action taken on the owned sandbox when the Cordis service is disposed. */
export type E2BDisposeMode = 'kill' | 'pause' | 'leave'

View File

@@ -11,7 +11,7 @@ const configPath = join(fixtureRoot, 'cordis.yml')
const tsconfigPath = fileURLToPath(new URL('../../../../tsconfig.json', import.meta.url))
describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
it('shares remote state across FS and Bash without creating host workspace files', async () => {
it('runs FS, Bash, PTY, LSP, and Code Runtime in one sandbox and deletes it', async () => {
const { stdout, stderr } = await runLoaderSmoke({
label: 'E2B composition',
tempDirPrefix: 'dsh-e2b-composition-',
@@ -19,10 +19,14 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
libBinScript: binScript,
configPath,
tsconfigPath,
processTimeoutMs: 90_000,
env: {
NODE_OPTIONS: [process.env.NODE_OPTIONS, '--disable-warning=ExperimentalWarning'].filter(Boolean).join(' '),
},
processTimeoutMs: 120_000,
inspect: async (cwd) => {
await expect(access(join(cwd, 'from-fs.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
await expect(access(join(cwd, 'from-bash.txt'))).rejects.toMatchObject({ code: 'ENOENT' })
for (const name of ['from-fs.txt', 'from-bash.txt', 'multibyte.ts', 'fixture-lsp.mjs']) {
await expect(access(join(cwd, name))).rejects.toMatchObject({ code: 'ENOENT' })
}
},
})
@@ -31,9 +35,37 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
expect(output).toMatchObject({
bashRead: 'written-by-fs\n',
fsRead: 'written-by-bash\n',
hover: {
kind: 'hover',
hover: { contents: '**remote hover** 你好 café' },
},
definition: {
kind: 'locations',
locations: [{ range: { start: { line: 0, character: 6 }, end: { line: 0, character: 10 } } }],
},
terminal: {
echo: { waitReason: 'stdin_read', sessionStatus: { kind: 'running' } },
signal: { delivered: true },
interrupted: { sessionStatus: { kind: 'running' } },
},
hostileOutput: { error: { kind: 'output-limit' } },
timedOut: { error: { kind: 'timeout' } },
aborted: { error: { kind: 'abort', message: 'live abort' } },
lingeringCodeRunners: 0,
})
expect((output.terminal as { motd: string }).motd.length).toBeGreaterThan(0)
expect((output.terminal as { echo: { viewport: string } }).echo.viewport).toContain('PTY-你好')
expect((output.terminal as { scrollback: string }).scrollback).toContain('PTY-你好')
expect((output.terminal as { signal: { targetPgid: number } }).signal.targetPgid).toBeGreaterThan(0)
expect(['stdin_read', 'inferred_idle']).toContain(
(output.terminal as { interrupted: { waitReason: string } }).interrupted.waitReason,
)
expect(output.code).toEqual({
value: { doubled: 42, typed: true },
logs: ['remote-log 你好 42', 'post-mutation'],
})
const apiKey = process.env.E2B_API_KEY
if (apiKey === undefined) throw new Error('E2B_API_KEY disappeared during the live composition test')
await expect(Sandbox.getInfo(String(output.sandboxId), { apiKey })).rejects.toBeInstanceOf(SandboxNotFoundError)
}, 105_000)
}, 135_000)
})

View File

@@ -2,8 +2,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Sandbox as SandboxType } from 'e2b'
import E2BSandboxService, {
E2BFrameDecoder,
E2BSandboxId,
encodeE2BFrame,
quoteE2BShellArg,
resolveE2BExecutable,
} from '@deepseek-ai/dsh-e2b'
import * as E2BInvariant from '../src/invariant.ts'
import InvariantService from '@deepseek-ai/dsh-invariants'
@@ -209,6 +212,49 @@ describe('E2B helpers and invariant companion', () => {
expect(quoteE2BShellArg("a'b $HOME")).toBe("'a'\"'\"'b $HOME'")
})
it('resolves absolute and PATH executables inside the sandbox', async () => {
const run = vi.fn()
.mockResolvedValueOnce({ exitCode: 0, stdout: '', stderr: '' })
.mockResolvedValueOnce({ exitCode: 0, stdout: '/usr/bin/node\n', stderr: '' })
const sandbox = { commands: { run } } as unknown as SandboxType
await expect(resolveE2BExecutable(sandbox, '/bin/bash')).resolves.toBe('/bin/bash')
await expect(resolveE2BExecutable(sandbox, 'node')).resolves.toBe('/usr/bin/node')
expect(run).toHaveBeenNthCalledWith(1, "test -f '/bin/bash' -a -x '/bin/bash'")
expect(run).toHaveBeenNthCalledWith(2, "command -v -- 'node'")
})
it('rejects empty or non-absolute executable resolutions', async () => {
const sandbox = {
commands: { run: vi.fn().mockResolvedValue({ exitCode: 0, stdout: 'relative\npath\n', stderr: '' }) },
} as unknown as SandboxType
await expect(resolveE2BExecutable(sandbox, '')).rejects.toThrow('non-empty')
await expect(resolveE2BExecutable(sandbox, 'tool')).rejects.toThrow('did not resolve')
})
it('round-trips split and adjacent ASCII/base64 JSON frames', () => {
const decoder = new E2BFrameDecoder(128)
const encoded = encodeE2BFrame({ text: '你好' }) + encodeE2BFrame([1, true])
expect(decoder.push(encoded.slice(0, 5))).toEqual([])
expect(decoder.push(encoded.slice(5))).toEqual([{ text: '你好' }, [1, true]])
expect(() => { decoder.finish() }).not.toThrow()
expect(() => encodeE2BFrame(undefined)).toThrow('not JSON-serializable')
})
it('rejects malformed, oversized, and truncated frame streams', () => {
expect(() => new E2BFrameDecoder(0)).toThrow('positive safe integer')
expect(() => new E2BFrameDecoder(1.5)).toThrow('positive safe integer')
expect(() => new E2BFrameDecoder(4).push('é')).toThrow('non-ASCII')
expect(() => new E2BFrameDecoder(3).push('AAAAA')).toThrow('byte limit')
expect(() => new E2BFrameDecoder(8).push('\n')).toThrow('invalid base64')
expect(() => new E2BFrameDecoder(8).push('abc!\n')).toThrow('invalid base64')
expect(() => new E2BFrameDecoder(2).push(`${Buffer.from('abc').toString('base64')}\n`)).toThrow('byte limit')
expect(() => new E2BFrameDecoder(8).push('/w==\n')).toThrow('invalid UTF-8')
expect(() => new E2BFrameDecoder(16).push(`${Buffer.from('not-json').toString('base64')}\n`)).toThrow('invalid JSON')
const truncated = new E2BFrameDecoder(8)
truncated.push('YQ==')
expect(() => { truncated.finish() }).toThrow('mid-frame')
})
it('registers the package-owned empty invariant installer', async () => {
const ctx = new Context()
await ctx.plugin(InvariantService, { enabled: true })