refactor(e2b): compose portable runtime consumers

This commit is contained in:
Tianyi Cui
2026-07-29 02:51:44 +08:00
parent 97496c3d00
commit 2abc9823e7
64 changed files with 1426 additions and 4229 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: 5f89a4bcffdfd11fef8929d2a2ceecb41af319e2
README.zh.md: b4956033bae131bb8aa236276323ecba30f00115
README.md: 00264b8f0b03e4af8512025322fe3e457e7b6b9b
README.zh.md: 93fad661ded446e78e3addc0c8b2b8fdc39bd994

View File

@@ -2,7 +2,7 @@
English | [中文](README.zh.md)
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.
Shared lifecycle owner for one E2B sandbox. The 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`; the [family map](../README.md) lists the opt-in composition.
## Configuration

View File

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

View File

@@ -1,96 +0,0 @@
/** 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 {
return encodeFrame(value)
}
/**
* Encode one JSON-compatible value while enforcing the decoded frame bound.
* @param value - Value accepted by `JSON.stringify`.
* @param maxFrameBytes - Maximum UTF-8 JSON bytes in the encoded frame.
* @returns Base64-encoded UTF-8 JSON followed by one newline.
*/
export function encodeBoundedE2BFrame(value: unknown, maxFrameBytes: number): string {
if (!Number.isSafeInteger(maxFrameBytes) || maxFrameBytes <= 0) {
throw new Error('E2B frame maxFrameBytes must be a positive safe integer')
}
return encodeFrame(value, maxFrameBytes)
}
function encodeFrame(value: unknown, maxFrameBytes?: number): string {
const json: unknown = JSON.stringify(value)
if (typeof json !== 'string') throw new Error('E2B frame value is not JSON-serializable')
const bytes = Buffer.from(json)
if (maxFrameBytes !== undefined && bytes.length > maxFrameBytes) {
throw new Error('E2B frame exceeded its byte limit')
}
return `${bytes.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,8 +10,6 @@ import z from 'schemastery'
import { Sandbox } from 'e2b'
import type { Branded } from '@deepseek-ai/dsh-brand'
export { E2BFrameDecoder, encodeBoundedE2BFrame, encodeE2BFrame } from './frame.ts'
export {
CommandExitError,
FileNotFoundError,
@@ -44,26 +42,6 @@ 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

@@ -3,13 +3,13 @@ import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { Context } from 'cordis'
import { describe, expect, it } from 'vitest'
import { AgentMessageId } from '@deepseek-ai/dsh-agent'
import type { Agent } from '@deepseek-ai/dsh-agent'
import { runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke'
import { Sandbox, SandboxNotFoundError } from '@deepseek-ai/dsh-e2b'
import { E2BPtyBackend } from '@deepseek-ai/dsh-pty-e2b'
import { PtySessionId } from '@deepseek-ai/dsh-pty'
import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty'
import { LocalPtyBackend } from '@deepseek-ai/dsh-pty-local'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
const fixtureRoot = fileURLToPath(new URL('../../../../examples/headless-agent/tests/fixtures/e2b/e2b/', import.meta.url))
const binScript = join(fixtureRoot, 'bin.ts')
@@ -29,7 +29,17 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
})
try {
const ctx = new Context()
ctx.provide('e2b', { cwd: '/home/user', getSandbox: async () => sandbox } as never)
ctx.provide('e2b', {
cwd: '/home/user',
runtimeRoot: '/home/user/.dsh-e2b',
getSandbox: async () => sandbox,
} as never)
ctx.provide('sandboxPolicy', {
defaultMode: 'danger-full-access',
workspaceRoot: '/home/user',
} as never)
const ptyFiber = await ctx.plugin(PtyService)
const subprocessFiber = await ctx.plugin(E2BSubprocessService)
const ownerId = SessionId('e2b-pty-env-owner')
const owner: Agent = {
id: ownerId,
@@ -38,17 +48,19 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
status: 'idle',
acceptsNextStep: false,
ctx,
followup: () => AgentMessageId('unused'),
steer: () => AgentMessageId('unused'),
inject: () => AgentMessageId('unused'),
send: () => AgentMessageId('unused'),
followup() {},
steer() {},
inject() {},
send() {},
cancel() {},
whenIdle: () => Promise.resolve(),
}
const backend = new E2BPtyBackend(ctx, {
backendType: 'shell', rows: 24, cols: 80,
const backend = new LocalPtyBackend(ctx, {
backendType: 'shell', shellPath: '/bin/bash', shellArgs: ['--noprofile', '--norc', '-i'],
rows: 24, cols: 80,
scrollbackLines: 100, scrollbackMaxBytes: 65_536, maxReadBytes: 16_384,
pollIntervalMs: 25, idleSilenceMs: 1_000, timeoutMs: 5_000, disposeGraceMs: 1_000,
pollIntervalMs: 25, exactProbeAfterMs: 150, idleSilenceMs: 1_000,
handoffGraceMs: 500, timeoutMs: 5_000, disposeGraceMs: 1_000,
})
const session = await backend.spawn({ sessionId: PtySessionId('env'), owner, type: 'shell' })
const result = await session.startSend({
@@ -59,6 +71,8 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
expect(result.viewport).not.toContain('sentinel-secret')
expect(result.viewport).not.toContain('sentinel-stale')
await session.close('environment test complete')
await subprocessFiber.dispose()
await ptyFiber.dispose()
} finally {
await sandbox.kill().catch(() => false)
}

View File

@@ -2,12 +2,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
import { Context } from 'cordis'
import type { Sandbox as SandboxType } from 'e2b'
import E2BSandboxService, {
E2BFrameDecoder,
E2BSandboxId,
encodeBoundedE2BFrame,
encodeE2BFrame,
quoteE2BShellArg,
resolveE2BExecutable,
} from '@deepseek-ai/dsh-e2b'
import * as E2BInvariant from '../src/invariant.ts'
import InvariantService from '@deepseek-ai/dsh-invariants'
@@ -229,57 +225,6 @@ 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('bounds outbound frames by decoded UTF-8 bytes', () => {
const exact = encodeBoundedE2BFrame({ text: '你' }, 14)
expect(new E2BFrameDecoder(14).push(exact)).toEqual([{ text: '你' }])
expect(() => encodeBoundedE2BFrame({ text: '你' }, 13)).toThrow('byte limit')
expect(() => encodeBoundedE2BFrame(null, 0)).toThrow('positive safe integer')
expect(() => encodeBoundedE2BFrame(null, 1.5)).toThrow('positive safe integer')
})
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 })