Add E2B PTY, LSP, and code runtime providers
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: bf62cb7d4811ca92f263bd3d337fff9fe41ce223
|
||||
README.zh.md: 8c7603aa501a2481aba6dab1f22750056e43ad26
|
||||
README.md: 5f89a4bcffdfd11fef8929d2a2ceecb41af319e2
|
||||
README.zh.md: b4956033bae131bb8aa236276323ecba30f00115
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 配置
|
||||
|
||||
|
||||
75
packages/e2b/e2b/src/frame.ts
Normal file
75
packages/e2b/e2b/src/frame.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user