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/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 })