From 29ba52f071e06f6d8a8db177164e6d51aa896520 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Thu, 15 Oct 2026 15:11:29 +0200 Subject: [PATCH 01/21] feat(acp-js): add @e2b/acp package with ACP framing helpers New package for running Agent Client Protocol agents in E2B sandboxes. This first part has the newline-delimited JSON framing with a validated per-message size limit, and the rules that keep the agent's tools in the sandbox: initialize (also inside a batch) turns off the client fs and terminal capabilities, and agent fs/* and terminal/* requests are answered with method-not-found. --- packages/acp-js/LICENSE | 9 ++ packages/acp-js/package.json | 69 ++++++++++++++ packages/acp-js/src/utils.ts | 134 ++++++++++++++++++++++++++++ packages/acp-js/tests/utils.test.ts | 134 ++++++++++++++++++++++++++++ packages/acp-js/tsconfig.json | 8 ++ packages/acp-js/tsdown.config.ts | 5 ++ packages/acp-js/vitest.config.mts | 16 ++++ pnpm-lock.yaml | 37 ++++++++ 8 files changed, 412 insertions(+) create mode 100644 packages/acp-js/LICENSE create mode 100644 packages/acp-js/package.json create mode 100644 packages/acp-js/src/utils.ts create mode 100644 packages/acp-js/tests/utils.test.ts create mode 100644 packages/acp-js/tsconfig.json create mode 100644 packages/acp-js/tsdown.config.ts create mode 100644 packages/acp-js/vitest.config.mts diff --git a/packages/acp-js/LICENSE b/packages/acp-js/LICENSE new file mode 100644 index 0000000000..4aa6529d10 --- /dev/null +++ b/packages/acp-js/LICENSE @@ -0,0 +1,9 @@ +MIT License + +Copyright (c) 2025 FOUNDRYLABS, INC. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/packages/acp-js/package.json b/packages/acp-js/package.json new file mode 100644 index 0000000000..f24018fa13 --- /dev/null +++ b/packages/acp-js/package.json @@ -0,0 +1,69 @@ +{ + "name": "@e2b/acp", + "version": "0.0.0", + "packageManager": "pnpm@10.34.5", + "description": "Run Agent Client Protocol (ACP) agents in E2B sandboxes", + "homepage": "https://e2b.dev/docs/agents/acp?utm_source=npm&utm_medium=referral&utm_campaign=package_homepage&utm_content=e2b-acp", + "license": "MIT", + "author": { + "name": "FoundryLabs, Inc.", + "email": "hello@e2b.dev", + "url": "https://e2b.dev" + }, + "bugs": "https://github.com/e2b-dev/e2b/issues", + "repository": { + "type": "git", + "url": "https://github.com/e2b-dev/e2b", + "directory": "packages/acp-js" + }, + "publishConfig": { + "access": "public" + }, + "sideEffects": false, + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "scripts": { + "prepublishOnly": "pnpm build", + "build": "tsc --noEmit && tsdown", + "dev": "tsdown --watch", + "test": "vitest run", + "typecheck": "tsc --noEmit", + "lint": "oxlint --config ../../.oxlintrc.json src tests", + "format": "prettier --write src/ tests/" + }, + "devDependencies": { + "@agentclientprotocol/sdk": "1.8.0", + "@types/node": "catalog:", + "@typescript/native": "catalog:", + "dotenv": "^16.4.5", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "files": [ + "dist", + "README.md", + "package.json" + ], + "keywords": [ + "e2b", + "acp", + "agent-client-protocol", + "ai-agents", + "coding-agents", + "sandbox" + ], + "engines": { + "node": ">=22.19.0" + }, + "browserslist": [ + "defaults" + ], + "dependencies": { + "e2b": "workspace:^" + }, + "peerDependencies": { + "@agentclientprotocol/sdk": "^1.8.0" + } +} diff --git a/packages/acp-js/src/utils.ts b/packages/acp-js/src/utils.ts new file mode 100644 index 0000000000..28cf86e7e3 --- /dev/null +++ b/packages/acp-js/src/utils.ts @@ -0,0 +1,134 @@ +import { InvalidArgumentError, SandboxError } from 'e2b' + +/** + * Default maximum size of a single message from the agent, matching the + * TypeScript ACP SDK. + */ +export const DEFAULT_MAX_MESSAGE_BYTES = 32 * 1024 * 1024 + +const encoder = new TextEncoder() + +/** + * Splits newline-delimited text chunks into complete lines. + */ +export class LineSplitter { + private pending = '' + + constructor(private readonly maxLineBytes: number) { + if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes <= 0) { + throw new InvalidArgumentError( + `maxMessageBytes must be a positive integer, got ${maxLineBytes}` + ) + } + } + + push(chunk: string): string[] { + const parts = (this.pending + chunk).split('\n') + this.pending = parts.pop() ?? '' + // Characters never outnumber UTF-8 bytes, so the cheap length check only + // lets through lines that might be too large. + for (const line of [...parts, this.pending]) { + if ( + line.length > this.maxLineBytes / 4 && + encoder.encode(line).length > this.maxLineBytes + ) { + throw new SandboxError( + `ACP message exceeds the limit of ${this.maxLineBytes} bytes` + ) + } + } + return parts + } + + flush(): string { + const rest = this.pending + this.pending = '' + return rest + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Methods that would make the agent read files or run commands on the + * client's machine instead of inside the sandbox. + */ +export function isClientExecutionMethod(method: unknown): boolean { + return ( + typeof method === 'string' && + (method.startsWith('fs/') || method.startsWith('terminal/')) + ) +} + +/** + * Turns off the client filesystem and terminal capabilities in an + * `initialize` request, also inside a batch, so the agent keeps using its own + * tools in the sandbox. Other messages are returned unchanged. + */ +export function restrictClientCapabilities(message: T): T { + if (Array.isArray(message)) { + return message.map(restrictClientCapabilities) as T + } + if (!isRecord(message) || message.method !== 'initialize') { + return message + } + const params = isRecord(message.params) ? message.params : {} + const capabilities = isRecord(params.clientCapabilities) + ? params.clientCapabilities + : {} + return { + ...message, + params: { + ...params, + clientCapabilities: { + ...capabilities, + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }, + }, + } +} + +/** + * Splits agent output into what is forwarded to the client and the error + * responses sent straight back to the agent for client-execution requests. + */ +export function filterAgentMessage(message: unknown): { + forward?: unknown + replies: unknown[] +} { + const items = Array.isArray(message) ? message : [message] + const forward: unknown[] = [] + const replies: unknown[] = [] + + for (const item of items) { + if (!isRecord(item) || !isClientExecutionMethod(item.method)) { + forward.push(item) + continue + } + // Notifications get no reply; requests are rejected. + if ('id' in item) { + replies.push({ + jsonrpc: '2.0', + id: item.id, + error: { + code: -32601, + message: `Method not found: ${item.method} is not available, the agent runs inside an E2B sandbox`, + }, + }) + } + } + + if (!Array.isArray(message)) { + return { forward: forward[0], replies } + } + return { forward: forward.length ? forward : undefined, replies } +} + +export const PARSE_ERROR_RESPONSE = { + jsonrpc: '2.0', + id: null, + error: { code: -32700, message: 'Parse error' }, +} diff --git a/packages/acp-js/tests/utils.test.ts b/packages/acp-js/tests/utils.test.ts new file mode 100644 index 0000000000..827f99faf3 --- /dev/null +++ b/packages/acp-js/tests/utils.test.ts @@ -0,0 +1,134 @@ +import { assert, expect, test } from 'vitest' +import { + filterAgentMessage, + LineSplitter, + restrictClientCapabilities, +} from '../src/utils' + +test('line splitter joins chunks split mid-line', () => { + const lines = new LineSplitter(1024) + + assert.deepEqual(lines.push('{"a":'), []) + assert.deepEqual(lines.push('1}\n{"b"'), ['{"a":1}']) + assert.deepEqual(lines.push(':2}\n\n'), ['{"b":2}', '']) + assert.equal(lines.flush(), '') +}) + +test('line splitter keeps the trailing partial line for flush', () => { + const lines = new LineSplitter(1024) + + assert.deepEqual(lines.push('{"a":1}\n{"b":2}'), ['{"a":1}']) + assert.equal(lines.flush(), '{"b":2}') +}) + +test('line splitter rejects messages over the limit in bytes', () => { + const lines = new LineSplitter(8) + + assert.deepEqual(lines.push('1234567\n'), ['1234567']) + // Four 3-byte characters are 12 bytes. + expect(() => lines.push('€€€€')).toThrow(/exceeds the limit of 8 bytes/) +}) + +test('initialize turns off client filesystem and terminal', () => { + const message = { + jsonrpc: '2.0', + id: 0, + method: 'initialize', + params: { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: true, + _meta: { custom: true }, + }, + }, + } + + const restricted = restrictClientCapabilities(message) + + assert.deepEqual(restricted.params, { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + _meta: { custom: true }, + }, + }) + assert.isTrue(message.params.clientCapabilities.terminal) +}) + +test('other client messages pass unchanged', () => { + const message = { jsonrpc: '2.0', id: 1, method: 'session/new', params: {} } + + assert.strictEqual(restrictClientCapabilities(message), message) +}) + +test('agent requests for client fs and terminal are rejected', () => { + const { forward, replies } = filterAgentMessage({ + jsonrpc: '2.0', + id: 7, + method: 'fs/write_text_file', + params: { path: '/tmp/x', content: '' }, + }) + + assert.isUndefined(forward) + assert.equal(replies.length, 1) + assert.deepInclude(replies[0], { jsonrpc: '2.0', id: 7 }) + assert.equal((replies[0] as { error: { code: number } }).error.code, -32601) +}) + +test('agent notifications for client terminal are dropped silently', () => { + const { forward, replies } = filterAgentMessage({ + jsonrpc: '2.0', + method: 'terminal/release', + }) + + assert.isUndefined(forward) + assert.deepEqual(replies, []) +}) + +test('batches keep only forwardable messages', () => { + const update = { jsonrpc: '2.0', method: 'session/update', params: {} } + const { forward, replies } = filterAgentMessage([ + update, + { jsonrpc: '2.0', id: 'x', method: 'terminal/create', params: {} }, + ]) + + assert.deepEqual(forward, [update]) + assert.equal(replies.length, 1) +}) + +test('other agent messages are forwarded', () => { + const message = { + jsonrpc: '2.0', + id: 3, + method: 'session/request_permission', + } + + assert.deepEqual(filterAgentMessage(message), { + forward: message, + replies: [], + }) +}) + +test('initialize inside a batch is restricted too', () => { + const [restricted] = restrictClientCapabilities([ + { + jsonrpc: '2.0', + id: 0, + method: 'initialize', + params: { clientCapabilities: { terminal: true } }, + }, + ]) as { params: { clientCapabilities: unknown } }[] + + assert.deepEqual(restricted.params.clientCapabilities, { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }) +}) + +test('message limit must be a positive integer', () => { + for (const limit of [0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY]) { + expect(() => new LineSplitter(limit)).toThrow(/positive integer/) + } +}) diff --git a/packages/acp-js/tsconfig.json b/packages/acp-js/tsconfig.json new file mode 100644 index 0000000000..ff09139d65 --- /dev/null +++ b/packages/acp-js/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.sdk.json", + "compilerOptions": { + "outDir": "dist" + }, + "include": ["src"], + "exclude": ["dist", "node_modules", "tsdown.config.ts"] +} diff --git a/packages/acp-js/tsdown.config.ts b/packages/acp-js/tsdown.config.ts new file mode 100644 index 0000000000..3a056f8886 --- /dev/null +++ b/packages/acp-js/tsdown.config.ts @@ -0,0 +1,5 @@ +import { defineConfig } from 'tsdown' + +import { createSdkTsdownConfig } from '../../tsdown.sdk.config.mts' + +export default defineConfig(createSdkTsdownConfig()) diff --git a/packages/acp-js/vitest.config.mts b/packages/acp-js/vitest.config.mts new file mode 100644 index 0000000000..7189b75139 --- /dev/null +++ b/packages/acp-js/vitest.config.mts @@ -0,0 +1,16 @@ +import { defineConfig } from 'vitest/config' +import { config } from 'dotenv' + +import { createSdkVitestConfig } from '../../vitest.sdk.config.mts' + +const env = config() + +export default defineConfig( + createSdkVitestConfig( + { + ...(process.env as Record), + ...env.parsed, + }, + { testTimeout: 90_000 } + ) +) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 304a43648b..b8d4b3581b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -96,6 +96,34 @@ importers: specifier: 'catalog:' version: 4.1.12(@types/node@22.20.5)(@vitest/browser-playwright@4.1.12)(@vitest/coverage-v8@4.1.12)(msw@2.15.0(@types/node@22.20.5)(@typescript/typescript6@6.0.2))(vite@6.4.4(@types/node@22.20.5)(jiti@2.7.0)(terser@5.46.0)(tsx@4.23.12)(yaml@2.9.1)) + packages/acp-js: + dependencies: + e2b: + specifier: workspace:^ + version: link:../js-sdk + devDependencies: + '@agentclientprotocol/sdk': + specifier: 1.8.0 + version: 1.8.0(zod@4.6.5) + '@types/node': + specifier: 'catalog:' + version: 22.20.5 + '@typescript/native': + specifier: 'catalog:' + version: typescript@7.0.2 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 + tsdown: + specifier: 'catalog:' + version: 0.22.14(@typescript/typescript6@6.0.2)(oxc-resolver@11.24.2)(tsx@4.23.12) + typescript: + specifier: 'catalog:' + version: '@typescript/typescript6@6.0.2' + vitest: + specifier: 'catalog:' + version: 4.1.12(@types/node@22.20.5)(@vitest/browser-playwright@4.1.12)(@vitest/coverage-v8@4.1.12)(msw@2.15.0(@types/node@22.20.5)(@typescript/typescript6@6.0.2))(vite@6.4.4(@types/node@22.20.5)(jiti@2.7.0)(terser@5.46.0)(tsx@4.23.12)(yaml@2.9.1)) + packages/cli: dependencies: '@iarna/toml': @@ -370,6 +398,11 @@ importers: packages: + '@agentclientprotocol/sdk@1.8.0': + resolution: {integrity: sha512-fbP7qHZsFyTJVT0/RPrcWJ8An26qLXHEuGCHHEKDyxD1xpXV+Ejyh2LAE6vBAFePkpVjZeeohnbgvuEhZ7wLMg==} + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + '@apidevtools/json-schema-ref-parser@11.9.3': resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} engines: {node: '>= 16'} @@ -4278,6 +4311,10 @@ packages: snapshots: + '@agentclientprotocol/sdk@1.8.0(zod@4.6.5)': + dependencies: + zod: 4.6.5 + '@apidevtools/json-schema-ref-parser@11.9.3': dependencies: '@jsdevtools/ono': 7.1.3 From de11987da26500066b8fc2bdc1544fc004b431a9 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Thu, 15 Oct 2026 15:11:29 +0200 Subject: [PATCH 02/21] feat(acp-js): add connectAcpAgent connectAcpAgent(sandbox, { command, client }) starts the agent over a streaming command and returns an AcpAgent with the ACP ClientSideConnection, close(), wait() and kill(). Writes, transport replies and EOF share one queue. close() bounds the whole shutdown from the call, kills an agent whose output stream failed without a known exit, and stops delivering later output. Closing the writable is a graceful EOF that still reports a failed exit and a failure to close stdin. --- packages/acp-js/README.md | 70 ++++ packages/acp-js/src/index.ts | 126 +++++++ packages/acp-js/src/transport.ts | 354 ++++++++++++++++++ .../acp-js/tests/transport.offline.test.ts | 144 +++++++ 4 files changed, 694 insertions(+) create mode 100644 packages/acp-js/README.md create mode 100644 packages/acp-js/src/index.ts create mode 100644 packages/acp-js/src/transport.ts create mode 100644 packages/acp-js/tests/transport.offline.test.ts diff --git a/packages/acp-js/README.md b/packages/acp-js/README.md new file mode 100644 index 0000000000..91a8c3e04e --- /dev/null +++ b/packages/acp-js/README.md @@ -0,0 +1,70 @@ +# E2B ACP + +Run [Agent Client Protocol](https://agentclientprotocol.com) (ACP) agents such as goose, OpenCode or Claude Code inside an [E2B](https://e2b.dev) sandbox, and drive them from your code with the official ACP client. + +Source: [packages/acp-js](https://github.com/e2b-dev/E2B/tree/main/packages/acp-js) + +## Install + +``` +npm i e2b @e2b/acp @agentclientprotocol/sdk +``` + +Set `E2B_API_KEY` to your [E2B API key](https://e2b.dev/dashboard?tab=keys), and the provider key the agent needs. + +## Usage + +Create a sandbox from a template with the agent installed, then start the agent and connect your ACP client to it: + +```ts +import { Sandbox } from 'e2b' +import { connectAcpAgent } from '@e2b/acp' +import { PROTOCOL_VERSION, type Client } from '@agentclientprotocol/sdk' + +const client: Client = { + async requestPermission({ options }) { + const allow = options.find((o) => o.kind === 'allow_once') ?? options[0] + return { outcome: { outcome: 'selected', optionId: allow.optionId } } + }, + async sessionUpdate({ update }) { + if (update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text') { + process.stdout.write(update.content.text) + } + }, +} + +const sandbox = await Sandbox.create('goose', { timeoutMs: 10 * 60_000 }) +try { + const agent = await connectAcpAgent(sandbox, { + command: 'goose acp --with-builtin developer', + client, + envs: { + GOOSE_PROVIDER: 'openai', + GOOSE_MODEL: 'gpt-5.6-luna', + OPENAI_API_KEY: process.env.OPENAI_API_KEY!, + }, + }) + + await agent.connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + const { sessionId } = await agent.connection.newSession({ cwd: '/home/user', mcpServers: [] }) + await agent.connection.prompt({ + sessionId, + prompt: [{ type: 'text', text: 'Create fizzbuzz.py for 1 to 15 and run it.' }], + }) + + await agent.close() +} finally { + await sandbox.kill() +} +``` + +## Where the agent's tools run + +The agent talks ACP over its stdin and stdout. `connectAcpAgent` turns off the client filesystem and terminal capabilities in `initialize` and answers agent requests for `fs/*` or `terminal/*` with an error, so the agent edits files and runs commands with its own tools inside the sandbox. It does not restrict the agent's network access or the MCP servers you pass to it. + +## Lifecycle + +- `agent.close()` sends EOF to the agent and kills it if it has not exited within `timeoutMs` (5 seconds by default) of the call. Messages the agent sends after that are not delivered. +- `agent.wait()` resolves with the exit code once the agent exits. +- If the agent exits with an error, the connection fails with the exit code and the end of the agent's stderr. Pass `onStderr` to receive its logs. +- The agent runs as long as the sandbox does, so set the sandbox timeout for the longest task you expect. diff --git a/packages/acp-js/src/index.ts b/packages/acp-js/src/index.ts new file mode 100644 index 0000000000..fad72db78a --- /dev/null +++ b/packages/acp-js/src/index.ts @@ -0,0 +1,126 @@ +import { + ClientSideConnection, + type Agent, + type Client, +} from '@agentclientprotocol/sdk' +import type { Sandbox } from 'e2b' +import { + startAcpTransport, + type AcpAgentExit, + type AcpCloseOpts, + type AcpStartOpts, + type AcpTransport, +} from './transport' + +export type { AcpAgentExit, AcpCloseOpts, AcpStartOpts } from './transport' + +/** + * Options for connecting to an ACP agent in a sandbox. + */ +export interface ConnectAcpAgentOpts extends AcpStartOpts { + /** + * Command that starts the agent in ACP mode, for example `goose acp`. + */ + command: string + /** + * Your ACP client, or a function that creates it for the agent connection. + * It answers permission requests and receives the agent's session updates. + */ + client: Client | ((agent: Agent) => Client) +} + +/** + * ACP agent running in a sandbox, connected to your client. + */ +export class AcpAgent { + /** + * @hidden + * @internal + */ + constructor( + private readonly transport: AcpTransport, + /** + * Connection to the agent. Use it to initialize, create sessions and send prompts. + */ + readonly connection: ClientSideConnection + ) {} + + /** + * Process ID of the agent in the sandbox. + */ + get pid(): number { + return this.transport.pid + } + + /** + * Wait for the agent process to exit. + * + * It rejects if the agent's output stream failed before its exit status was known. + * + * @returns exit status of the agent. + */ + async wait(): Promise { + return this.transport.wait() + } + + /** + * Close the agent: send EOF to its stdin and kill it if it has not exited + * within `timeoutMs` of this call. Messages the agent sends after this call + * are not delivered. + * + * @param opts options for closing the agent. + */ + async close(opts?: AcpCloseOpts): Promise { + return this.transport.close(opts) + } + + /** + * Kill the agent process with `SIGKILL`. + * + * @returns `true` if the agent was killed, `false` if it was not found. + */ + async kill(): Promise { + return this.transport.kill() + } +} + +/** + * Start an [Agent Client Protocol](https://agentclientprotocol.com) agent in + * the sandbox and connect your ACP client to it over the agent's stdin/stdout. + * + * The client filesystem and terminal capabilities are turned off in + * `initialize`, and agent requests for `fs/*` or `terminal/*` are answered + * with an error without reaching your client, so the agent edits files and + * runs commands with its own tools in the sandbox. Network access and MCP + * servers you pass to the agent are not restricted. + * + * @param sandbox sandbox with the agent installed. + * @param opts options for starting and connecting to the agent. + * + * @returns `AcpAgent` with the ACP connection. + * + * @example + * ```ts + * import { Sandbox } from 'e2b' + * import { connectAcpAgent } from '@e2b/acp' + * import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' + * + * const sandbox = await Sandbox.create('goose') + * const agent = await connectAcpAgent(sandbox, { command: 'goose acp', client }) + * await agent.connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + * // ... + * await agent.close() + * ``` + */ +export async function connectAcpAgent( + sandbox: Sandbox, + opts: ConnectAcpAgentOpts +): Promise { + const { command, client, ...startOpts } = opts + const transport = await startAcpTransport(sandbox, command, startOpts) + const toClient = typeof client === 'function' ? client : () => client + return new AcpAgent( + transport, + new ClientSideConnection(toClient, transport.stream) + ) +} diff --git a/packages/acp-js/src/transport.ts b/packages/acp-js/src/transport.ts new file mode 100644 index 0000000000..0983bf6d0e --- /dev/null +++ b/packages/acp-js/src/transport.ts @@ -0,0 +1,354 @@ +import type { AnyMessage } from '@agentclientprotocol/sdk' +import { + CommandExitError, + SandboxError, + type CommandHandle, + type CommandStartOpts, + type Sandbox, +} from 'e2b' +import { + DEFAULT_MAX_MESSAGE_BYTES, + filterAgentMessage, + LineSplitter, + PARSE_ERROR_RESPONSE, + restrictClientCapabilities, +} from './utils' + +/** + * Options for starting an ACP agent in a sandbox. + */ +export interface AcpStartOpts extends Pick< + CommandStartOpts, + 'cwd' | 'envs' | 'user' | 'requestTimeoutMs' +> { + /** + * Callback for the agent's stderr output (logs). The agent's stdout is reserved for the protocol. + */ + onStderr?: (data: string) => void | Promise + /** + * Maximum size of a single message from the agent in **bytes**. + * + * @default 33_554_432 // 32 MiB + */ + maxMessageBytes?: number +} + +/** + * Options for closing an ACP agent. + */ +export interface AcpCloseOpts { + /** + * Time for the agent to exit after `close()` is called, in **milliseconds**. + * It covers sending EOF to the agent's stdin; after it the agent is killed. + * + * @default 5_000 // 5 seconds + */ + timeoutMs?: number +} + +/** + * Exit status of an ACP agent process. + */ +export interface AcpAgentExit { + /** + * Exit code of the agent process. + */ + exitCode: number + /** + * Error message if the process failed. + */ + error?: string +} + +/** + * Message stream connected to an ACP agent. + */ +export interface AcpStream { + readable: ReadableStream + writable: WritableStream +} + +/** + * The part of `Sandbox` the transport needs. + */ +export type AcpSandbox = Pick + +const STDERR_TAIL_CHARS = 4096 +const READ_QUEUE_MESSAGES = 64 + +/** + * ACP over the stdin/stdout of an agent process in the sandbox. + */ +export class AcpTransport { + /** + * @hidden + * @internal + */ + constructor( + readonly pid: number, + readonly stream: AcpStream, + private readonly handle: CommandHandle, + private readonly exit: Promise, + private readonly exitStatus: () => AcpAgentExit | undefined, + private readonly stop: () => Promise + ) {} + + /** + * Wait for the agent process to exit. + * + * It rejects if the agent's output stream failed before its exit status was known. + */ + async wait(): Promise { + return this.exit + } + + /** + * Close the agent: send EOF to its stdin and kill it if it has not exited + * within `timeoutMs` of this call. Messages the agent sends after this call + * are not delivered. + */ + async close(opts?: AcpCloseOpts): Promise { + // Only a known exit status means the process is gone. A failed output + // stream can leave it running. + if (this.exitStatus()) { + return + } + + let timer: ReturnType | undefined + const exited = await Promise.race([ + this.stop() + .then(() => this.exit) + .then( + () => true, + () => false + ), + new Promise((resolve) => { + timer = setTimeout(() => resolve(false), opts?.timeoutMs ?? 5_000) + }), + ]) + clearTimeout(timer) + + if (!exited && !this.exitStatus()) { + await this.kill().catch(() => false) + } + } + + /** + * Kill the agent process with `SIGKILL`. + * + * @returns `true` if the agent was killed, `false` if it was not found. + */ + async kill(): Promise { + return this.handle.kill() + } +} + +/** + * Start an ACP agent in the sandbox and connect a message stream to its stdin/stdout. + * + * The client filesystem and terminal capabilities are turned off in + * `initialize`, and agent requests for `fs/*` or `terminal/*` are answered + * with an error without reaching the client, so the agent edits files and + * runs commands with its own tools in the sandbox. + */ +export async function startAcpTransport( + sandbox: AcpSandbox, + cmd: string, + opts?: AcpStartOpts +): Promise { + const commands = sandbox.commands + const lines = new LineSplitter( + opts?.maxMessageBytes ?? DEFAULT_MAX_MESSAGE_BYTES + ) + let stderrTail = '' + let ended = false + // Set by close(): the exit that follows is expected, and output after it is + // not delivered. + let closing = false + let resumeReading: (() => void) | undefined + let controller!: ReadableStreamDefaultController + let exitStatus: AcpAgentExit | undefined + + let resolveHandle!: (handle: CommandHandle) => void + let rejectHandle!: (err: unknown) => void + const handleReady = new Promise((resolve, reject) => { + resolveHandle = resolve + rejectHandle = reject + }) + handleReady.catch(() => {}) + + // Client messages, replies to the agent and EOF share one ordered write + // path, so concurrent writes never interleave. + let writes: Promise = Promise.resolve() + const enqueueWrite = (write: (handle: CommandHandle) => Promise) => { + const result = writes.then(async () => { + await write(await handleReady) + }) + writes = result.catch(() => {}) + return result + } + const send = (message: unknown) => { + const line = JSON.stringify(message) + '\n' + return enqueueWrite((handle) => handle.sendStdin(line)) + } + const endInput = () => + enqueueWrite((handle) => + commands.supportsStdinClose ? handle.closeStdin() : handle.kill() + ) + + const end = (err?: unknown) => { + if (ended) { + return + } + ended = true + resumeReading?.() + if (err === undefined) { + controller.close() + } else { + controller.error(err) + } + } + + const deliver = async (line: string) => { + const text = line.trim() + if (!text || ended || closing) { + return + } + + let message: unknown + try { + message = JSON.parse(text) + } catch { + send(PARSE_ERROR_RESPONSE).catch(() => {}) + return + } + + const { forward, replies } = filterAgentMessage(message) + for (const reply of replies) { + send(reply).catch(() => {}) + } + if (forward === undefined) { + return + } + + controller.enqueue(forward as AnyMessage) + // Stop reading agent output until the client catches up. + if ((controller.desiredSize ?? 1) <= 0 && !ended && !closing) { + await new Promise((resolve) => (resumeReading = resolve)) + } + } + + let transport: AcpTransport | undefined + const closeTransport = () => transport?.close() ?? Promise.resolve() + + const readable = new ReadableStream( + { + start: (c) => { + controller = c + }, + pull: () => { + resumeReading?.() + resumeReading = undefined + }, + cancel: async () => { + ended = true + resumeReading?.() + await closeTransport() + }, + }, + new CountQueuingStrategy({ highWaterMark: READ_QUEUE_MESSAGES }) + ) + + const writable = new WritableStream({ + write: (message) => send(restrictClientCapabilities(message)), + // Closing the writable is a graceful EOF: the agent's remaining messages + // and a failed exit still reach the client. + close: () => endInput(), + abort: () => closeTransport(), + }) + + const stop = () => { + closing = true + resumeReading?.() + resumeReading = undefined + return endInput() + } + + let handle: CommandHandle + try { + handle = await commands.startStreaming(cmd, { + stdin: true, + timeoutMs: 0, + cwd: opts?.cwd, + envs: opts?.envs, + user: opts?.user, + requestTimeoutMs: opts?.requestTimeoutMs, + onStdout: async (data) => { + if (ended || closing) { + return + } + let complete: string[] + try { + complete = lines.push(data) + } catch (err) { + end(err) + void closeTransport() + return + } + for (const line of complete) { + await deliver(line) + } + }, + onStderr: async (data) => { + stderrTail = (stderrTail + data).slice(-STDERR_TAIL_CHARS) + await opts?.onStderr?.(data) + }, + }) + } catch (err) { + rejectHandle(err) + throw err + } + resolveHandle(handle) + + const exit = handle.wait().then( + (result): AcpAgentExit => ({ + exitCode: result.exitCode, + error: result.error, + }), + (err): AcpAgentExit => { + if (err instanceof CommandExitError) { + return { exitCode: err.exitCode, error: err.error } + } + throw err + } + ) + + exit.then( + async (status) => { + exitStatus = status + await deliver(lines.flush()) + if (status.exitCode === 0 || closing) { + end() + return + } + end( + new SandboxError( + `ACP agent exited with code ${status.exitCode}${ + status.error ? `: ${status.error}` : '' + }${stderrTail ? `\n${stderrTail}` : ''}` + ) + ) + }, + (err) => end(err) + ) + + transport = new AcpTransport( + handle.pid, + { readable, writable }, + handle, + exit, + () => exitStatus, + stop + ) + return transport +} diff --git a/packages/acp-js/tests/transport.offline.test.ts b/packages/acp-js/tests/transport.offline.test.ts new file mode 100644 index 0000000000..3a691a61a9 --- /dev/null +++ b/packages/acp-js/tests/transport.offline.test.ts @@ -0,0 +1,144 @@ +import { CommandExitError, type CommandStartOpts } from 'e2b' +import { assert, expect, test, vi } from 'vitest' +import { startAcpTransport, type AcpSandbox } from '../src/transport' + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + +// A command handle driven by the test instead of a sandbox. +function fakeAgent(opts: { closeStdin?: () => Promise } = {}) { + let callbacks!: Pick + let settle!: { resolve: (r: unknown) => void; reject: (e: unknown) => void } + const exit = new Promise((resolve, reject) => (settle = { resolve, reject })) + exit.catch(() => {}) + + const writes: string[] = [] + let active = 0 + let maxActive = 0 + const exitWith = (exitCode: number) => { + const result = { exitCode, stdout: '', stderr: '' } + if (exitCode === 0) settle.resolve(result) + else settle.reject(new CommandExitError(result)) + } + const handle = { + pid: 42, + sendStdin: async (data: string) => { + active++ + maxActive = Math.max(maxActive, active) + await sleep(2) + writes.push(data) + active-- + }, + closeStdin: opts.closeStdin ?? (async () => {}), + kill: vi.fn(async () => { + exitWith(-1) + return true + }), + wait: () => exit, + } + const sandbox = { + commands: { + supportsStdinClose: true, + startStreaming: async (_cmd: string, o: CommandStartOpts) => { + callbacks = o + return handle + }, + }, + } as unknown as AcpSandbox + + return { + sandbox, + handle, + writes, + maxActive: () => maxActive, + emit: (message: unknown) => + callbacks.onStdout!(JSON.stringify(message) + '\n'), + exitWith, + fail: (err: unknown) => settle.reject(err), + } +} + +test('client writes and transport replies never overlap', async () => { + const agent = fakeAgent() + const transport = await startAcpTransport(agent.sandbox, 'agent') + const writer = transport.stream.writable.getWriter() + + const pending = [] + for (let i = 0; i < 10; i++) { + pending.push(writer.write({ jsonrpc: '2.0', id: i, method: 'echo' })) + // Each forbidden request makes the transport write a reply itself. + pending.push( + agent.emit({ jsonrpc: '2.0', id: `fs-${i}`, method: 'fs/read_text_file' }) + ) + } + await Promise.all(pending) + await writer.ready + await sleep(50) + + assert.equal(agent.writes.length, 20) + assert.equal(agent.maxActive(), 1) +}) + +test('graceful EOF still reports a failed exit', async () => { + const agent = fakeAgent() + const transport = await startAcpTransport(agent.sandbox, 'agent') + const reader = transport.stream.readable.getReader() + + await transport.stream.writable.getWriter().close() + agent.exitWith(3) + + await expect(reader.read()).rejects.toThrow(/exited with code 3/) +}) + +test('graceful EOF surfaces a failure to close stdin', async () => { + const agent = fakeAgent({ + closeStdin: async () => { + throw new Error('stdin gone') + }, + }) + const transport = await startAcpTransport(agent.sandbox, 'agent') + + await expect(transport.stream.writable.getWriter().close()).rejects.toThrow( + 'stdin gone' + ) +}) + +test('close kills the agent when its output stream failed', async () => { + // EOF goes through, but the process is still running. + const agent = fakeAgent() + const transport = await startAcpTransport(agent.sandbox, 'agent') + + agent.fail(new Error('output stream broke')) + await expect(transport.wait()).rejects.toThrow('output stream broke') + await transport.close({ timeoutMs: 50 }) + + expect(agent.handle.kill).toHaveBeenCalled() +}) + +test('close timeout covers a hung EOF and then kills', async () => { + const agent = fakeAgent({ closeStdin: () => new Promise(() => {}) }) + const transport = await startAcpTransport(agent.sandbox, 'agent') + + const started = Date.now() + await transport.close({ timeoutMs: 50 }) + + assert.isBelow(Date.now() - started, 1_000) + expect(agent.handle.kill).toHaveBeenCalledOnce() +}) + +test('close keeps buffered messages and drops later ones', async () => { + const agent = fakeAgent() + const transport = await startAcpTransport(agent.sandbox, 'agent') + const reader = transport.stream.readable.getReader() + + await agent.emit({ jsonrpc: '2.0', method: 'before' }) + const closing = transport.close({ timeoutMs: 50 }) + await agent.emit({ jsonrpc: '2.0', method: 'after' }) + agent.exitWith(0) + await closing + + assert.equal( + ((await reader.read()).value as { method: string }).method, + 'before' + ) + assert.isTrue((await reader.read()).done) +}) From 0bbd233f11ed6a5d8818de96bd6820e7bfbfec3e Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Thu, 15 Oct 2026 15:11:29 +0200 Subject: [PATCH 03/21] test(acp-js): cover connectAcpAgent against a scripted agent A small Python agent in a sandbox exercises the capability rewrite, rejected fs/terminal calls, ordering, UTF-8 split across bytes, the parse-error reply, the size limit, EOF, kill on close, failed exits, a failing stderr callback and cancellation, without any model or key. --- packages/acp-js/tests/connect.test.ts | 29 +++ packages/acp-js/tests/fakeAgent.ts | 74 +++++++ packages/acp-js/tests/setup.ts | 14 ++ packages/acp-js/tests/transport.test.ts | 258 ++++++++++++++++++++++++ 4 files changed, 375 insertions(+) create mode 100644 packages/acp-js/tests/connect.test.ts create mode 100644 packages/acp-js/tests/fakeAgent.ts create mode 100644 packages/acp-js/tests/setup.ts create mode 100644 packages/acp-js/tests/transport.test.ts diff --git a/packages/acp-js/tests/connect.test.ts b/packages/acp-js/tests/connect.test.ts new file mode 100644 index 0000000000..60b11c513e --- /dev/null +++ b/packages/acp-js/tests/connect.test.ts @@ -0,0 +1,29 @@ +import { PROTOCOL_VERSION, type Client } from '@agentclientprotocol/sdk' +import { assert } from 'vitest' +import { connectAcpAgent } from '../src' +import { FAKE_AGENT_CMD, uploadFakeAgent } from './fakeAgent' +import { sandboxTest } from './setup' + +const client: Client = { + async requestPermission() { + return { outcome: { outcome: 'cancelled' } } + }, + async sessionUpdate() {}, +} + +sandboxTest('connectAcpAgent connects an ACP client', async ({ sandbox }) => { + await uploadFakeAgent(sandbox) + const agent = await connectAcpAgent(sandbox, { + command: FAKE_AGENT_CMD, + client, + }) + + const response = await agent.connection.initialize({ + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { fs: { readTextFile: true }, terminal: true }, + }) + + assert.equal(response.protocolVersion, 1) + await agent.close() + assert.equal((await agent.wait()).exitCode, 0) +}) diff --git a/packages/acp-js/tests/fakeAgent.ts b/packages/acp-js/tests/fakeAgent.ts new file mode 100644 index 0000000000..d3393ca036 --- /dev/null +++ b/packages/acp-js/tests/fakeAgent.ts @@ -0,0 +1,74 @@ +import type { Sandbox } from 'e2b' + +// Minimal deterministic ACP-shaped agent driven by test methods. +const FAKE_AGENT = String.raw` +import json, os, sys, time + +def send(message): + sys.stdout.write(json.dumps(message, ensure_ascii=False) + "\n") + sys.stdout.flush() + +pending = {} +errors = [] +for line in sys.stdin: + message = json.loads(line) + method, id, params = message.get("method"), message.get("id"), message.get("params") + if method is None and message.get("id") is None: + # Error response without an id, such as a parse error. + errors.append(message.get("error")) + continue + if method is None: + # Response to one of our requests. + origin = pending.pop(message["id"], None) + if origin is not None: + send({"jsonrpc": "2.0", "id": origin, "result": message}) + continue + if method == "initialize": + send({"jsonrpc": "2.0", "id": id, "result": {"protocolVersion": 1, "received": params}}) + elif method == "test/echo": + send({"jsonrpc": "2.0", "id": id, "result": params}) + elif method == "test/client_call": + # Ask the client to run something on its machine. + pending["call-" + str(id)] = id + send({"jsonrpc": "2.0", "id": "call-" + str(id), "method": params["method"], "params": {}}) + elif method == "test/notify_client": + send({"jsonrpc": "2.0", "method": "terminal/output", "params": {}}) + send({"jsonrpc": "2.0", "id": id, "result": "sent"}) + elif method == "test/bytewise": + data = (json.dumps({"jsonrpc": "2.0", "id": id, "result": params["text"]}, ensure_ascii=False) + "\n").encode() + for b in data: + os.write(1, bytes([b])) + time.sleep(0.002) + elif method == "test/garbage": + sys.stdout.write("not json\n") + sys.stdout.flush() + send({"jsonrpc": "2.0", "id": id, "result": "garbage sent"}) + elif method == "test/errors": + send({"jsonrpc": "2.0", "id": id, "result": errors}) + elif method == "test/big": + send({"jsonrpc": "2.0", "id": id, "result": "x" * params["size"]}) + elif method == "test/stderr_then_sleep": + sys.stderr.write("log line\n") + sys.stderr.flush() + send({"jsonrpc": "2.0", "id": id, "result": "sleeping"}) + time.sleep(600) + elif method == "test/exit": + sys.stderr.write(params.get("stderr", "")) + sys.stderr.flush() + sys.exit(params["code"]) + elif method == "test/flood": + for i in range(params["count"]): + send({"jsonrpc": "2.0", "method": "session/update", "params": {"i": i}}) + send({"jsonrpc": "2.0", "id": id, "result": "flooded"}) + elif method == "test/ignore_eof": + os.close(0) + send({"jsonrpc": "2.0", "id": id, "result": "ignoring"}) + time.sleep(600) +` + +export const FAKE_AGENT_PATH = '/tmp/fake_acp_agent.py' +export const FAKE_AGENT_CMD = `python3 -u ${FAKE_AGENT_PATH}` + +export async function uploadFakeAgent(sandbox: Sandbox) { + await sandbox.files.write(FAKE_AGENT_PATH, FAKE_AGENT) +} diff --git a/packages/acp-js/tests/setup.ts b/packages/acp-js/tests/setup.ts new file mode 100644 index 0000000000..1cdb7a4fc2 --- /dev/null +++ b/packages/acp-js/tests/setup.ts @@ -0,0 +1,14 @@ +import { Sandbox } from 'e2b' +import { test as base } from 'vitest' + +export const sandboxTest = base.extend<{ sandbox: Sandbox }>({ + // eslint-disable-next-line no-empty-pattern + sandbox: async ({}, use) => { + const sandbox = await Sandbox.create({ timeoutMs: 5 * 60_000 }) + try { + await use(sandbox) + } finally { + await sandbox.kill() + } + }, +}) diff --git a/packages/acp-js/tests/transport.test.ts b/packages/acp-js/tests/transport.test.ts new file mode 100644 index 0000000000..dc40dae299 --- /dev/null +++ b/packages/acp-js/tests/transport.test.ts @@ -0,0 +1,258 @@ +import type { AnyMessage } from '@agentclientprotocol/sdk' +import { assert } from 'vitest' +import { startAcpTransport, type AcpStartOpts } from '../src/transport' +import { FAKE_AGENT_CMD, uploadFakeAgent } from './fakeAgent' +import { sandboxTest } from './setup' + +type Message = Record + +async function startAgent( + sandbox: Parameters[1]>[0]['sandbox'], + opts?: AcpStartOpts +) { + await uploadFakeAgent(sandbox) + const agent = await startAcpTransport(sandbox, FAKE_AGENT_CMD, opts) + const writer = agent.stream.writable.getWriter() + const reader = agent.stream.readable.getReader() + const call = async (id: number, method: string, params: unknown = {}) => { + await writer.write({ jsonrpc: '2.0', id, method, params }) + const { value } = await reader.read() + return value as Message + } + return { agent, writer, reader, call } +} + +sandboxTest( + 'initialize reaches the agent without client fs and terminal', + async ({ sandbox }) => { + const { agent, call } = await startAgent(sandbox) + + const response = await call(0, 'initialize', { + protocolVersion: 1, + clientCapabilities: { + fs: { readTextFile: true, writeTextFile: true }, + terminal: true, + }, + }) + + assert.deepEqual(response.result.received.clientCapabilities, { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }) + await agent.close() + } +) + +sandboxTest( + 'agent requests for client fs and terminal are rejected in the transport', + async ({ sandbox }) => { + const { agent, call } = await startAgent(sandbox) + + for (const [id, method] of [ + [1, 'fs/write_text_file'], + [2, 'fs/read_text_file'], + [3, 'terminal/create'], + ] as const) { + // The client only sees the agent's final answer, never the request. + const response = await call(id, 'test/client_call', { method }) + assert.equal(response.id, id) + assert.equal(response.result.error.code, -32601) + } + + // Notifications are dropped; the next message is the response. + const response = await call(4, 'test/notify_client') + assert.equal(response.result, 'sent') + await agent.close() + } +) + +sandboxTest('writes keep their order', async ({ sandbox }) => { + const { agent, writer, reader } = await startAgent(sandbox) + const count = 100 + + await Promise.all( + Array.from({ length: count }, (_, i) => + writer.write({ jsonrpc: '2.0', id: i, method: 'test/echo', params: i }) + ) + ) + + for (let i = 0; i < count; i++) { + const { value } = await reader.read() + assert.equal((value as Message).result, i) + } + await agent.close() +}) + +sandboxTest( + 'messages split into single bytes are reassembled', + async ({ sandbox }) => { + const { agent, call } = await startAgent(sandbox) + const text = 'Привет 🌍 E2B' + + const response = await call(5, 'test/bytewise', { text }) + + assert.equal(response.result, text) + await agent.close() + } +) + +sandboxTest( + 'invalid agent output gets a parse error and the stream continues', + async ({ sandbox }) => { + const { agent, call } = await startAgent(sandbox) + + assert.equal((await call(6, 'test/garbage')).result, 'garbage sent') + + // The agent got a parse error reply, and the stream still works. + const errors = await call(7, 'test/errors') + assert.equal(errors.result[0].code, -32700) + await agent.close() + } +) + +sandboxTest('messages over the limit fail the stream', async ({ sandbox }) => { + const { agent, writer, reader } = await startAgent(sandbox, { + maxMessageBytes: 1024, + }) + + await writer.write({ + jsonrpc: '2.0', + id: 7, + method: 'test/big', + params: { size: 4096 }, + }) + + let error: unknown + try { + await reader.read() + } catch (err) { + error = err + } + assert.match(String(error), /exceeds the limit of 1024 bytes/) + await agent.close() +}) + +sandboxTest( + 'closing the writable sends EOF and ends the stream', + async ({ sandbox }) => { + const { agent, writer, reader } = await startAgent(sandbox) + + await writer.close() + + const { done } = await reader.read() + assert.isTrue(done) + assert.deepEqual(await agent.wait(), { exitCode: 0, error: undefined }) + } +) + +sandboxTest( + 'close kills an agent that ignores EOF', + async ({ sandbox }) => { + const { agent, call, reader } = await startAgent(sandbox) + assert.equal((await call(8, 'test/ignore_eof')).result, 'ignoring') + + const started = Date.now() + await agent.close({ timeoutMs: 1_000 }) + + assert.isBelow(Date.now() - started, 10_000) + const { done } = await reader.read() + assert.isTrue(done) + const processes = await sandbox.commands.list() + assert.isFalse(processes.some((p) => p.pid === agent.pid)) + }, + 30_000 +) + +sandboxTest( + 'agent failure errors the stream with its stderr', + async ({ sandbox }) => { + let stderr = '' + const { agent, writer, reader } = await startAgent(sandbox, { + onStderr: (data) => { + stderr += data + }, + }) + + await writer.write({ + jsonrpc: '2.0', + id: 9, + method: 'test/exit', + params: { code: 3, stderr: 'boom' }, + } as AnyMessage) + + let error: unknown + try { + await reader.read() + } catch (err) { + error = err + } + assert.match(String(error), /exited with code 3[\s\S]*boom/) + assert.equal(stderr, 'boom') + assert.equal((await agent.wait()).exitCode, 3) + } +) + +sandboxTest('cancelling the readable closes the agent', async ({ sandbox }) => { + const { agent, reader } = await startAgent(sandbox) + + await reader.cancel() + + assert.equal((await agent.wait()).exitCode, 0) +}) + +sandboxTest( + 'close does not hang when the client stops reading', + async ({ sandbox }) => { + const { agent, writer } = await startAgent(sandbox) + + // Far more messages than the read queue holds, none of them read. + await writer.write({ + jsonrpc: '2.0', + id: 10, + method: 'test/flood', + params: { count: 1000 }, + }) + await new Promise((resolve) => setTimeout(resolve, 2_000)) + + await agent.close({ timeoutMs: 2_000 }) + + assert.equal((await agent.wait()).exitCode, 0) + }, + 30_000 +) + +sandboxTest( + 'close stops the agent when the stderr callback failed', + async ({ sandbox }) => { + const { agent, writer, reader } = await startAgent(sandbox, { + onStderr: () => { + throw new Error('callback failed') + }, + }) + // The agent logs to stderr and keeps running. + await writer.write({ + jsonrpc: '2.0', + id: 11, + method: 'test/stderr_then_sleep', + params: {}, + }) + + // The failed callback ends the output stream, but not the process. + let error: unknown + try { + await reader.read() + } catch (err) { + error = err + } + assert.match(String(error), /callback failed/) + assert.isTrue( + (await sandbox.commands.list()).some((p) => p.pid === agent.pid) + ) + + await agent.close({ timeoutMs: 1_000 }) + + const processes = await sandbox.commands.list() + assert.isFalse(processes.some((p) => p.pid === agent.pid)) + }, + 30_000 +) From 0d5d3bd855460d7d24e0f9cb1aacde387599c2ce Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Fri, 23 Oct 2026 14:23:48 +0200 Subject: [PATCH 04/21] ci: test and release the @e2b/acp package Run the @e2b/acp suite when the package or the JS SDK changes, and include it in the release preflight, tests and publish. --- .github/workflows/acp_js_tests.yml | 68 ++++++++++++++++++++++++++++++ .github/workflows/release.yml | 17 ++++++++ .github/workflows/sdk_tests.yml | 19 +++++++++ 3 files changed, 104 insertions(+) create mode 100644 .github/workflows/acp_js_tests.yml diff --git a/.github/workflows/acp_js_tests.yml b/.github/workflows/acp_js_tests.yml new file mode 100644 index 0000000000..8da4b6311f --- /dev/null +++ b/.github/workflows/acp_js_tests.yml @@ -0,0 +1,68 @@ +name: Test ACP JS SDK + +on: + workflow_call: + inputs: + E2B_DOMAIN: + required: false + type: string + default: '' + secrets: + E2B_API_KEY: + required: true + +permissions: + contents: read + +jobs: + test: + defaults: + run: + working-directory: ./packages/acp-js + shell: bash + name: ACP JS SDK - Build and test + runs-on: ubuntu-22.04 + steps: + - name: Checkout repository + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + + - name: Parse .tool-versions + uses: wistia/parse-tool-versions@32f568a4ffd4bfa7720ebf93f171597d1ebc979a # v2.1.1 + with: + filename: '.tool-versions' + uppercase: 'true' + prefix: 'tool_version_' + + - name: Install pnpm + uses: pnpm/action-setup@b906affcce14559ad1aafd4ab0e942779e9f58b1 # v4.3.0 + with: + version: '${{ env.TOOL_VERSION_PNPM }}' + + - name: Setup Node + uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6.5.0 + with: + node-version: '${{ env.TOOL_VERSION_NODEJS }}' + registry-url: 'https://registry.npmjs.org' + cache: pnpm + cache-dependency-path: pnpm-lock.yaml + + - name: Configure pnpm + run: | + pnpm config set auto-install-peers true + pnpm config set exclude-links-from-lockfile true + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Build base JS SDK + run: pnpm --dir ../js-sdk build + + - name: Test build + run: pnpm build + + - name: Run tests + run: pnpm test + env: + E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + E2B_DOMAIN: ${{ inputs.E2B_DOMAIN }} + E2B_TEST_MAX_WORKERS: 1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a95098d819..83c4aceae5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -31,6 +31,7 @@ jobs: desktop_python: ${{ steps.desktop_python.outputs.release }} dockerfile_utils_js: ${{ steps.dockerfile_utils_js.outputs.release }} dockerfile_utils_python: ${{ steps.dockerfile_utils_python.outputs.release }} + acp_js: ${{ steps.acp_js.outputs.release }} itinerary: ${{ steps.itinerary.outputs.itinerary }} steps: - name: Check the ref @@ -148,6 +149,13 @@ jobs: IS_RELEASE=$(./.github/scripts/is_release_for_package.sh "@e2b/dockerfile-utils-python" "$RUNNER_TEMP/release-plan.json") echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT" + - name: Check ACP JavaScript SDK Release + id: acp_js + if: steps.version.outputs.release == 'true' + run: | + IS_RELEASE=$(./.github/scripts/is_release_for_package.sh "@e2b/acp") + echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT" + - name: Build release itinerary id: itinerary if: steps.version.outputs.release == 'true' @@ -223,6 +231,13 @@ jobs: if: needs.preflight.outputs.dockerfile_utils_python == 'true' uses: ./.github/workflows/dockerfile_utils_python_tests.yml + acp-js-tests: + name: ACP JS SDK Tests + needs: [preflight] + if: needs.preflight.outputs.acp_js == 'true' + uses: ./.github/workflows/acp_js_tests.yml + secrets: inherit + publish: name: Publish needs: @@ -236,6 +251,7 @@ jobs: - desktop-python-tests - dockerfile-utils-js-tests - dockerfile-utils-python-tests + - acp-js-tests if: (!cancelled()) && !contains(needs.*.result, 'failure') && needs.preflight.outputs.release == 'true' uses: ./.github/workflows/publish_packages.yml with: @@ -275,6 +291,7 @@ jobs: - desktop-python-tests - dockerfile-utils-js-tests - dockerfile-utils-python-tests + - acp-js-tests - publish if: failure() name: Release Failed - Slack Notification diff --git a/.github/workflows/sdk_tests.yml b/.github/workflows/sdk_tests.yml index 4aeaea69a1..3f2c419b9c 100644 --- a/.github/workflows/sdk_tests.yml +++ b/.github/workflows/sdk_tests.yml @@ -35,6 +35,7 @@ jobs: desktop_python: ${{ steps.filter.outputs.desktop_python }} dockerfile_utils_js: ${{ steps.filter.outputs.dockerfile_utils_js }} dockerfile_utils_python: ${{ steps.filter.outputs.dockerfile_utils_python }} + acp_js: ${{ steps.filter.outputs.acp_js }} steps: - name: Filter changed paths # On workflow_dispatch there is no PR diff and paths-filter would fall @@ -71,6 +72,15 @@ jobs: - 'packages/cli/**/!(*.md)' - 'packages/js-sdk/**/!(*.md)' - 'packages/dockerfile-utils-js/**/!(*.md)' + acp_js: + - *shared + - '.github/workflows/acp_js_tests.yml' + - 'tsconfig.sdk.json' + - 'tsdown.sdk.config.mts' + - 'vitest.sdk.config.mts' + - 'packages/acp-js/**/!(*.md)' + - 'packages/js-sdk/**/!(*.md)' + - 'packages/dockerfile-utils-js/**/!(*.md)' code_interpreter_js: - *shared - '.github/workflows/code_interpreter_js_tests.yml' @@ -136,6 +146,14 @@ jobs: secrets: E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + acp-js-tests: + name: Production / ACP JS SDK Tests + needs: changes + if: ${{ needs.changes.outputs.acp_js == 'true' || github.event_name == 'workflow_dispatch' }} + uses: ./.github/workflows/acp_js_tests.yml + secrets: + E2B_API_KEY: ${{ secrets.E2B_API_KEY }} + code-interpreter-js-tests: name: Production / Code Interpreter JS SDK Tests needs: changes @@ -269,6 +287,7 @@ jobs: - js-tests - python-tests - cli-tests + - acp-js-tests - code-interpreter-js-tests - code-interpreter-python-tests - desktop-js-tests From b482d3832985b4aac73ae52ae4632fb1f451b5d7 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Fri, 23 Oct 2026 14:23:59 +0200 Subject: [PATCH 05/21] chore: add changeset for @e2b/acp --- .changeset/acp-js-package.md | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 .changeset/acp-js-package.md diff --git a/.changeset/acp-js-package.md b/.changeset/acp-js-package.md new file mode 100644 index 0000000000..98c27ca2c1 --- /dev/null +++ b/.changeset/acp-js-package.md @@ -0,0 +1,6 @@ +--- +"@e2b/acp": minor +"e2b": patch +--- + +Add `@e2b/acp` for running [Agent Client Protocol](https://agentclientprotocol.com) agents such as goose, OpenCode or Claude Code (through its ACP adapter) inside an E2B sandbox. `connectAcpAgent(sandbox, command, client)` starts the agent over stdin/stdout and returns the ACP client connection. Client filesystem and terminal capabilities are turned off and agent requests for them are rejected, so the agent edits files and runs commands with its own tools in the sandbox. The JS SDK can now stream a background command's output without keeping it in memory, which this package uses. From c46b413dd2c984a9903fc2892f6570a204b213e0 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Fri, 16 Oct 2026 10:42:00 +0200 Subject: [PATCH 06/21] fix(acp-js): reject close() when the agent could not be killed --- packages/acp-js/src/index.ts | 2 +- packages/acp-js/src/transport.ts | 7 +++++-- packages/acp-js/tests/transport.offline.test.ts | 10 ++++++++++ 3 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/acp-js/src/index.ts b/packages/acp-js/src/index.ts index fad72db78a..b55613867a 100644 --- a/packages/acp-js/src/index.ts +++ b/packages/acp-js/src/index.ts @@ -66,7 +66,7 @@ export class AcpAgent { /** * Close the agent: send EOF to its stdin and kill it if it has not exited * within `timeoutMs` of this call. Messages the agent sends after this call - * are not delivered. + * are not delivered. It rejects if the agent could not be killed. * * @param opts options for closing the agent. */ diff --git a/packages/acp-js/src/transport.ts b/packages/acp-js/src/transport.ts index 0983bf6d0e..bca04cb46f 100644 --- a/packages/acp-js/src/transport.ts +++ b/packages/acp-js/src/transport.ts @@ -106,6 +106,8 @@ export class AcpTransport { * Close the agent: send EOF to its stdin and kill it if it has not exited * within `timeoutMs` of this call. Messages the agent sends after this call * are not delivered. + * + * It rejects if the agent could not be killed. */ async close(opts?: AcpCloseOpts): Promise { // Only a known exit status means the process is gone. A failed output @@ -129,7 +131,7 @@ export class AcpTransport { clearTimeout(timer) if (!exited && !this.exitStatus()) { - await this.kill().catch(() => false) + await this.kill() } } @@ -292,7 +294,8 @@ export async function startAcpTransport( complete = lines.push(data) } catch (err) { end(err) - void closeTransport() + // The stream already failed with the real error. + closeTransport().catch(() => {}) return } for (const line of complete) { diff --git a/packages/acp-js/tests/transport.offline.test.ts b/packages/acp-js/tests/transport.offline.test.ts index 3a691a61a9..0e86138ac2 100644 --- a/packages/acp-js/tests/transport.offline.test.ts +++ b/packages/acp-js/tests/transport.offline.test.ts @@ -125,6 +125,16 @@ test('close timeout covers a hung EOF and then kills', async () => { expect(agent.handle.kill).toHaveBeenCalledOnce() }) +test('close rejects when the agent could not be killed', async () => { + const agent = fakeAgent({ closeStdin: () => new Promise(() => {}) }) + agent.handle.kill.mockRejectedValueOnce(new Error('kill failed')) + const transport = await startAcpTransport(agent.sandbox, 'agent') + + await expect(transport.close({ timeoutMs: 50 })).rejects.toThrow( + 'kill failed' + ) +}) + test('close keeps buffered messages and drops later ones', async () => { const agent = fakeAgent() const transport = await startAcpTransport(agent.sandbox, 'agent') From e2328cc488edaf3ec77b02c985cd5e24a542c86c Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Fri, 16 Oct 2026 11:43:00 +0200 Subject: [PATCH 07/21] fix(acp-js): kill the agent when the client factory throws --- packages/acp-js/src/index.ts | 12 ++++++++---- packages/acp-js/tests/transport.offline.test.ts | 17 ++++++++++++++++- 2 files changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/acp-js/src/index.ts b/packages/acp-js/src/index.ts index b55613867a..825ebb9f38 100644 --- a/packages/acp-js/src/index.ts +++ b/packages/acp-js/src/index.ts @@ -119,8 +119,12 @@ export async function connectAcpAgent( const { command, client, ...startOpts } = opts const transport = await startAcpTransport(sandbox, command, startOpts) const toClient = typeof client === 'function' ? client : () => client - return new AcpAgent( - transport, - new ClientSideConnection(toClient, transport.stream) - ) + let connection: ClientSideConnection + try { + connection = new ClientSideConnection(toClient, transport.stream) + } catch (err) { + await transport.kill().catch(() => false) + throw err + } + return new AcpAgent(transport, connection) } diff --git a/packages/acp-js/tests/transport.offline.test.ts b/packages/acp-js/tests/transport.offline.test.ts index 0e86138ac2..77973d8084 100644 --- a/packages/acp-js/tests/transport.offline.test.ts +++ b/packages/acp-js/tests/transport.offline.test.ts @@ -1,5 +1,6 @@ -import { CommandExitError, type CommandStartOpts } from 'e2b' +import { CommandExitError, type CommandStartOpts, type Sandbox } from 'e2b' import { assert, expect, test, vi } from 'vitest' +import { connectAcpAgent } from '../src' import { startAcpTransport, type AcpSandbox } from '../src/transport' const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) @@ -152,3 +153,17 @@ test('close keeps buffered messages and drops later ones', async () => { ) assert.isTrue((await reader.read()).done) }) + +test('connect kills the agent when the client factory throws', async () => { + const agent = fakeAgent() + + await expect( + connectAcpAgent(agent.sandbox as Sandbox, { + command: 'agent', + client: () => { + throw new Error('no client') + }, + }) + ).rejects.toThrow('no client') + expect(agent.handle.kill).toHaveBeenCalledOnce() +}) From 2ab0801d78ddb1855235ef72c0c38665822379e8 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Fri, 16 Oct 2026 12:13:00 +0200 Subject: [PATCH 08/21] test(acp-js): cover dropping in-flight lines after close --- .../acp-js/tests/transport.offline.test.ts | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/packages/acp-js/tests/transport.offline.test.ts b/packages/acp-js/tests/transport.offline.test.ts index 77973d8084..e837126b73 100644 --- a/packages/acp-js/tests/transport.offline.test.ts +++ b/packages/acp-js/tests/transport.offline.test.ts @@ -51,8 +51,10 @@ function fakeAgent(opts: { closeStdin?: () => Promise } = {}) { handle, writes, maxActive: () => maxActive, - emit: (message: unknown) => - callbacks.onStdout!(JSON.stringify(message) + '\n'), + emit: (...messages: unknown[]) => + callbacks.onStdout!( + messages.map((m) => JSON.stringify(m) + '\n').join('') + ), exitWith, fail: (err: unknown) => settle.reject(err), } @@ -154,6 +156,31 @@ test('close keeps buffered messages and drops later ones', async () => { assert.isTrue((await reader.read()).done) }) +test('close drops later lines of an output chunk already being read', async () => { + const agent = fakeAgent() + const transport = await startAcpTransport(agent.sandbox, 'agent') + + for (let i = 0; i < 63; i++) { + await agent.emit({ jsonrpc: '2.0', method: 'before' }) + } + // The read queue fills on the first line, so the chunk waits for the client. + const reading = agent.emit( + { jsonrpc: '2.0', method: 'before' }, + { jsonrpc: '2.0', method: 'after' } + ) + await sleep(0) + await transport.close({ timeoutMs: 10 }) + await reading + + const methods: string[] = [] + const reader = transport.stream.readable.getReader() + for (let r = await reader.read(); !r.done; r = await reader.read()) { + methods.push((r.value as { method: string }).method) + } + assert.lengthOf(methods, 64) + assert.notInclude(methods, 'after') +}) + test('connect kills the agent when the client factory throws', async () => { const agent = fakeAgent() From 0658e27cf46367f340cb145ff3649b749c713d25 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Fri, 16 Oct 2026 13:45:00 +0200 Subject: [PATCH 09/21] test(acp-js): drop live tests the offline tests already cover --- packages/acp-js/tests/fakeAgent.ts | 15 ---- packages/acp-js/tests/transport.test.ts | 92 ------------------------- 2 files changed, 107 deletions(-) diff --git a/packages/acp-js/tests/fakeAgent.ts b/packages/acp-js/tests/fakeAgent.ts index d3393ca036..4cc30cf7bf 100644 --- a/packages/acp-js/tests/fakeAgent.ts +++ b/packages/acp-js/tests/fakeAgent.ts @@ -25,8 +25,6 @@ for line in sys.stdin: continue if method == "initialize": send({"jsonrpc": "2.0", "id": id, "result": {"protocolVersion": 1, "received": params}}) - elif method == "test/echo": - send({"jsonrpc": "2.0", "id": id, "result": params}) elif method == "test/client_call": # Ask the client to run something on its machine. pending["call-" + str(id)] = id @@ -47,23 +45,10 @@ for line in sys.stdin: send({"jsonrpc": "2.0", "id": id, "result": errors}) elif method == "test/big": send({"jsonrpc": "2.0", "id": id, "result": "x" * params["size"]}) - elif method == "test/stderr_then_sleep": - sys.stderr.write("log line\n") - sys.stderr.flush() - send({"jsonrpc": "2.0", "id": id, "result": "sleeping"}) - time.sleep(600) elif method == "test/exit": sys.stderr.write(params.get("stderr", "")) sys.stderr.flush() sys.exit(params["code"]) - elif method == "test/flood": - for i in range(params["count"]): - send({"jsonrpc": "2.0", "method": "session/update", "params": {"i": i}}) - send({"jsonrpc": "2.0", "id": id, "result": "flooded"}) - elif method == "test/ignore_eof": - os.close(0) - send({"jsonrpc": "2.0", "id": id, "result": "ignoring"}) - time.sleep(600) ` export const FAKE_AGENT_PATH = '/tmp/fake_acp_agent.py' diff --git a/packages/acp-js/tests/transport.test.ts b/packages/acp-js/tests/transport.test.ts index dc40dae299..6aaf583bb0 100644 --- a/packages/acp-js/tests/transport.test.ts +++ b/packages/acp-js/tests/transport.test.ts @@ -66,23 +66,6 @@ sandboxTest( } ) -sandboxTest('writes keep their order', async ({ sandbox }) => { - const { agent, writer, reader } = await startAgent(sandbox) - const count = 100 - - await Promise.all( - Array.from({ length: count }, (_, i) => - writer.write({ jsonrpc: '2.0', id: i, method: 'test/echo', params: i }) - ) - ) - - for (let i = 0; i < count; i++) { - const { value } = await reader.read() - assert.equal((value as Message).result, i) - } - await agent.close() -}) - sandboxTest( 'messages split into single bytes are reassembled', async ({ sandbox }) => { @@ -145,24 +128,6 @@ sandboxTest( } ) -sandboxTest( - 'close kills an agent that ignores EOF', - async ({ sandbox }) => { - const { agent, call, reader } = await startAgent(sandbox) - assert.equal((await call(8, 'test/ignore_eof')).result, 'ignoring') - - const started = Date.now() - await agent.close({ timeoutMs: 1_000 }) - - assert.isBelow(Date.now() - started, 10_000) - const { done } = await reader.read() - assert.isTrue(done) - const processes = await sandbox.commands.list() - assert.isFalse(processes.some((p) => p.pid === agent.pid)) - }, - 30_000 -) - sandboxTest( 'agent failure errors the stream with its stderr', async ({ sandbox }) => { @@ -199,60 +164,3 @@ sandboxTest('cancelling the readable closes the agent', async ({ sandbox }) => { assert.equal((await agent.wait()).exitCode, 0) }) - -sandboxTest( - 'close does not hang when the client stops reading', - async ({ sandbox }) => { - const { agent, writer } = await startAgent(sandbox) - - // Far more messages than the read queue holds, none of them read. - await writer.write({ - jsonrpc: '2.0', - id: 10, - method: 'test/flood', - params: { count: 1000 }, - }) - await new Promise((resolve) => setTimeout(resolve, 2_000)) - - await agent.close({ timeoutMs: 2_000 }) - - assert.equal((await agent.wait()).exitCode, 0) - }, - 30_000 -) - -sandboxTest( - 'close stops the agent when the stderr callback failed', - async ({ sandbox }) => { - const { agent, writer, reader } = await startAgent(sandbox, { - onStderr: () => { - throw new Error('callback failed') - }, - }) - // The agent logs to stderr and keeps running. - await writer.write({ - jsonrpc: '2.0', - id: 11, - method: 'test/stderr_then_sleep', - params: {}, - }) - - // The failed callback ends the output stream, but not the process. - let error: unknown - try { - await reader.read() - } catch (err) { - error = err - } - assert.match(String(error), /callback failed/) - assert.isTrue( - (await sandbox.commands.list()).some((p) => p.pid === agent.pid) - ) - - await agent.close({ timeoutMs: 1_000 }) - - const processes = await sandbox.commands.list() - assert.isFalse(processes.some((p) => p.pid === agent.pid)) - }, - 30_000 -) From bc2cca12c5bbe32be036b654886be5185810c97a Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Mon, 19 Oct 2026 10:02:41 +0200 Subject: [PATCH 10/21] fix(acp-js): let kill() and early output errors stop the agent kill() now releases agent output that waits for the client, so the command handle can see the exit and wait() resolves. Cleanup after an oversized first message waits for the transport instead of doing nothing when the output arrives before startStreaming() returns. --- packages/acp-js/src/transport.ts | 18 ++++--- .../acp-js/tests/transport.offline.test.ts | 54 +++++++++++++++++-- 2 files changed, 62 insertions(+), 10 deletions(-) diff --git a/packages/acp-js/src/transport.ts b/packages/acp-js/src/transport.ts index bca04cb46f..2ae3625132 100644 --- a/packages/acp-js/src/transport.ts +++ b/packages/acp-js/src/transport.ts @@ -90,7 +90,8 @@ export class AcpTransport { private readonly handle: CommandHandle, private readonly exit: Promise, private readonly exitStatus: () => AcpAgentExit | undefined, - private readonly stop: () => Promise + private readonly stopReading: () => void, + private readonly endInput: () => Promise ) {} /** @@ -116,9 +117,10 @@ export class AcpTransport { return } + this.stopReading() let timer: ReturnType | undefined const exited = await Promise.race([ - this.stop() + this.endInput() .then(() => this.exit) .then( () => true, @@ -141,6 +143,9 @@ export class AcpTransport { * @returns `true` if the agent was killed, `false` if it was not found. */ async kill(): Promise { + // Output waiting for the client would otherwise keep the handle from + // seeing the exit. + this.stopReading() return this.handle.kill() } } @@ -241,7 +246,8 @@ export async function startAcpTransport( } let transport: AcpTransport | undefined - const closeTransport = () => transport?.close() ?? Promise.resolve() + // Output can arrive before startStreaming() returns, so wait for the transport. + const closeTransport = () => handleReady.then(() => transport!.close()) const readable = new ReadableStream( { @@ -269,11 +275,10 @@ export async function startAcpTransport( abort: () => closeTransport(), }) - const stop = () => { + const stopReading = () => { closing = true resumeReading?.() resumeReading = undefined - return endInput() } let handle: CommandHandle @@ -351,7 +356,8 @@ export async function startAcpTransport( handle, exit, () => exitStatus, - stop + stopReading, + endInput ) return transport } diff --git a/packages/acp-js/tests/transport.offline.test.ts b/packages/acp-js/tests/transport.offline.test.ts index e837126b73..f27f417efd 100644 --- a/packages/acp-js/tests/transport.offline.test.ts +++ b/packages/acp-js/tests/transport.offline.test.ts @@ -9,7 +9,12 @@ const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) function fakeAgent(opts: { closeStdin?: () => Promise } = {}) { let callbacks!: Pick let settle!: { resolve: (r: unknown) => void; reject: (e: unknown) => void } - const exit = new Promise((resolve, reject) => (settle = { resolve, reject })) + const settled = new Promise( + (resolve, reject) => (settle = { resolve, reject }) + ) + // Like the SDK handle, the exit is seen only after output callbacks return. + let output: Promise = Promise.resolve() + const exit = settled.finally(() => output) exit.catch(() => {}) const writes: string[] = [] @@ -52,9 +57,11 @@ function fakeAgent(opts: { closeStdin?: () => Promise } = {}) { writes, maxActive: () => maxActive, emit: (...messages: unknown[]) => - callbacks.onStdout!( - messages.map((m) => JSON.stringify(m) + '\n').join('') - ), + (output = Promise.resolve( + callbacks.onStdout!( + messages.map((m) => JSON.stringify(m) + '\n').join('') + ) + )), exitWith, fail: (err: unknown) => settle.reject(err), } @@ -194,3 +201,42 @@ test('connect kills the agent when the client factory throws', async () => { ).rejects.toThrow('no client') expect(agent.handle.kill).toHaveBeenCalledOnce() }) + +test('kill lets wait() see the exit while output waits for the client', async () => { + const agent = fakeAgent() + const transport = await startAcpTransport(agent.sandbox, 'agent') + + for (let i = 0; i < 63; i++) { + await agent.emit({ jsonrpc: '2.0', method: 'update' }) + } + // The read queue is full, so this callback waits for the client. + agent.emit({ jsonrpc: '2.0', method: 'update' }) + await sleep(0) + await transport.kill() + + const exited = await Promise.race([ + transport.wait().then(() => true), + sleep(500).then(() => false), + ]) + assert.isTrue(exited) +}) + +test('output over the limit before start returns still closes the agent', async () => { + const closeStdin = vi.fn(async () => {}) + const agent = fakeAgent({ closeStdin }) + const startStreaming = agent.sandbox.commands.startStreaming + agent.sandbox.commands.startStreaming = async (cmd, opts) => { + const handle = await startStreaming(cmd, opts) + opts!.onStdout!('x'.repeat(100)) + return handle + } + const transport = await startAcpTransport(agent.sandbox, 'agent', { + maxMessageBytes: 10, + }) + + await expect(transport.stream.readable.getReader().read()).rejects.toThrow( + /exceeds the limit/ + ) + await sleep(0) + expect(closeStdin).toHaveBeenCalledOnce() +}) From a763be18b96341ab6c5954af23a325c115e6eb7f Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Mon, 19 Oct 2026 10:02:41 +0200 Subject: [PATCH 11/21] fix(acp-js): split agent output without rescanning a pending line The line splitter kept the unfinished line as one string and split it again on every chunk, which made a long message quadratic to read. It now keeps the chunks and their byte count and joins them once. --- packages/acp-js/src/utils.ts | 52 ++++++++++++++++++++--------- packages/acp-js/tests/utils.test.ts | 12 +++++++ 2 files changed, 48 insertions(+), 16 deletions(-) diff --git a/packages/acp-js/src/utils.ts b/packages/acp-js/src/utils.ts index 28cf86e7e3..6d0be2f267 100644 --- a/packages/acp-js/src/utils.ts +++ b/packages/acp-js/src/utils.ts @@ -12,7 +12,10 @@ const encoder = new TextEncoder() * Splits newline-delimited text chunks into complete lines. */ export class LineSplitter { - private pending = '' + // Text after the last newline. Chunks are joined once, when the line ends, + // so a long message is not rescanned on every chunk. + private pending: string[] = [] + private pendingBytes = 0 constructor(private readonly maxLineBytes: number) { if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes <= 0) { @@ -23,28 +26,45 @@ export class LineSplitter { } push(chunk: string): string[] { - const parts = (this.pending + chunk).split('\n') - this.pending = parts.pop() ?? '' - // Characters never outnumber UTF-8 bytes, so the cheap length check only - // lets through lines that might be too large. - for (const line of [...parts, this.pending]) { - if ( - line.length > this.maxLineBytes / 4 && - encoder.encode(line).length > this.maxLineBytes - ) { - throw new SandboxError( - `ACP message exceeds the limit of ${this.maxLineBytes} bytes` - ) + const lines = chunk.split('\n') + const rest = lines.pop() ?? '' + if (lines.length > 0) { + this.append(lines[0]) + lines[0] = this.flush() + for (const line of lines.slice(1)) { + // Characters never outnumber UTF-8 bytes, so the cheap length check + // only lets through lines that might be too large. + if (line.length > this.maxLineBytes / 4) { + this.check(encoder.encode(line).length) + } } } - return parts + this.append(rest) + return lines } flush(): string { - const rest = this.pending - this.pending = '' + const rest = this.pending.join('') + this.pending = [] + this.pendingBytes = 0 return rest } + + private append(text: string) { + if (text) { + this.pending.push(text) + this.pendingBytes += encoder.encode(text).length + this.check(this.pendingBytes) + } + } + + private check(bytes: number) { + if (bytes > this.maxLineBytes) { + throw new SandboxError( + `ACP message exceeds the limit of ${this.maxLineBytes} bytes` + ) + } + } } function isRecord(value: unknown): value is Record { diff --git a/packages/acp-js/tests/utils.test.ts b/packages/acp-js/tests/utils.test.ts index 827f99faf3..26e6c25ac5 100644 --- a/packages/acp-js/tests/utils.test.ts +++ b/packages/acp-js/tests/utils.test.ts @@ -29,6 +29,18 @@ test('line splitter rejects messages over the limit in bytes', () => { expect(() => lines.push('€€€€')).toThrow(/exceeds the limit of 8 bytes/) }) +test('line splitter handles a long message in small chunks quickly', () => { + const lines = new LineSplitter(32 * 1024 * 1024) + const chunk = 'x'.repeat(1024) + + const started = Date.now() + for (let i = 0; i < 8 * 1024; i++) { + assert.deepEqual(lines.push(chunk), []) + } + assert.lengthOf(lines.push('\n')[0], 8 * 1024 * 1024) + assert.isBelow(Date.now() - started, 1_000) +}) + test('initialize turns off client filesystem and terminal', () => { const message = { jsonrpc: '2.0', From 695effaec076eac27579542e2d9cb9a3f17e5db6 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Mon, 19 Oct 2026 10:04:40 +0200 Subject: [PATCH 12/21] refactor(acp-js): take command and client as arguments, rename close() to stop() connectAcpAgent(sandbox, command, client, opts?) matches the Python signature and keeps only optional settings in the options object. stop() ends the agent process that connectAcpAgent started, its default timeout is the exported DEFAULT_STOP_TIMEOUT_MS, and AcpStartOpts forwards an AbortSignal to the start request. --- packages/acp-js/README.md | 8 ++- packages/acp-js/src/index.ts | 51 +++++++++---------- packages/acp-js/src/transport.ts | 23 ++++++--- packages/acp-js/tests/connect.test.ts | 7 +-- .../acp-js/tests/transport.offline.test.ts | 7 +-- 5 files changed, 47 insertions(+), 49 deletions(-) diff --git a/packages/acp-js/README.md b/packages/acp-js/README.md index 91a8c3e04e..7418de7ea7 100644 --- a/packages/acp-js/README.md +++ b/packages/acp-js/README.md @@ -35,9 +35,7 @@ const client: Client = { const sandbox = await Sandbox.create('goose', { timeoutMs: 10 * 60_000 }) try { - const agent = await connectAcpAgent(sandbox, { - command: 'goose acp --with-builtin developer', - client, + const agent = await connectAcpAgent(sandbox, 'goose acp --with-builtin developer', client, { envs: { GOOSE_PROVIDER: 'openai', GOOSE_MODEL: 'gpt-5.6-luna', @@ -52,7 +50,7 @@ try { prompt: [{ type: 'text', text: 'Create fizzbuzz.py for 1 to 15 and run it.' }], }) - await agent.close() + await agent.stop() } finally { await sandbox.kill() } @@ -64,7 +62,7 @@ The agent talks ACP over its stdin and stdout. `connectAcpAgent` turns off the c ## Lifecycle -- `agent.close()` sends EOF to the agent and kills it if it has not exited within `timeoutMs` (5 seconds by default) of the call. Messages the agent sends after that are not delivered. +- `agent.stop()` sends EOF to the agent and kills it if it has not exited within `timeoutMs` (5 seconds by default) of the call. Messages the agent sends after that are not delivered. - `agent.wait()` resolves with the exit code once the agent exits. - If the agent exits with an error, the connection fails with the exit code and the end of the agent's stderr. Pass `onStderr` to receive its logs. - The agent runs as long as the sandbox does, so set the sandbox timeout for the longest task you expect. diff --git a/packages/acp-js/src/index.ts b/packages/acp-js/src/index.ts index 825ebb9f38..41886cea23 100644 --- a/packages/acp-js/src/index.ts +++ b/packages/acp-js/src/index.ts @@ -7,27 +7,13 @@ import type { Sandbox } from 'e2b' import { startAcpTransport, type AcpAgentExit, - type AcpCloseOpts, type AcpStartOpts, + type AcpStopOpts, type AcpTransport, } from './transport' -export type { AcpAgentExit, AcpCloseOpts, AcpStartOpts } from './transport' - -/** - * Options for connecting to an ACP agent in a sandbox. - */ -export interface ConnectAcpAgentOpts extends AcpStartOpts { - /** - * Command that starts the agent in ACP mode, for example `goose acp`. - */ - command: string - /** - * Your ACP client, or a function that creates it for the agent connection. - * It answers permission requests and receives the agent's session updates. - */ - client: Client | ((agent: Agent) => Client) -} +export { DEFAULT_STOP_TIMEOUT_MS } from './transport' +export type { AcpAgentExit, AcpStartOpts, AcpStopOpts } from './transport' /** * ACP agent running in a sandbox, connected to your client. @@ -64,13 +50,15 @@ export class AcpAgent { } /** - * Close the agent: send EOF to its stdin and kill it if it has not exited + * Stop the agent: send EOF to its stdin and kill it if it has not exited * within `timeoutMs` of this call. Messages the agent sends after this call - * are not delivered. It rejects if the agent could not be killed. + * are not delivered. * - * @param opts options for closing the agent. + * @param opts options for stopping the agent. + * + * @throws SandboxError if the agent could not be killed. */ - async close(opts?: AcpCloseOpts): Promise { + async stop(opts?: AcpStopOpts): Promise { return this.transport.close(opts) } @@ -95,10 +83,18 @@ export class AcpAgent { * servers you pass to the agent are not restricted. * * @param sandbox sandbox with the agent installed. - * @param opts options for starting and connecting to the agent. + * @param command command that starts the agent in ACP mode, for example `goose acp`. + * @param client your ACP client, or a function that creates it for the agent + * connection. It answers permission requests and receives the agent's session updates. + * @param opts options for starting the agent. * * @returns `AcpAgent` with the ACP connection. * + * @throws InvalidArgumentError if `maxMessageBytes` is not a positive integer. + * @throws SandboxError if the agent could not be started. Later, the + * connection fails with a `SandboxError` if the agent exits with a non-zero + * code or sends a message over `maxMessageBytes`. + * * @example * ```ts * import { Sandbox } from 'e2b' @@ -106,18 +102,19 @@ export class AcpAgent { * import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' * * const sandbox = await Sandbox.create('goose') - * const agent = await connectAcpAgent(sandbox, { command: 'goose acp', client }) + * const agent = await connectAcpAgent(sandbox, 'goose acp', client) * await agent.connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) * // ... - * await agent.close() + * await agent.stop() * ``` */ export async function connectAcpAgent( sandbox: Sandbox, - opts: ConnectAcpAgentOpts + command: string, + client: Client | ((agent: Agent) => Client), + opts?: AcpStartOpts ): Promise { - const { command, client, ...startOpts } = opts - const transport = await startAcpTransport(sandbox, command, startOpts) + const transport = await startAcpTransport(sandbox, command, opts) const toClient = typeof client === 'function' ? client : () => client let connection: ClientSideConnection try { diff --git a/packages/acp-js/src/transport.ts b/packages/acp-js/src/transport.ts index 2ae3625132..256f885eed 100644 --- a/packages/acp-js/src/transport.ts +++ b/packages/acp-js/src/transport.ts @@ -19,7 +19,7 @@ import { */ export interface AcpStartOpts extends Pick< CommandStartOpts, - 'cwd' | 'envs' | 'user' | 'requestTimeoutMs' + 'cwd' | 'envs' | 'user' | 'requestTimeoutMs' | 'signal' > { /** * Callback for the agent's stderr output (logs). The agent's stdout is reserved for the protocol. @@ -34,14 +34,19 @@ export interface AcpStartOpts extends Pick< } /** - * Options for closing an ACP agent. + * Default time for an ACP agent to exit after `stop()`, in **milliseconds**. */ -export interface AcpCloseOpts { +export const DEFAULT_STOP_TIMEOUT_MS = 5_000 + +/** + * Options for stopping an ACP agent. + */ +export interface AcpStopOpts { /** - * Time for the agent to exit after `close()` is called, in **milliseconds**. + * Time for the agent to exit after `stop()` is called, in **milliseconds**. * It covers sending EOF to the agent's stdin; after it the agent is killed. * - * @default 5_000 // 5 seconds + * @default DEFAULT_STOP_TIMEOUT_MS // 5 seconds */ timeoutMs?: number } @@ -110,7 +115,7 @@ export class AcpTransport { * * It rejects if the agent could not be killed. */ - async close(opts?: AcpCloseOpts): Promise { + async close(opts?: AcpStopOpts): Promise { // Only a known exit status means the process is gone. A failed output // stream can leave it running. if (this.exitStatus()) { @@ -127,7 +132,10 @@ export class AcpTransport { () => false ), new Promise((resolve) => { - timer = setTimeout(() => resolve(false), opts?.timeoutMs ?? 5_000) + timer = setTimeout( + () => resolve(false), + opts?.timeoutMs ?? DEFAULT_STOP_TIMEOUT_MS + ) }), ]) clearTimeout(timer) @@ -290,6 +298,7 @@ export async function startAcpTransport( envs: opts?.envs, user: opts?.user, requestTimeoutMs: opts?.requestTimeoutMs, + signal: opts?.signal, onStdout: async (data) => { if (ended || closing) { return diff --git a/packages/acp-js/tests/connect.test.ts b/packages/acp-js/tests/connect.test.ts index 60b11c513e..20b6be3abe 100644 --- a/packages/acp-js/tests/connect.test.ts +++ b/packages/acp-js/tests/connect.test.ts @@ -13,10 +13,7 @@ const client: Client = { sandboxTest('connectAcpAgent connects an ACP client', async ({ sandbox }) => { await uploadFakeAgent(sandbox) - const agent = await connectAcpAgent(sandbox, { - command: FAKE_AGENT_CMD, - client, - }) + const agent = await connectAcpAgent(sandbox, FAKE_AGENT_CMD, client) const response = await agent.connection.initialize({ protocolVersion: PROTOCOL_VERSION, @@ -24,6 +21,6 @@ sandboxTest('connectAcpAgent connects an ACP client', async ({ sandbox }) => { }) assert.equal(response.protocolVersion, 1) - await agent.close() + await agent.stop() assert.equal((await agent.wait()).exitCode, 0) }) diff --git a/packages/acp-js/tests/transport.offline.test.ts b/packages/acp-js/tests/transport.offline.test.ts index f27f417efd..07c4a2cf51 100644 --- a/packages/acp-js/tests/transport.offline.test.ts +++ b/packages/acp-js/tests/transport.offline.test.ts @@ -192,11 +192,8 @@ test('connect kills the agent when the client factory throws', async () => { const agent = fakeAgent() await expect( - connectAcpAgent(agent.sandbox as Sandbox, { - command: 'agent', - client: () => { - throw new Error('no client') - }, + connectAcpAgent(agent.sandbox as Sandbox, 'agent', () => { + throw new Error('no client') }) ).rejects.toThrow('no client') expect(agent.handle.kill).toHaveBeenCalledOnce() From 80f19caf4d8b7eab100e08aa60fa44272ac45738 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Fri, 23 Oct 2026 16:31:22 +0200 Subject: [PATCH 13/21] fix(acp-js): kill the agent when its output stream fails A failed output stream, for example after the start signal is aborted or onStderr throws, left the agent running with no working connection. The transport now kills it; the stream still fails with the original error. --- packages/acp-js/src/transport.ts | 8 +++++++- packages/acp-js/tests/transport.offline.test.ts | 11 +++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/packages/acp-js/src/transport.ts b/packages/acp-js/src/transport.ts index 256f885eed..862d7167cd 100644 --- a/packages/acp-js/src/transport.ts +++ b/packages/acp-js/src/transport.ts @@ -356,7 +356,13 @@ export async function startAcpTransport( ) ) }, - (err) => end(err) + (err) => { + end(err) + // The process may still be running, but nothing can talk to it any + // more. The stream already failed with the real error. + stopReading() + handle.kill().catch(() => {}) + } ) transport = new AcpTransport( diff --git a/packages/acp-js/tests/transport.offline.test.ts b/packages/acp-js/tests/transport.offline.test.ts index 07c4a2cf51..6e372f4588 100644 --- a/packages/acp-js/tests/transport.offline.test.ts +++ b/packages/acp-js/tests/transport.offline.test.ts @@ -124,6 +124,17 @@ test('close kills the agent when its output stream failed', async () => { expect(agent.handle.kill).toHaveBeenCalled() }) +test('a failed output stream kills the agent', async () => { + const agent = fakeAgent() + const transport = await startAcpTransport(agent.sandbox, 'agent') + const reader = transport.stream.readable.getReader() + + agent.fail(new Error('output stream broke')) + + await expect(reader.read()).rejects.toThrow('output stream broke') + expect(agent.handle.kill).toHaveBeenCalledOnce() +}) + test('close timeout covers a hung EOF and then kills', async () => { const agent = fakeAgent({ closeStdin: () => new Promise(() => {}) }) const transport = await startAcpTransport(agent.sandbox, 'agent') From 2d5649650645c23796e5ae3c20475ef5474b7a1e Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Fri, 23 Oct 2026 16:31:49 +0200 Subject: [PATCH 14/21] refactor(acp-js): rename AcpAgentExit to AcpAgentResult, point the size error at maxMessageBytes Also export DEFAULT_MAX_MESSAGE_BYTES next to DEFAULT_STOP_TIMEOUT_MS, and describe the JS SDK streaming hook as internal in the changeset. --- .changeset/acp-js-package.md | 2 +- packages/acp-js/src/index.ts | 7 ++++--- packages/acp-js/src/transport.ts | 14 +++++++------- packages/acp-js/src/utils.ts | 2 +- 4 files changed, 13 insertions(+), 12 deletions(-) diff --git a/.changeset/acp-js-package.md b/.changeset/acp-js-package.md index 98c27ca2c1..ffe5825396 100644 --- a/.changeset/acp-js-package.md +++ b/.changeset/acp-js-package.md @@ -3,4 +3,4 @@ "e2b": patch --- -Add `@e2b/acp` for running [Agent Client Protocol](https://agentclientprotocol.com) agents such as goose, OpenCode or Claude Code (through its ACP adapter) inside an E2B sandbox. `connectAcpAgent(sandbox, command, client)` starts the agent over stdin/stdout and returns the ACP client connection. Client filesystem and terminal capabilities are turned off and agent requests for them are rejected, so the agent edits files and runs commands with its own tools in the sandbox. The JS SDK can now stream a background command's output without keeping it in memory, which this package uses. +Add `@e2b/acp` for running [Agent Client Protocol](https://agentclientprotocol.com) agents such as goose, OpenCode or Claude Code (through its ACP adapter) inside an E2B sandbox. `connectAcpAgent(sandbox, command, client)` starts the agent over stdin/stdout and returns the ACP client connection. Client filesystem and terminal capabilities are turned off and agent requests for them are rejected, so the agent edits files and runs commands with its own tools in the sandbox. The JS SDK gets an internal way to stream a background command's output without keeping it in memory, used by this package. diff --git a/packages/acp-js/src/index.ts b/packages/acp-js/src/index.ts index 41886cea23..2ddc43a002 100644 --- a/packages/acp-js/src/index.ts +++ b/packages/acp-js/src/index.ts @@ -6,14 +6,15 @@ import { import type { Sandbox } from 'e2b' import { startAcpTransport, - type AcpAgentExit, + type AcpAgentResult, type AcpStartOpts, type AcpStopOpts, type AcpTransport, } from './transport' export { DEFAULT_STOP_TIMEOUT_MS } from './transport' -export type { AcpAgentExit, AcpStartOpts, AcpStopOpts } from './transport' +export { DEFAULT_MAX_MESSAGE_BYTES } from './utils' +export type { AcpAgentResult, AcpStartOpts, AcpStopOpts } from './transport' /** * ACP agent running in a sandbox, connected to your client. @@ -45,7 +46,7 @@ export class AcpAgent { * * @returns exit status of the agent. */ - async wait(): Promise { + async wait(): Promise { return this.transport.wait() } diff --git a/packages/acp-js/src/transport.ts b/packages/acp-js/src/transport.ts index 862d7167cd..2199024073 100644 --- a/packages/acp-js/src/transport.ts +++ b/packages/acp-js/src/transport.ts @@ -54,7 +54,7 @@ export interface AcpStopOpts { /** * Exit status of an ACP agent process. */ -export interface AcpAgentExit { +export interface AcpAgentResult { /** * Exit code of the agent process. */ @@ -93,8 +93,8 @@ export class AcpTransport { readonly pid: number, readonly stream: AcpStream, private readonly handle: CommandHandle, - private readonly exit: Promise, - private readonly exitStatus: () => AcpAgentExit | undefined, + private readonly exit: Promise, + private readonly exitStatus: () => AcpAgentResult | undefined, private readonly stopReading: () => void, private readonly endInput: () => Promise ) {} @@ -104,7 +104,7 @@ export class AcpTransport { * * It rejects if the agent's output stream failed before its exit status was known. */ - async wait(): Promise { + async wait(): Promise { return this.exit } @@ -182,7 +182,7 @@ export async function startAcpTransport( let closing = false let resumeReading: (() => void) | undefined let controller!: ReadableStreamDefaultController - let exitStatus: AcpAgentExit | undefined + let exitStatus: AcpAgentResult | undefined let resolveHandle!: (handle: CommandHandle) => void let rejectHandle!: (err: unknown) => void @@ -328,11 +328,11 @@ export async function startAcpTransport( resolveHandle(handle) const exit = handle.wait().then( - (result): AcpAgentExit => ({ + (result): AcpAgentResult => ({ exitCode: result.exitCode, error: result.error, }), - (err): AcpAgentExit => { + (err): AcpAgentResult => { if (err instanceof CommandExitError) { return { exitCode: err.exitCode, error: err.error } } diff --git a/packages/acp-js/src/utils.ts b/packages/acp-js/src/utils.ts index 6d0be2f267..e65f5a495e 100644 --- a/packages/acp-js/src/utils.ts +++ b/packages/acp-js/src/utils.ts @@ -61,7 +61,7 @@ export class LineSplitter { private check(bytes: number) { if (bytes > this.maxLineBytes) { throw new SandboxError( - `ACP message exceeds the limit of ${this.maxLineBytes} bytes` + `ACP message exceeds the limit of ${this.maxLineBytes} bytes. Pass a larger \`maxMessageBytes\` to connectAcpAgent() if the agent sends larger messages.` ) } } From 22d15366c047bf65c1ef15e8ade778ec6866eeb4 Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:11:50 +0100 Subject: [PATCH 15/21] refactor(acp-js): start the agent with the public retainOutput option #2044 added retainOutput to commands.run and commands.connect, so the internal Commands.startStreaming this branch carried is gone. The transport starts the agent with run({ background: true, retainOutput: false }) and the changeset no longer bumps the JS SDK. Co-Authored-By: Claude Fable 5.1 --- .changeset/acp-js-package.md | 3 +-- packages/acp-js/src/transport.ts | 26 ++++++++++++------- .../acp-js/tests/transport.offline.test.ts | 13 ++++++---- 3 files changed, 25 insertions(+), 17 deletions(-) diff --git a/.changeset/acp-js-package.md b/.changeset/acp-js-package.md index ffe5825396..53dcdeb947 100644 --- a/.changeset/acp-js-package.md +++ b/.changeset/acp-js-package.md @@ -1,6 +1,5 @@ --- "@e2b/acp": minor -"e2b": patch --- -Add `@e2b/acp` for running [Agent Client Protocol](https://agentclientprotocol.com) agents such as goose, OpenCode or Claude Code (through its ACP adapter) inside an E2B sandbox. `connectAcpAgent(sandbox, command, client)` starts the agent over stdin/stdout and returns the ACP client connection. Client filesystem and terminal capabilities are turned off and agent requests for them are rejected, so the agent edits files and runs commands with its own tools in the sandbox. The JS SDK gets an internal way to stream a background command's output without keeping it in memory, used by this package. +Add `@e2b/acp` for running [Agent Client Protocol](https://agentclientprotocol.com) agents such as goose, OpenCode or Claude Code (through its ACP adapter) inside an E2B sandbox. `connectAcpAgent(sandbox, command, client)` starts the agent over stdin/stdout and returns the ACP client connection. Client filesystem and terminal capabilities are turned off and agent requests for them are rejected, so the agent edits files and runs commands with its own tools in the sandbox. diff --git a/packages/acp-js/src/transport.ts b/packages/acp-js/src/transport.ts index 2199024073..73b12a73e9 100644 --- a/packages/acp-js/src/transport.ts +++ b/packages/acp-js/src/transport.ts @@ -92,7 +92,7 @@ export class AcpTransport { constructor( readonly pid: number, readonly stream: AcpStream, - private readonly handle: CommandHandle, + private readonly handle: CommandHandle, private readonly exit: Promise, private readonly exitStatus: () => AcpAgentResult | undefined, private readonly stopReading: () => void, @@ -184,18 +184,22 @@ export async function startAcpTransport( let controller!: ReadableStreamDefaultController let exitStatus: AcpAgentResult | undefined - let resolveHandle!: (handle: CommandHandle) => void + let resolveHandle!: (handle: CommandHandle) => void let rejectHandle!: (err: unknown) => void - const handleReady = new Promise((resolve, reject) => { - resolveHandle = resolve - rejectHandle = reject - }) + const handleReady = new Promise>( + (resolve, reject) => { + resolveHandle = resolve + rejectHandle = reject + } + ) handleReady.catch(() => {}) // Client messages, replies to the agent and EOF share one ordered write // path, so concurrent writes never interleave. let writes: Promise = Promise.resolve() - const enqueueWrite = (write: (handle: CommandHandle) => Promise) => { + const enqueueWrite = ( + write: (handle: CommandHandle) => Promise + ) => { const result = writes.then(async () => { await write(await handleReady) }) @@ -254,7 +258,7 @@ export async function startAcpTransport( } let transport: AcpTransport | undefined - // Output can arrive before startStreaming() returns, so wait for the transport. + // Output can arrive before run() returns, so wait for the transport. const closeTransport = () => handleReady.then(() => transport!.close()) const readable = new ReadableStream( @@ -289,9 +293,11 @@ export async function startAcpTransport( resumeReading = undefined } - let handle: CommandHandle + let handle: CommandHandle try { - handle = await commands.startStreaming(cmd, { + handle = await commands.run(cmd, { + background: true, + retainOutput: false, stdin: true, timeoutMs: 0, cwd: opts?.cwd, diff --git a/packages/acp-js/tests/transport.offline.test.ts b/packages/acp-js/tests/transport.offline.test.ts index 6e372f4588..3e8c91877f 100644 --- a/packages/acp-js/tests/transport.offline.test.ts +++ b/packages/acp-js/tests/transport.offline.test.ts @@ -44,7 +44,7 @@ function fakeAgent(opts: { closeStdin?: () => Promise } = {}) { const sandbox = { commands: { supportsStdinClose: true, - startStreaming: async (_cmd: string, o: CommandStartOpts) => { + run: async (_cmd: string, o: CommandStartOpts) => { callbacks = o return handle }, @@ -232,12 +232,15 @@ test('kill lets wait() see the exit while output waits for the client', async () test('output over the limit before start returns still closes the agent', async () => { const closeStdin = vi.fn(async () => {}) const agent = fakeAgent({ closeStdin }) - const startStreaming = agent.sandbox.commands.startStreaming - agent.sandbox.commands.startStreaming = async (cmd, opts) => { - const handle = await startStreaming(cmd, opts) + const run = agent.sandbox.commands.run + agent.sandbox.commands.run = (async ( + cmd: string, + opts?: CommandStartOpts + ) => { + const handle = await run(cmd, opts) opts!.onStdout!('x'.repeat(100)) return handle - } + }) as typeof run const transport = await startAcpTransport(agent.sandbox, 'agent', { maxMessageBytes: 10, }) From 9f142b08ebe564b1ca9efb7ab0966e9f5fd6268a Mon Sep 17 00:00:00 2001 From: Mish Ushakov <10400064+mishushakov@users.noreply.github.com> Date: Wed, 28 Oct 2026 17:11:50 +0100 Subject: [PATCH 16/21] chore: refresh the pnpm lockfile for the rebased branch Co-Authored-By: Claude Fable 5.1 --- pnpm-lock.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b8d4b3581b..79a363ce01 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,7 +104,7 @@ importers: devDependencies: '@agentclientprotocol/sdk': specifier: 1.8.0 - version: 1.8.0(zod@4.6.5) + version: 1.8.0(zod@4.6.7) '@types/node': specifier: 'catalog:' version: 22.20.5 @@ -401,7 +401,7 @@ packages: '@agentclientprotocol/sdk@1.8.0': resolution: {integrity: sha512-fbP7qHZsFyTJVT0/RPrcWJ8An26qLXHEuGCHHEKDyxD1xpXV+Ejyh2LAE6vBAFePkpVjZeeohnbgvuEhZ7wLMg==} peerDependencies: - zod: ^3.25.0 || ^4.0.0 + zod: ^4.6.6 '@apidevtools/json-schema-ref-parser@11.9.3': resolution: {integrity: sha512-60vepv88RwcJtSHrD6MjIL6Ta3SOYbgfnkHb+ppAVK+o9mXprRtulx7VlRl3lN3bbvysAfCS7WMVfhUYemB0IQ==} @@ -4311,9 +4311,9 @@ packages: snapshots: - '@agentclientprotocol/sdk@1.8.0(zod@4.6.5)': + '@agentclientprotocol/sdk@1.8.0(zod@4.6.7)': dependencies: - zod: 4.6.5 + zod: 4.6.7 '@apidevtools/json-schema-ref-parser@11.9.3': dependencies: From 78421479717f96c023b972e8a1df9e94affd0c84 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Thu, 29 Oct 2026 14:18:00 +0100 Subject: [PATCH 17/21] ci: pass the release plan to is_release_for_package for @e2b/acp --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 83c4aceae5..2d4770c809 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -153,7 +153,7 @@ jobs: id: acp_js if: steps.version.outputs.release == 'true' run: | - IS_RELEASE=$(./.github/scripts/is_release_for_package.sh "@e2b/acp") + IS_RELEASE=$(./.github/scripts/is_release_for_package.sh "@e2b/acp" "$RUNNER_TEMP/release-plan.json") echo "release=$IS_RELEASE" >> "$GITHUB_OUTPUT" - name: Build release itinerary From 6f6250e499cd34286b8b4213776709b82f899573 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Thu, 29 Oct 2026 14:18:40 +0100 Subject: [PATCH 18/21] refactor(acp-js)!: start agents with AcpAgent.start, rename option types Replace connectAcpAgent() with the static AcpAgent.start(), rename AcpStartOpts/AcpStopOpts to AcpAgentStartOpts/AcpAgentStopOpts and export the StderrHandler type. --- .changeset/acp-js-package.md | 2 +- packages/acp-js/README.md | 6 +- packages/acp-js/src/index.ts | 121 +++++++++--------- packages/acp-js/src/transport.ts | 15 ++- packages/acp-js/src/utils.ts | 2 +- packages/acp-js/tests/connect.test.ts | 6 +- .../acp-js/tests/transport.offline.test.ts | 4 +- packages/acp-js/tests/transport.test.ts | 4 +- 8 files changed, 85 insertions(+), 75 deletions(-) diff --git a/.changeset/acp-js-package.md b/.changeset/acp-js-package.md index 53dcdeb947..cd058a1290 100644 --- a/.changeset/acp-js-package.md +++ b/.changeset/acp-js-package.md @@ -2,4 +2,4 @@ "@e2b/acp": minor --- -Add `@e2b/acp` for running [Agent Client Protocol](https://agentclientprotocol.com) agents such as goose, OpenCode or Claude Code (through its ACP adapter) inside an E2B sandbox. `connectAcpAgent(sandbox, command, client)` starts the agent over stdin/stdout and returns the ACP client connection. Client filesystem and terminal capabilities are turned off and agent requests for them are rejected, so the agent edits files and runs commands with its own tools in the sandbox. +Add `@e2b/acp` for running [Agent Client Protocol](https://agentclientprotocol.com) agents such as goose, OpenCode or Claude Code (through its ACP adapter) inside an E2B sandbox. `AcpAgent.start(sandbox, command, client)` starts the agent over stdin/stdout and returns the ACP client connection. Client filesystem and terminal capabilities are turned off and agent requests for them are rejected, so the agent edits files and runs commands with its own tools in the sandbox. diff --git a/packages/acp-js/README.md b/packages/acp-js/README.md index 7418de7ea7..0f4f023741 100644 --- a/packages/acp-js/README.md +++ b/packages/acp-js/README.md @@ -18,7 +18,7 @@ Create a sandbox from a template with the agent installed, then start the agent ```ts import { Sandbox } from 'e2b' -import { connectAcpAgent } from '@e2b/acp' +import { AcpAgent } from '@e2b/acp' import { PROTOCOL_VERSION, type Client } from '@agentclientprotocol/sdk' const client: Client = { @@ -35,7 +35,7 @@ const client: Client = { const sandbox = await Sandbox.create('goose', { timeoutMs: 10 * 60_000 }) try { - const agent = await connectAcpAgent(sandbox, 'goose acp --with-builtin developer', client, { + const agent = await AcpAgent.start(sandbox, 'goose acp --with-builtin developer', client, { envs: { GOOSE_PROVIDER: 'openai', GOOSE_MODEL: 'gpt-5.6-luna', @@ -58,7 +58,7 @@ try { ## Where the agent's tools run -The agent talks ACP over its stdin and stdout. `connectAcpAgent` turns off the client filesystem and terminal capabilities in `initialize` and answers agent requests for `fs/*` or `terminal/*` with an error, so the agent edits files and runs commands with its own tools inside the sandbox. It does not restrict the agent's network access or the MCP servers you pass to it. +The agent talks ACP over its stdin and stdout. `AcpAgent.start` turns off the client filesystem and terminal capabilities in `initialize` and answers agent requests for `fs/*` or `terminal/*` with an error, so the agent edits files and runs commands with its own tools inside the sandbox. It does not restrict the agent's network access or the MCP servers you pass to it. ## Lifecycle diff --git a/packages/acp-js/src/index.ts b/packages/acp-js/src/index.ts index 2ddc43a002..11881520d2 100644 --- a/packages/acp-js/src/index.ts +++ b/packages/acp-js/src/index.ts @@ -7,14 +7,19 @@ import type { Sandbox } from 'e2b' import { startAcpTransport, type AcpAgentResult, - type AcpStartOpts, - type AcpStopOpts, + type AcpAgentStartOpts, + type AcpAgentStopOpts, type AcpTransport, } from './transport' export { DEFAULT_STOP_TIMEOUT_MS } from './transport' export { DEFAULT_MAX_MESSAGE_BYTES } from './utils' -export type { AcpAgentResult, AcpStartOpts, AcpStopOpts } from './transport' +export type { + AcpAgentResult, + AcpAgentStartOpts, + AcpAgentStopOpts, + StderrHandler, +} from './transport' /** * ACP agent running in a sandbox, connected to your client. @@ -32,6 +37,60 @@ export class AcpAgent { readonly connection: ClientSideConnection ) {} + /** + * Start an [Agent Client Protocol](https://agentclientprotocol.com) agent in + * the sandbox and connect your ACP client to it over the agent's stdin/stdout. + * + * The client filesystem and terminal capabilities are turned off in + * `initialize`, and agent requests for `fs/*` or `terminal/*` are answered + * with an error without reaching your client, so the agent edits files and + * runs commands with its own tools in the sandbox. Network access and MCP + * servers you pass to the agent are not restricted. + * + * @param sandbox sandbox with the agent installed. + * @param command command that starts the agent in ACP mode, for example `goose acp`. + * @param client your ACP client, or a function that creates it for the agent + * connection. It answers permission requests and receives the agent's session updates. + * @param opts options for starting the agent. + * + * @returns `AcpAgent` with the ACP connection. + * + * @throws InvalidArgumentError if `maxMessageBytes` is not a positive integer. + * @throws SandboxError if the agent could not be started. Later, the + * connection fails with a `SandboxError` if the agent exits with a non-zero + * code or sends a message over `maxMessageBytes`. + * + * @example + * ```ts + * import { Sandbox } from 'e2b' + * import { AcpAgent } from '@e2b/acp' + * import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' + * + * const sandbox = await Sandbox.create('goose') + * const agent = await AcpAgent.start(sandbox, 'goose acp', client) + * await agent.connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) + * // ... + * await agent.stop() + * ``` + */ + static async start( + sandbox: Sandbox, + command: string, + client: Client | ((agent: Agent) => Client), + opts?: AcpAgentStartOpts + ): Promise { + const transport = await startAcpTransport(sandbox, command, opts) + const toClient = typeof client === 'function' ? client : () => client + let connection: ClientSideConnection + try { + connection = new ClientSideConnection(toClient, transport.stream) + } catch (err) { + await transport.kill().catch(() => false) + throw err + } + return new AcpAgent(transport, connection) + } + /** * Process ID of the agent in the sandbox. */ @@ -59,7 +118,7 @@ export class AcpAgent { * * @throws SandboxError if the agent could not be killed. */ - async stop(opts?: AcpStopOpts): Promise { + async stop(opts?: AcpAgentStopOpts): Promise { return this.transport.close(opts) } @@ -72,57 +131,3 @@ export class AcpAgent { return this.transport.kill() } } - -/** - * Start an [Agent Client Protocol](https://agentclientprotocol.com) agent in - * the sandbox and connect your ACP client to it over the agent's stdin/stdout. - * - * The client filesystem and terminal capabilities are turned off in - * `initialize`, and agent requests for `fs/*` or `terminal/*` are answered - * with an error without reaching your client, so the agent edits files and - * runs commands with its own tools in the sandbox. Network access and MCP - * servers you pass to the agent are not restricted. - * - * @param sandbox sandbox with the agent installed. - * @param command command that starts the agent in ACP mode, for example `goose acp`. - * @param client your ACP client, or a function that creates it for the agent - * connection. It answers permission requests and receives the agent's session updates. - * @param opts options for starting the agent. - * - * @returns `AcpAgent` with the ACP connection. - * - * @throws InvalidArgumentError if `maxMessageBytes` is not a positive integer. - * @throws SandboxError if the agent could not be started. Later, the - * connection fails with a `SandboxError` if the agent exits with a non-zero - * code or sends a message over `maxMessageBytes`. - * - * @example - * ```ts - * import { Sandbox } from 'e2b' - * import { connectAcpAgent } from '@e2b/acp' - * import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' - * - * const sandbox = await Sandbox.create('goose') - * const agent = await connectAcpAgent(sandbox, 'goose acp', client) - * await agent.connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - * // ... - * await agent.stop() - * ``` - */ -export async function connectAcpAgent( - sandbox: Sandbox, - command: string, - client: Client | ((agent: Agent) => Client), - opts?: AcpStartOpts -): Promise { - const transport = await startAcpTransport(sandbox, command, opts) - const toClient = typeof client === 'function' ? client : () => client - let connection: ClientSideConnection - try { - connection = new ClientSideConnection(toClient, transport.stream) - } catch (err) { - await transport.kill().catch(() => false) - throw err - } - return new AcpAgent(transport, connection) -} diff --git a/packages/acp-js/src/transport.ts b/packages/acp-js/src/transport.ts index 73b12a73e9..732c049ab5 100644 --- a/packages/acp-js/src/transport.ts +++ b/packages/acp-js/src/transport.ts @@ -14,17 +14,22 @@ import { restrictClientCapabilities, } from './utils' +/** + * Callback for the agent's stderr output. + */ +export type StderrHandler = (data: string) => void | Promise + /** * Options for starting an ACP agent in a sandbox. */ -export interface AcpStartOpts extends Pick< +export interface AcpAgentStartOpts extends Pick< CommandStartOpts, 'cwd' | 'envs' | 'user' | 'requestTimeoutMs' | 'signal' > { /** * Callback for the agent's stderr output (logs). The agent's stdout is reserved for the protocol. */ - onStderr?: (data: string) => void | Promise + onStderr?: StderrHandler /** * Maximum size of a single message from the agent in **bytes**. * @@ -41,7 +46,7 @@ export const DEFAULT_STOP_TIMEOUT_MS = 5_000 /** * Options for stopping an ACP agent. */ -export interface AcpStopOpts { +export interface AcpAgentStopOpts { /** * Time for the agent to exit after `stop()` is called, in **milliseconds**. * It covers sending EOF to the agent's stdin; after it the agent is killed. @@ -115,7 +120,7 @@ export class AcpTransport { * * It rejects if the agent could not be killed. */ - async close(opts?: AcpStopOpts): Promise { + async close(opts?: AcpAgentStopOpts): Promise { // Only a known exit status means the process is gone. A failed output // stream can leave it running. if (this.exitStatus()) { @@ -169,7 +174,7 @@ export class AcpTransport { export async function startAcpTransport( sandbox: AcpSandbox, cmd: string, - opts?: AcpStartOpts + opts?: AcpAgentStartOpts ): Promise { const commands = sandbox.commands const lines = new LineSplitter( diff --git a/packages/acp-js/src/utils.ts b/packages/acp-js/src/utils.ts index e65f5a495e..ecd6b0abf7 100644 --- a/packages/acp-js/src/utils.ts +++ b/packages/acp-js/src/utils.ts @@ -61,7 +61,7 @@ export class LineSplitter { private check(bytes: number) { if (bytes > this.maxLineBytes) { throw new SandboxError( - `ACP message exceeds the limit of ${this.maxLineBytes} bytes. Pass a larger \`maxMessageBytes\` to connectAcpAgent() if the agent sends larger messages.` + `ACP message exceeds the limit of ${this.maxLineBytes} bytes. Pass a larger \`maxMessageBytes\` to AcpAgent.start() if the agent sends larger messages.` ) } } diff --git a/packages/acp-js/tests/connect.test.ts b/packages/acp-js/tests/connect.test.ts index 20b6be3abe..050f30be75 100644 --- a/packages/acp-js/tests/connect.test.ts +++ b/packages/acp-js/tests/connect.test.ts @@ -1,6 +1,6 @@ import { PROTOCOL_VERSION, type Client } from '@agentclientprotocol/sdk' import { assert } from 'vitest' -import { connectAcpAgent } from '../src' +import { AcpAgent } from '../src' import { FAKE_AGENT_CMD, uploadFakeAgent } from './fakeAgent' import { sandboxTest } from './setup' @@ -11,9 +11,9 @@ const client: Client = { async sessionUpdate() {}, } -sandboxTest('connectAcpAgent connects an ACP client', async ({ sandbox }) => { +sandboxTest('AcpAgent.start connects an ACP client', async ({ sandbox }) => { await uploadFakeAgent(sandbox) - const agent = await connectAcpAgent(sandbox, FAKE_AGENT_CMD, client) + const agent = await AcpAgent.start(sandbox, FAKE_AGENT_CMD, client) const response = await agent.connection.initialize({ protocolVersion: PROTOCOL_VERSION, diff --git a/packages/acp-js/tests/transport.offline.test.ts b/packages/acp-js/tests/transport.offline.test.ts index 3e8c91877f..5e73ccd99e 100644 --- a/packages/acp-js/tests/transport.offline.test.ts +++ b/packages/acp-js/tests/transport.offline.test.ts @@ -1,6 +1,6 @@ import { CommandExitError, type CommandStartOpts, type Sandbox } from 'e2b' import { assert, expect, test, vi } from 'vitest' -import { connectAcpAgent } from '../src' +import { AcpAgent } from '../src' import { startAcpTransport, type AcpSandbox } from '../src/transport' const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) @@ -203,7 +203,7 @@ test('connect kills the agent when the client factory throws', async () => { const agent = fakeAgent() await expect( - connectAcpAgent(agent.sandbox as Sandbox, 'agent', () => { + AcpAgent.start(agent.sandbox as Sandbox, 'agent', () => { throw new Error('no client') }) ).rejects.toThrow('no client') diff --git a/packages/acp-js/tests/transport.test.ts b/packages/acp-js/tests/transport.test.ts index 6aaf583bb0..dacc97ca32 100644 --- a/packages/acp-js/tests/transport.test.ts +++ b/packages/acp-js/tests/transport.test.ts @@ -1,6 +1,6 @@ import type { AnyMessage } from '@agentclientprotocol/sdk' import { assert } from 'vitest' -import { startAcpTransport, type AcpStartOpts } from '../src/transport' +import { startAcpTransport, type AcpAgentStartOpts } from '../src/transport' import { FAKE_AGENT_CMD, uploadFakeAgent } from './fakeAgent' import { sandboxTest } from './setup' @@ -8,7 +8,7 @@ type Message = Record async function startAgent( sandbox: Parameters[1]>[0]['sandbox'], - opts?: AcpStartOpts + opts?: AcpAgentStartOpts ) { await uploadFakeAgent(sandbox) const agent = await startAcpTransport(sandbox, FAKE_AGENT_CMD, opts) From 0b900aefb2378341e90bc001979e8e1d9abf6c15 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Thu, 29 Oct 2026 14:20:27 +0100 Subject: [PATCH 19/21] refactor(acp-js)!: connect a ClientApp instead of the deprecated ClientSideConnection AcpAgent.start() takes a client() app from @agentclientprotocol/sdk and connects it to the agent's stream, so agent.connection is a ClientConnection. ClientSideConnection is deprecated in @agentclientprotocol/sdk 1.8.0. --- packages/acp-js/src/index.ts | 38 +++++++----- packages/acp-js/tests/connect.test.ts | 25 ++++---- .../acp-js/tests/transport.offline.test.ts | 62 +++++++++++++++++-- 3 files changed, 92 insertions(+), 33 deletions(-) diff --git a/packages/acp-js/src/index.ts b/packages/acp-js/src/index.ts index 11881520d2..09b790d5a9 100644 --- a/packages/acp-js/src/index.ts +++ b/packages/acp-js/src/index.ts @@ -1,8 +1,4 @@ -import { - ClientSideConnection, - type Agent, - type Client, -} from '@agentclientprotocol/sdk' +import type { ClientApp, ClientConnection } from '@agentclientprotocol/sdk' import type { Sandbox } from 'e2b' import { startAcpTransport, @@ -32,9 +28,10 @@ export class AcpAgent { constructor( private readonly transport: AcpTransport, /** - * Connection to the agent. Use it to initialize, create sessions and send prompts. + * Connection to the agent. Use `connection.agent` to initialize, create + * sessions and send prompts. */ - readonly connection: ClientSideConnection + readonly connection: ClientConnection ) {} /** @@ -49,8 +46,9 @@ export class AcpAgent { * * @param sandbox sandbox with the agent installed. * @param command command that starts the agent in ACP mode, for example `goose acp`. - * @param client your ACP client, or a function that creates it for the agent - * connection. It answers permission requests and receives the agent's session updates. + * @param client your ACP client app, created with `client()` from + * `@agentclientprotocol/sdk`. Its handlers answer permission requests and + * receive the agent's session updates. * @param opts options for starting the agent. * * @returns `AcpAgent` with the ACP connection. @@ -64,26 +62,32 @@ export class AcpAgent { * ```ts * import { Sandbox } from 'e2b' * import { AcpAgent } from '@e2b/acp' - * import { PROTOCOL_VERSION } from '@agentclientprotocol/sdk' + * import { client, methods, PROTOCOL_VERSION } from '@agentclientprotocol/sdk' * + * const app = client().onNotification(methods.client.session.update, ({ params }) => { + * console.log(params.update) + * }) * const sandbox = await Sandbox.create('goose') - * const agent = await AcpAgent.start(sandbox, 'goose acp', client) - * await agent.connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - * // ... + * const agent = await AcpAgent.start(sandbox, 'goose acp', app) + * await agent.connection.agent.request(methods.agent.initialize, { + * protocolVersion: PROTOCOL_VERSION, + * }) + * const session = await agent.connection.agent.buildSession('/home/user').start() + * await session.prompt('Create hello.py and run it.') * await agent.stop() * ``` */ static async start( sandbox: Sandbox, command: string, - client: Client | ((agent: Agent) => Client), + client: ClientApp, opts?: AcpAgentStartOpts ): Promise { const transport = await startAcpTransport(sandbox, command, opts) - const toClient = typeof client === 'function' ? client : () => client - let connection: ClientSideConnection + let connection: ClientConnection try { - connection = new ClientSideConnection(toClient, transport.stream) + // Throws if an `onConnect` handler throws. + connection = client.connect(transport.stream) } catch (err) { await transport.kill().catch(() => false) throw err diff --git a/packages/acp-js/tests/connect.test.ts b/packages/acp-js/tests/connect.test.ts index 050f30be75..8105d42ad5 100644 --- a/packages/acp-js/tests/connect.test.ts +++ b/packages/acp-js/tests/connect.test.ts @@ -1,24 +1,25 @@ -import { PROTOCOL_VERSION, type Client } from '@agentclientprotocol/sdk' +import { client, methods, PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { assert } from 'vitest' import { AcpAgent } from '../src' import { FAKE_AGENT_CMD, uploadFakeAgent } from './fakeAgent' import { sandboxTest } from './setup' -const client: Client = { - async requestPermission() { - return { outcome: { outcome: 'cancelled' } } - }, - async sessionUpdate() {}, -} +const app = client().onRequest( + methods.client.session.requestPermission, + async () => ({ outcome: { outcome: 'cancelled' } }) +) sandboxTest('AcpAgent.start connects an ACP client', async ({ sandbox }) => { await uploadFakeAgent(sandbox) - const agent = await AcpAgent.start(sandbox, FAKE_AGENT_CMD, client) + const agent = await AcpAgent.start(sandbox, FAKE_AGENT_CMD, app) - const response = await agent.connection.initialize({ - protocolVersion: PROTOCOL_VERSION, - clientCapabilities: { fs: { readTextFile: true }, terminal: true }, - }) + const response = await agent.connection.agent.request( + methods.agent.initialize, + { + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { fs: { readTextFile: true }, terminal: true }, + } + ) assert.equal(response.protocolVersion, 1) await agent.stop() diff --git a/packages/acp-js/tests/transport.offline.test.ts b/packages/acp-js/tests/transport.offline.test.ts index 5e73ccd99e..a1575db2be 100644 --- a/packages/acp-js/tests/transport.offline.test.ts +++ b/packages/acp-js/tests/transport.offline.test.ts @@ -1,5 +1,6 @@ import { CommandExitError, type CommandStartOpts, type Sandbox } from 'e2b' import { assert, expect, test, vi } from 'vitest' +import { client, methods, PROTOCOL_VERSION } from '@agentclientprotocol/sdk' import { AcpAgent } from '../src' import { startAcpTransport, type AcpSandbox } from '../src/transport' @@ -199,17 +200,70 @@ test('close drops later lines of an output chunk already being read', async () = assert.notInclude(methods, 'after') }) -test('connect kills the agent when the client factory throws', async () => { +test('start kills the agent when an onConnect handler throws', async () => { const agent = fakeAgent() + const app = client().onConnect(() => { + throw new Error('no client') + }) await expect( - AcpAgent.start(agent.sandbox as Sandbox, 'agent', () => { - throw new Error('no client') - }) + AcpAgent.start(agent.sandbox as Sandbox, 'agent', app) ).rejects.toThrow('no client') expect(agent.handle.kill).toHaveBeenCalledOnce() }) +test('start connects the client app to the agent', async () => { + const agent = fakeAgent() + const updates: unknown[] = [] + const app = client().onNotification( + methods.client.session.update, + ({ params }) => { + updates.push(params.update) + } + ) + const acpAgent = await AcpAgent.start(agent.sandbox as Sandbox, 'agent', app) + + const response = acpAgent.connection.agent.request(methods.agent.initialize, { + protocolVersion: PROTOCOL_VERSION, + clientCapabilities: { terminal: true }, + }) + await vi.waitFor(() => assert.lengthOf(agent.writes, 1)) + const request = JSON.parse(agent.writes[0]) + assert.deepEqual(request.params.clientCapabilities, { + fs: { readTextFile: false, writeTextFile: false }, + terminal: false, + }) + await agent.emit( + { + jsonrpc: '2.0', + method: 'session/update', + params: { + sessionId: 's', + update: { sessionUpdate: 'plan', entries: [] }, + }, + }, + { jsonrpc: '2.0', id: request.id, result: { protocolVersion: 1 } } + ) + + assert.equal((await response).protocolVersion, 1) + await vi.waitFor(() => assert.lengthOf(updates, 1)) +}) + +test('closing the connection stops the agent', async () => { + const closeStdin = vi.fn(async () => {}) + const agent = fakeAgent({ closeStdin }) + const acpAgent = await AcpAgent.start( + agent.sandbox as Sandbox, + 'agent', + client() + ) + + acpAgent.connection.close() + await acpAgent.connection.closed + + await vi.waitFor(() => expect(closeStdin).toHaveBeenCalledOnce()) +}) + test('kill lets wait() see the exit while output waits for the client', async () => { const agent = fakeAgent() const transport = await startAcpTransport(agent.sandbox, 'agent') From b43f150d4cf78d7b53e0d99f7bc3be8a5f738796 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Thu, 29 Oct 2026 14:20:27 +0100 Subject: [PATCH 20/21] docs(acp-js): show AcpAgent.start with a client() app --- .changeset/acp-js-package.md | 2 +- packages/acp-js/README.md | 29 +++++++++++++---------------- 2 files changed, 14 insertions(+), 17 deletions(-) diff --git a/.changeset/acp-js-package.md b/.changeset/acp-js-package.md index cd058a1290..baafaa31f5 100644 --- a/.changeset/acp-js-package.md +++ b/.changeset/acp-js-package.md @@ -2,4 +2,4 @@ "@e2b/acp": minor --- -Add `@e2b/acp` for running [Agent Client Protocol](https://agentclientprotocol.com) agents such as goose, OpenCode or Claude Code (through its ACP adapter) inside an E2B sandbox. `AcpAgent.start(sandbox, command, client)` starts the agent over stdin/stdout and returns the ACP client connection. Client filesystem and terminal capabilities are turned off and agent requests for them are rejected, so the agent edits files and runs commands with its own tools in the sandbox. +Add `@e2b/acp` for running [Agent Client Protocol](https://agentclientprotocol.com) agents such as goose, OpenCode or Claude Code (through its ACP adapter) inside an E2B sandbox. `AcpAgent.start(sandbox, command, client)` starts the agent over stdin/stdout and connects your ACP `client()` app to it. Client filesystem and terminal capabilities are turned off and agent requests for them are rejected, so the agent edits files and runs commands with its own tools in the sandbox. diff --git a/packages/acp-js/README.md b/packages/acp-js/README.md index 0f4f023741..7f8948abdc 100644 --- a/packages/acp-js/README.md +++ b/packages/acp-js/README.md @@ -14,28 +14,27 @@ Set `E2B_API_KEY` to your [E2B API key](https://e2b.dev/dashboard?tab=keys), and ## Usage -Create a sandbox from a template with the agent installed, then start the agent and connect your ACP client to it: +Build your ACP client with `client()` from `@agentclientprotocol/sdk`, create a sandbox from a template with the agent installed, then start the agent and connect the client to it: ```ts import { Sandbox } from 'e2b' import { AcpAgent } from '@e2b/acp' -import { PROTOCOL_VERSION, type Client } from '@agentclientprotocol/sdk' +import { client, methods, PROTOCOL_VERSION } from '@agentclientprotocol/sdk' -const client: Client = { - async requestPermission({ options }) { - const allow = options.find((o) => o.kind === 'allow_once') ?? options[0] +const app = client({ name: 'my-app' }) + .onRequest(methods.client.session.requestPermission, async ({ params }) => { + const allow = params.options.find((o) => o.kind === 'allow_once') ?? params.options[0] return { outcome: { outcome: 'selected', optionId: allow.optionId } } - }, - async sessionUpdate({ update }) { + }) + .onNotification(methods.client.session.update, ({ params: { update } }) => { if (update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text') { process.stdout.write(update.content.text) } - }, -} + }) const sandbox = await Sandbox.create('goose', { timeoutMs: 10 * 60_000 }) try { - const agent = await AcpAgent.start(sandbox, 'goose acp --with-builtin developer', client, { + const agent = await AcpAgent.start(sandbox, 'goose acp --with-builtin developer', app, { envs: { GOOSE_PROVIDER: 'openai', GOOSE_MODEL: 'gpt-5.6-luna', @@ -43,12 +42,9 @@ try { }, }) - await agent.connection.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} }) - const { sessionId } = await agent.connection.newSession({ cwd: '/home/user', mcpServers: [] }) - await agent.connection.prompt({ - sessionId, - prompt: [{ type: 'text', text: 'Create fizzbuzz.py for 1 to 15 and run it.' }], - }) + await agent.connection.agent.request(methods.agent.initialize, { protocolVersion: PROTOCOL_VERSION }) + const session = await agent.connection.agent.buildSession('/home/user').start() + await session.prompt('Create fizzbuzz.py for 1 to 15 and run it.') await agent.stop() } finally { @@ -64,5 +60,6 @@ The agent talks ACP over its stdin and stdout. `AcpAgent.start` turns off the cl - `agent.stop()` sends EOF to the agent and kills it if it has not exited within `timeoutMs` (5 seconds by default) of the call. Messages the agent sends after that are not delivered. - `agent.wait()` resolves with the exit code once the agent exits. +- Closing the connection with `agent.connection.close()` stops the agent the same way. - If the agent exits with an error, the connection fails with the exit code and the end of the agent's stderr. Pass `onStderr` to receive its logs. - The agent runs as long as the sandbox does, so set the sandbox timeout for the longest task you expect. From 336eea0f4d1091fd9d944a38405c15fab20a2b00 Mon Sep 17 00:00:00 2001 From: Maksim Obukhov Date: Thu, 29 Oct 2026 14:25:00 +0100 Subject: [PATCH 21/21] docs(acp-js): send session requests directly in the usage example buildSession().start() queues every session update until it is read, so with updates handled in onNotification the queue only grows. Also say that closing the connection stops the agent in the background. --- packages/acp-js/README.md | 12 +++++++++--- packages/acp-js/src/index.ts | 12 +++++++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/packages/acp-js/README.md b/packages/acp-js/README.md index 7f8948abdc..dc6a3aa658 100644 --- a/packages/acp-js/README.md +++ b/packages/acp-js/README.md @@ -43,8 +43,14 @@ try { }) await agent.connection.agent.request(methods.agent.initialize, { protocolVersion: PROTOCOL_VERSION }) - const session = await agent.connection.agent.buildSession('/home/user').start() - await session.prompt('Create fizzbuzz.py for 1 to 15 and run it.') + const { sessionId } = await agent.connection.agent.request(methods.agent.session.new, { + cwd: '/home/user', + mcpServers: [], + }) + await agent.connection.agent.request(methods.agent.session.prompt, { + sessionId, + prompt: [{ type: 'text', text: 'Create fizzbuzz.py for 1 to 15 and run it.' }], + }) await agent.stop() } finally { @@ -60,6 +66,6 @@ The agent talks ACP over its stdin and stdout. `AcpAgent.start` turns off the cl - `agent.stop()` sends EOF to the agent and kills it if it has not exited within `timeoutMs` (5 seconds by default) of the call. Messages the agent sends after that are not delivered. - `agent.wait()` resolves with the exit code once the agent exits. -- Closing the connection with `agent.connection.close()` stops the agent the same way. +- Closing the connection with `agent.connection.close()` also stops the agent, in the background. Use `agent.stop()` to wait for the agent to stop and to see kill errors. - If the agent exits with an error, the connection fails with the exit code and the end of the agent's stderr. Pass `onStderr` to receive its logs. - The agent runs as long as the sandbox does, so set the sandbox timeout for the longest task you expect. diff --git a/packages/acp-js/src/index.ts b/packages/acp-js/src/index.ts index 09b790d5a9..468b7ef328 100644 --- a/packages/acp-js/src/index.ts +++ b/packages/acp-js/src/index.ts @@ -29,7 +29,7 @@ export class AcpAgent { private readonly transport: AcpTransport, /** * Connection to the agent. Use `connection.agent` to initialize, create - * sessions and send prompts. + * sessions and send prompts. Closing it also stops the agent. */ readonly connection: ClientConnection ) {} @@ -72,8 +72,14 @@ export class AcpAgent { * await agent.connection.agent.request(methods.agent.initialize, { * protocolVersion: PROTOCOL_VERSION, * }) - * const session = await agent.connection.agent.buildSession('/home/user').start() - * await session.prompt('Create hello.py and run it.') + * const { sessionId } = await agent.connection.agent.request(methods.agent.session.new, { + * cwd: '/home/user', + * mcpServers: [], + * }) + * await agent.connection.agent.request(methods.agent.session.prompt, { + * sessionId, + * prompt: [{ type: 'text', text: 'Create hello.py and run it.' }], + * }) * await agent.stop() * ``` */