refactor(e2b): compose portable runtime consumers
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/e2b/e2b/README.md
|
||||
README.md: 5f89a4bcffdfd11fef8929d2a2ceecb41af319e2
|
||||
README.zh.md: b4956033bae131bb8aa236276323ecba30f00115
|
||||
README.md: 00264b8f0b03e4af8512025322fe3e457e7b6b9b
|
||||
README.zh.md: 93fad661ded446e78e3addc0c8b2b8fdc39bd994
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 配置
|
||||
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user