Add E2B PTY, LSP, and code runtime providers
This commit is contained in:
@@ -1,9 +1,10 @@
|
||||
# POC overlay: keep the headless agent and model-facing tools, but place its
|
||||
# filesystem and managed Bash process world in one short-lived E2B sandbox.
|
||||
# POC overlay: keep the advanced headless agent and model-facing tools, but
|
||||
# place its filesystem, processes, terminals, language servers, and Code Mode
|
||||
# execution in one short-lived E2B sandbox.
|
||||
- id: base
|
||||
name: '@cordisjs/plugin-include'
|
||||
config:
|
||||
path: ./cordis.yml
|
||||
path: ./advanced.cordis.yml
|
||||
patches:
|
||||
- id: subprocess
|
||||
name: '@deepseek-ai/dsh-subprocess-local'
|
||||
@@ -11,6 +12,9 @@
|
||||
- id: fs-local
|
||||
name: '@deepseek-ai/dsh-fs-local'
|
||||
disabled: true
|
||||
- id: code-runtime
|
||||
name: '@deepseek-ai/dsh-code-runtime-worker'
|
||||
disabled: true
|
||||
- id: bash
|
||||
name: '@deepseek-ai/dsh-bash-local'
|
||||
config:
|
||||
@@ -28,3 +32,27 @@
|
||||
name: '@deepseek-ai/dsh-subprocess-e2b'
|
||||
- id: fs-e2b
|
||||
name: '@deepseek-ai/dsh-fs-e2b'
|
||||
- id: code-runtime-e2b
|
||||
name: '@deepseek-ai/dsh-code-runtime-e2b'
|
||||
- id: pty
|
||||
name: '@deepseek-ai/dsh-pty'
|
||||
- id: pty-e2b
|
||||
name: '@deepseek-ai/dsh-pty-e2b'
|
||||
- id: tool-pty
|
||||
name: '@deepseek-ai/dsh-tool-pty'
|
||||
- id: lsp
|
||||
name: '@deepseek-ai/dsh-lsp'
|
||||
- id: lsp-e2b
|
||||
name: '@deepseek-ai/dsh-lsp-e2b'
|
||||
config:
|
||||
servers:
|
||||
typescript:
|
||||
command: npx
|
||||
args: [--yes, typescript-language-server@5.0.0, --stdio]
|
||||
extensionToLanguage:
|
||||
.ts: typescript
|
||||
.tsx: typescriptreact
|
||||
.js: javascript
|
||||
.jsx: javascriptreact
|
||||
- id: tool-lsp
|
||||
name: '@deepseek-ai/dsh-tool-lsp'
|
||||
|
||||
@@ -1,13 +1,38 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { resolve } from 'node:path'
|
||||
import { boot } from '@deepseek-ai/dsh-app-boot'
|
||||
import { AgentMessageId } from '@deepseek-ai/dsh-agent'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { Session, SessionId } from '@deepseek-ai/dsh-session'
|
||||
import type {} from '@deepseek-ai/dsh-code-runtime-e2b'
|
||||
import type {} from '@deepseek-ai/dsh-e2b'
|
||||
import type {} from '@deepseek-ai/dsh-fs-e2b'
|
||||
import type {} from '@deepseek-ai/dsh-bash-local'
|
||||
import type {} from '@deepseek-ai/dsh-lsp-e2b'
|
||||
import type {} from '@deepseek-ai/dsh-pty-e2b'
|
||||
|
||||
const configPath = process.argv[2]
|
||||
if (configPath === undefined) throw new Error('usage: bin.ts <cordis.yml>')
|
||||
|
||||
const ctx = await boot('e2b-composition', resolve(configPath))
|
||||
const ownerFiber = ctx.plugin(() => {})
|
||||
const ownerId = SessionId('e2b-live-owner')
|
||||
const owner: Agent = {
|
||||
id: ownerId,
|
||||
options: {},
|
||||
session: new Session(ownerId),
|
||||
status: 'idle',
|
||||
acceptsNextStep: false,
|
||||
ctx: ownerFiber.ctx,
|
||||
followup: () => AgentMessageId('unused'),
|
||||
steer: () => AgentMessageId('unused'),
|
||||
inject: () => AgentMessageId('unused'),
|
||||
send: () => AgentMessageId('unused'),
|
||||
cancel() {},
|
||||
whenIdle: () => Promise.resolve(),
|
||||
}
|
||||
const unregisterOwner = ctx.agents.register(owner)
|
||||
let terminalId: Awaited<ReturnType<typeof ctx.pty.spawn>>['sessionId'] | undefined
|
||||
try {
|
||||
const fromFs = await ctx.fs.resolve('from-fs.txt')
|
||||
await ctx.fs.writeText(fromFs, 'written-by-fs\n', { kind: 'createIfAbsent' })
|
||||
@@ -22,11 +47,129 @@ try {
|
||||
}
|
||||
const fromBash = await ctx.fs.resolve('from-bash.txt')
|
||||
const fsRead = await ctx.fs.readText(fromBash)
|
||||
|
||||
const lspFixture = await readFile(new URL('./fixture-lsp.mjs', import.meta.url), 'utf8')
|
||||
const remoteLspFixture = await ctx.fs.resolve('fixture-lsp.mjs')
|
||||
await ctx.fs.writeText(remoteLspFixture, lspFixture, { kind: 'createIfAbsent' })
|
||||
const remoteSource = await ctx.fs.resolve('multibyte.ts')
|
||||
await ctx.fs.writeText(remoteSource, 'const café = "你好"\nconsole.log(café)\n', { kind: 'createIfAbsent' })
|
||||
const hover = await ctx.lsp.query({
|
||||
operation: 'hover',
|
||||
filePath: 'multibyte.ts',
|
||||
position: { line: 0, character: 7 },
|
||||
workspaceRoot: process.cwd(),
|
||||
})
|
||||
const definition = await ctx.lsp.query({
|
||||
operation: 'goToDefinition',
|
||||
filePath: 'multibyte.ts',
|
||||
position: { line: 0, character: 7 },
|
||||
workspaceRoot: process.cwd(),
|
||||
})
|
||||
|
||||
const terminal = await ctx.pty.spawn(owner, { type: 'shell' })
|
||||
terminalId = terminal.sessionId
|
||||
const terminalEcho = await ctx.pty.startSend(owner, terminal.sessionId, {
|
||||
text: "printf 'PTY-你好\\n'",
|
||||
submit: true,
|
||||
}).done
|
||||
const sleeping = ctx.pty.startSend(owner, terminal.sessionId, { text: 'sleep 30', submit: true })
|
||||
await new Promise(resolveDelay => setTimeout(resolveDelay, 150))
|
||||
const terminalSignal = await ctx.pty.signal(owner, terminal.sessionId, 'SIGINT')
|
||||
const interrupted = await sleeping.done
|
||||
const terminalScrollback = ctx.pty.read(owner, terminal.sessionId, { count: 50 })
|
||||
await ctx.pty.kill(owner, terminal.sessionId, 'live E2B composition complete')
|
||||
terminalId = undefined
|
||||
|
||||
const code = await ctx.codeRuntime.run({
|
||||
program: `
|
||||
console.log('remote-log 你好', 42)
|
||||
const arrayPrototype = Array.prototype
|
||||
const objectPrototype = Object.prototype
|
||||
const setPrototype = Set.prototype
|
||||
const stringPrototype = String.prototype
|
||||
Array.isArray = () => false
|
||||
Object.defineProperty = Object.getPrototypeOf = Object.keys = () => { throw new Error('mutated object method') }
|
||||
Object.hasOwn = () => false
|
||||
Object.is = () => true
|
||||
objectPrototype.propertyIsEnumerable = () => false
|
||||
Number.isFinite = Number.isSafeInteger = () => false
|
||||
Reflect.apply = Reflect.ownKeys = () => { throw new Error('mutated reflect method') }
|
||||
setPrototype.add = setPrototype.delete = setPrototype.has = () => { throw new Error('mutated set method') }
|
||||
stringPrototype.charCodeAt = stringPrototype.codePointAt = stringPrototype.slice = () => { throw new Error('mutated string method') }
|
||||
Buffer.byteLength = () => 0
|
||||
Function.prototype.toString = () => 'mutated'
|
||||
objectPrototype.constructor = arrayPrototype.constructor = null
|
||||
globalThis.Array = globalThis.Buffer = globalThis.Function = globalThis.Number = globalThis.Object = globalThis.Promise = globalThis.Reflect = globalThis.Set = globalThis.String = undefined
|
||||
process.stdout.write('post-mutation', () => {})
|
||||
const doubled: number = await bridge.double({ value: 21 })
|
||||
let typed = false
|
||||
try {
|
||||
await bridge.fail({ reason: 'expected' })
|
||||
} catch (error) {
|
||||
typed = error instanceof BridgeError && (error as { member: string }).member === 'fail'
|
||||
}
|
||||
return { doubled, typed }
|
||||
`,
|
||||
bindings: [{
|
||||
global: 'bridge',
|
||||
errorClass: { name: 'BridgeError', memberNameProperty: 'member' },
|
||||
functions: {
|
||||
double: async (args) => {
|
||||
const value = (args as { value: number }).value
|
||||
return value * 2
|
||||
},
|
||||
fail: async () => { throw new Error('binding rejected') },
|
||||
},
|
||||
}],
|
||||
})
|
||||
const hostileOutput = await ctx.codeRuntime.run({
|
||||
program: `
|
||||
const payload = '🙂'.repeat(4096)
|
||||
String.prototype[Symbol.iterator] = () => { throw new Error('mutated string iterator') }
|
||||
console.log(payload)
|
||||
return true
|
||||
`,
|
||||
bindings: [],
|
||||
})
|
||||
const timedOut = await ctx.codeRuntime.run({
|
||||
program: 'await new Promise(() => {})',
|
||||
bindings: [],
|
||||
})
|
||||
const abortController = new AbortController()
|
||||
const aborting = ctx.codeRuntime.run({
|
||||
program: 'await new Promise(() => {})',
|
||||
bindings: [],
|
||||
signal: abortController.signal,
|
||||
})
|
||||
setTimeout(() => { abortController.abort('live abort') }, 50)
|
||||
const aborted = await aborting
|
||||
const remoteProcesses = await (await ctx.e2b.getSandbox()).commands.list()
|
||||
const lingeringCodeRunners = remoteProcesses.filter(processInfo =>
|
||||
JSON.stringify([processInfo.cmd, processInfo.args]).includes('code-runtime-runner.mjs'),
|
||||
)
|
||||
|
||||
process.stdout.write(`${JSON.stringify({
|
||||
sandboxId: await ctx.e2b.sandboxId,
|
||||
bashRead: bashRead.stdout.text,
|
||||
fsRead,
|
||||
hover,
|
||||
definition,
|
||||
terminal: {
|
||||
motd: terminal.motd,
|
||||
echo: terminalEcho,
|
||||
signal: terminalSignal,
|
||||
interrupted,
|
||||
scrollback: terminalScrollback.text,
|
||||
},
|
||||
code,
|
||||
hostileOutput,
|
||||
timedOut,
|
||||
aborted,
|
||||
lingeringCodeRunners: lingeringCodeRunners.length,
|
||||
})}\n`)
|
||||
} finally {
|
||||
if (terminalId !== undefined) await ctx.pty.kill(owner, terminalId, 'fixture cleanup').catch(() => false)
|
||||
unregisterOwner()
|
||||
await ownerFiber.dispose()
|
||||
await ctx.fiber.dispose()
|
||||
}
|
||||
|
||||
@@ -17,3 +17,43 @@
|
||||
|
||||
- id: fs-e2b
|
||||
name: '@deepseek-ai/dsh-fs-e2b'
|
||||
|
||||
- id: agents
|
||||
name: '@deepseek-ai/dsh-agent'
|
||||
|
||||
- id: pty
|
||||
name: '@deepseek-ai/dsh-pty'
|
||||
|
||||
- id: pty-e2b
|
||||
name: '@deepseek-ai/dsh-pty-e2b'
|
||||
config:
|
||||
pollIntervalMs: 25
|
||||
idleSilenceMs: 2000
|
||||
timeoutMs: 5000
|
||||
disposeGraceMs: 1000
|
||||
|
||||
- id: lsp
|
||||
name: '@deepseek-ai/dsh-lsp'
|
||||
|
||||
- id: lsp-e2b
|
||||
name: '@deepseek-ai/dsh-lsp-e2b'
|
||||
config:
|
||||
servers:
|
||||
fixture:
|
||||
command: node
|
||||
args:
|
||||
- !!js process.cwd() + '/fixture-lsp.mjs'
|
||||
extensionToLanguage:
|
||||
.ts: typescript
|
||||
shutdownTimeoutMs: 1000
|
||||
killGraceMs: 500
|
||||
|
||||
- id: code-runtime-e2b
|
||||
name: '@deepseek-ai/dsh-code-runtime-e2b'
|
||||
config:
|
||||
computeMs: 500
|
||||
maxWallMs: 5000
|
||||
maxOutputBytes: 4096
|
||||
maxOldGenerationSizeMb: 128
|
||||
maxFrameBytes: 4194304
|
||||
killGraceMs: 500
|
||||
|
||||
85
examples/headless-agent/tests/fixtures/e2b/e2b/fixture-lsp.mjs
vendored
Normal file
85
examples/headless-agent/tests/fixtures/e2b/e2b/fixture-lsp.mjs
vendored
Normal file
@@ -0,0 +1,85 @@
|
||||
import { Buffer } from 'node:buffer'
|
||||
|
||||
let pending = Buffer.alloc(0)
|
||||
let source = ''
|
||||
let sourceUri = ''
|
||||
|
||||
function send(message) {
|
||||
const body = Buffer.from(JSON.stringify(message))
|
||||
process.stdout.write(`Content-Length: ${body.length}\r\n\r\n`)
|
||||
process.stdout.write(body)
|
||||
}
|
||||
|
||||
function respond(id, result) {
|
||||
send({ jsonrpc: '2.0', id, result })
|
||||
}
|
||||
|
||||
function dispatch(message) {
|
||||
switch (message.method) {
|
||||
case 'initialize':
|
||||
respond(message.id, {
|
||||
capabilities: {
|
||||
positionEncoding: 'utf-16',
|
||||
textDocumentSync: { openClose: true, change: 1 },
|
||||
definitionProvider: true,
|
||||
referencesProvider: true,
|
||||
implementationProvider: true,
|
||||
hoverProvider: true,
|
||||
},
|
||||
})
|
||||
return
|
||||
case 'textDocument/didOpen':
|
||||
source = message.params.textDocument.text
|
||||
sourceUri = message.params.textDocument.uri
|
||||
return
|
||||
case 'textDocument/didClose':
|
||||
source = ''
|
||||
sourceUri = ''
|
||||
return
|
||||
case 'textDocument/hover':
|
||||
if (!source.includes('const café = "你好"')) {
|
||||
send({ jsonrpc: '2.0', id: message.id, error: { code: -32000, message: 'multibyte source was corrupted' } })
|
||||
return
|
||||
}
|
||||
respond(message.id, {
|
||||
contents: { kind: 'markdown', value: '**remote hover** 你好 café' },
|
||||
range: { start: { line: 0, character: 6 }, end: { line: 0, character: 10 } },
|
||||
})
|
||||
return
|
||||
case 'textDocument/definition':
|
||||
case 'textDocument/references':
|
||||
case 'textDocument/implementation':
|
||||
respond(message.id, [{
|
||||
uri: sourceUri,
|
||||
range: { start: { line: 0, character: 6 }, end: { line: 0, character: 10 } },
|
||||
}])
|
||||
return
|
||||
case 'shutdown':
|
||||
respond(message.id, null)
|
||||
return
|
||||
case 'exit':
|
||||
process.exit(0)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
function drain() {
|
||||
for (;;) {
|
||||
const headerEnd = pending.indexOf('\r\n\r\n')
|
||||
if (headerEnd < 0) return
|
||||
const header = pending.subarray(0, headerEnd).toString('ascii')
|
||||
const match = /(?:^|\r\n)Content-Length: ([0-9]+)(?:\r\n|$)/i.exec(header)
|
||||
if (!match) throw new Error('missing Content-Length')
|
||||
const length = Number(match[1])
|
||||
const bodyStart = headerEnd + 4
|
||||
if (pending.length < bodyStart + length) return
|
||||
const body = pending.subarray(bodyStart, bodyStart + length)
|
||||
pending = pending.subarray(bodyStart + length)
|
||||
dispatch(JSON.parse(body.toString('utf8')))
|
||||
}
|
||||
}
|
||||
|
||||
process.stdin.on('data', chunk => {
|
||||
pending = Buffer.concat([pending, chunk])
|
||||
drain()
|
||||
})
|
||||
Reference in New Issue
Block a user