Skip to content

feat: add @e2b/acp for running ACP agents in a sandbox - #2025

Closed
max-sudolabs wants to merge 21 commits into
e2b-dev:mainfrom
max-sudolabs:int-155-acp-js
Closed

max-sudolabs wants to merge 21 commits into
e2b-dev:mainfrom
max-sudolabs:int-155-acp-js

Conversation

@max-sudolabs

@max-sudolabs max-sudolabs commented Oct 23, 2026 •

Copy link
Copy Markdown

Summary

Adds @e2b/acp (packages/acp-js) for running Agent Client Protocol agents (goose, OpenCode, Claude Code through its ACP adapter) inside an E2B sandbox and driving them with the official TypeScript ACP client, @agentclientprotocol/sdk (peer dependency).

AcpAgent.start(sandbox, command, client, opts?) starts the agent with stdin open, speaks newline-delimited JSON-RPC over its stdin/stdout, connects your client() app from the ACP SDK to it, and returns an AcpAgent with the ClientConnection plus pid, wait(), stop() and kill(). The client filesystem and terminal capabilities are turned off in initialize, and agent requests for fs/* / terminal/* are rejected in the transport, so the agent edits files and runs commands with its own tools inside the sandbox. Network access and MCP servers passed to the agent are not restricted.

The agent is started with commands.run(cmd, { background: true, retainOutput: false }) from #2044 (SDK-407), so a long-running agent does not grow the handle's buffers. No core changes. SDK-408 tracks dropping retained output for background commands in the next major.

Split out of #1992 together with the Python package (#2026). The two PRs are independent and can merge in either order.

Usage

import { Sandbox } from 'e2b'
import { AcpAgent } from '@e2b/acp'
import { client, methods, PROTOCOL_VERSION } from '@agentclientprotocol/sdk'

const app = client()
  .onRequest(methods.client.session.requestPermission, async ({ params }) => ({
    outcome: { outcome: 'selected', optionId: params.options[0].optionId },
  }))
  .onNotification(methods.client.session.update, ({ params }) => console.log(params.update))

const sandbox = await Sandbox.create('goose')
const agent = await AcpAgent.start(sandbox, 'goose acp --with-builtin developer', app, { envs })
try {
  await agent.connection.agent.request(methods.agent.initialize, { protocolVersion: PROTOCOL_VERSION })
  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: 'Write fizzbuzz.py and run it' }],
  })
} finally {
  await agent.stop()
  await sandbox.kill()
}

Review guide

Worth reading (~686 lines):

File Lines What it holds
packages/acp-js/src/transport.ts 389 Process start, line framing, read queue, write queue, stop and kill
packages/acp-js/src/utils.ts 154 Incremental line splitter with size limit, initialize rewrite, fs/* / terminal/* rejection
packages/acp-js/src/index.ts 143 Public AcpAgent and AcpAgent.start

Skim: .github/workflows/acp_js_tests.yml and the ACP JS entries in sdk_tests.yml / release.yml (mirror code-interpreter), package.json, README, .changeset/acp-js-package.md.

For automated reviewers

Behaviour the code is meant to guarantee, and the test that fails if it breaks. Offline tests use a fake command handle and need no API key; live tests start a scripted Python agent in a sandbox.

Guarantee Code Test
Agent cannot use the client's filesystem or terminal: initialize forces them off (also inside a batch), agent fs/* / terminal/* requests get -32601, notifications are dropped restrictClientCapabilities, filterAgentMessage utils.test.ts; end to end in transport.test.ts
Client writes, automatic replies to the agent and EOF never interleave on stdin enqueueWrite offline: writes and replies never overlap
stop(timeoutMs): EOF and exit are bounded by timeoutMs from the call; then kill unless an exit status is known; rejects if the kill fails AcpTransport.close offline: close … tests
kill() releases output waiting on a full read queue, so the handle sees the exit and wait() resolves AcpTransport.kill offline: kill lets wait() see the exit
After stop(), messages already queued stay readable and later ones are dropped, including later lines of a chunk that was waiting on backpressure closing checks in onStdout and deliver offline: keeps buffered messages / drops later lines of a chunk
Read queue is bounded at 64 messages CountQueuingStrategy + resumeReading offline: full queue
One message is capped at 32 MiB by bytes, and the limit is validated; a long message is joined once, not rescanned per chunk LineSplitter utils tests (incl. 8 MiB in 1 KiB chunks); live: over the limit
A failed exit errors the stream with the exit code and the stderr tail exit handler live: agent failure
A failed output stream (aborted signal, throwing onStderr) kills the agent and keeps the original error exit handler offline: a failed output stream kills the agent
A throwing onConnect handler does not leak the started process; an oversized message that arrives before run() returns still stops the agent AcpAgent.start, closeTransport offline: start kills the agent when an onConnect handler throws / output over the limit before start returns
Closing the ClientConnection stops the agent readable cancel / writable abort → closeTransport offline: closing the connection stops the agent

Out of scope: restricting network or MCP, batch semantics beyond the boundary check, reconnecting to a running agent.

# offline, no key
pnpm --filter @e2b/acp exec vitest run tests/utils.test.ts tests/transport.offline.test.ts
# full, needs E2B_API_KEY
pnpm --filter @e2b/acp test

Testing

  • @e2b/acp: 35 tests (unit, offline, live), typecheck and lint on this branch rebased on current main (includes feat(sdk): add retainOutput option to command handles #2044).
  • Offline guards are checked by breaking each guard and confirming a test fails.
  • After moving to AcpAgent.start and client(): goose with gpt-5.6-luna ran the README example end to end (file written, exit 0).
  • Real agents through the package before that change: goose 1.54.0, OpenCode 1.19.0 and Claude Code (claude-agent-acp 0.90.0) wrote and ran a file in the sandbox. The client received no fs/* or terminal/* calls, and the agent process was gone after it was stopped.

Release notes for maintainers

  • @e2b/acp depends on e2b through workspace:^, so it requires the core version released with it; it needs retainOutput from feat(sdk): add retainOutput option to command handles #2044, which is not released yet.
  • npm @e2b/acp needs trusted publishing configured before the first release.
  • Not wired yet: pkg_artifacts.yml preview builds (an ACP preview needs the matching preview core) and staging jobs in sdk_tests.yml. Happy to add either if you want them.

@changeset-bot

changeset-bot Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 336eea0

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@e2b/acp Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 23, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-26T09:19:56.841534Z df373e1 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TASTE.md review of @e2b/acp, covering the new packages/acp-js package and the startStreaming / retainOutput changes in packages/js-sdk.

Rules checked: T-1/T-2 parity, T-3 golden rule, T-4 factories, T-9 verbs, T-14 enums over booleans, T-19/T-20 data types, T-22/T-23 option and result naming, T-24 overloads, T-26/T-27/T-30 handles, callbacks and stop(), T-44–T-47 timeouts and defaults, T-54 entry point, T-57–T-63 errors, T-69/T-70/T-72 docs.

Violations: 5 inline, plus the following, which isn't tied to a single line:

  • T-1 (parity): @e2b/acp is JS-only. The PR has no Python package and no change under packages/python-sdk for either the ACP entry point or the new streaming mode for background commands. If the Python port is planned as a follow-up (the branch is int-155-acp-js), please link the tracking issue in the description so the new names and options can be ported 1:1.

Already compliant: the required arguments are positional with a trailing named AcpStartOpts, which extends the shared command options (T-3, T-10, T-22). The constructors are @hidden/@internal (T-26). onStderr is awaited, so a slow consumer applies backpressure (T-27). The handle uses stop() rather than close() (T-30). DEFAULT_STOP_TIMEOUT_MS is a named constant documented with @default (T-47). Validating maxMessageBytes before anything starts throws InvalidArgumentError (T-59). The value and type exports in index.ts are kept separate (T-54).

Written by Devin

Comment thread packages/acp-js/src/transport.ts Resolved
Comment thread packages/acp-js/src/transport.ts Resolved
Comment thread packages/acp-js/src/utils.ts Resolved
Comment thread packages/acp-js/src/index.ts Resolved

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 potential issues.

Devin Review

Comment thread packages/acp-js/src/transport.ts Resolved
@max-sudolabs

Copy link
Copy Markdown
Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Swish!

Reviewed commit: df373e13b3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

max-sudolabs and others added 16 commits October 28, 2026 17:07
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.
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.
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.
Run the @e2b/acp suite when the package or the JS SDK changes, and
include it in the release preflight, tests and publish.
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.
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.
…) 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.
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.
…ze 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.
e2b-dev#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 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

@OndrejDrapalik OndrejDrapalik left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the split, @max-sudolabs. I built both PRs and ran the full suites against prod: @e2b/acp 33/33, e2b-acp 34/34, plus lint, typecheck and build. Moving onto the public retainOutput from #2044 is the right call.

I answered the naming questions in their threads and left the rest inline, following TASTE.md in e2b-dev/sdk-harness. The one blocker is the release.yml argument. On my side, I'll set up trusted publishing for @e2b/acp on npm and e2b-acp on PyPI. Whichever PR lands second needs a quick rebase, because both touch the same lines in sdk_tests.yml and pnpm-lock.yaml.

Comment thread packages/acp-js/src/transport.ts Resolved
Comment thread packages/acp-js/src/transport.ts Resolved
Comment thread .github/workflows/release.yml Resolved
Comment thread packages/acp-js/src/index.ts Resolved
Replace connectAcpAgent() with the static AcpAgent.start(), rename
AcpStartOpts/AcpStopOpts to AcpAgentStartOpts/AcpAgentStopOpts and export
the StderrHandler type.
…ntSideConnection

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.
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.

@OndrejDrapalik OndrejDrapalik left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks Max, all review points are addressed. Verified 336eea0 locally against prod: typecheck, lint and build pass, tests 35/35. The ClientApp move is the right call, and JS with no separate factory is fine since client() builds handlers per connection.

@OndrejDrapalik

Copy link
Copy Markdown

Moved to #2060 with the same branch and commits, so CI can run with repo secrets (GitHub does not pass them to PRs from forks, so the live tests here failed with an empty E2B_API_KEY). Your commits and authorship are kept, and the squash commit credits you. Thanks Max!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants