Skip to content

feat(sandbox): add an E2B sandbox backend - #458

Merged
jlav merged 6 commits into
jl/sandbox-docker-ownershipfrom
jl/sandbox-e2b-provider
Sep 25, 2026
Merged

jlav merged 6 commits into
jl/sandbox-docker-ownershipfrom
jl/sandbox-e2b-provider

Conversation

@jlav

@jlav jlav commented Sep 22, 2026 •

Copy link
Copy Markdown
Member

HUMAN:

  • A human has tested these changes.

AGENT:


Why

Runtime-api is the only production sandbox backend today. This adds E2B as a second one, so a deployment can run sandboxes as Firecracker microVMs without running its own control plane.

E2B starts a sandbox by resuming a template snapshot. The agent server is already running at that point, so its environment is fixed when the template is built. The app server cannot inject a per-sandbox session key the way docker does.

The agent server's deferred-init mode handles this. The template boots the agent server dormant, holding a static OH_SECRET_KEY. Every /api/* route returns 503 until the server is claimed. The app server makes a random session key for each sandbox and sends it to POST /api/init, authenticated with the static key.

Summary

  • E2BSandboxService and E2BSandboxSpecService, selected with RUNTIME=e2b. A spec id is an E2B template name, so the sandbox-spec dropdown lists templates with no frontend change.
  • Ownership, spec and session key live in the shared sandbox table from feat(sandbox): reuse v1_remote_sandbox for every sandbox backend #466 (v1_remote_sandbox, with backend e2b). The key is stored encrypted, because E2B cannot return it after create. A row whose sandbox E2B no longer has reports MISSING, so its conversation shows as archived. start_sandbox refuses a caller without a user id.
  • scripts/e2b/build_template.py builds the template from the published agent-server image, so create() resumes a snapshot of a server that is already listening.

Configuration

Build the template once:

export E2B_API_KEY=e2b_... E2B_DOMAIN=e2b.app
# export E2B_API_URL=https://api.e2b.example.com   # self hosted clusters only
uv run scripts/e2b/build_template.py --name openhands-agent-server --cpu-count 2 --memory-mb 2048

It prints E2B_INIT_API_KEY=... at the end. On a self hosted cluster, a build can fail part way with internal error occurred. An identical re-run usually succeeds.

Then set these on the app server:

# Provider: E2B
OH_SANDBOX_KIND=openhands.app_server.sandbox.e2b_sandbox_service.E2BSandboxServiceInjector
E2B_API_KEY=e2b_...
E2B_DOMAIN=e2b.app
# Self hosted clusters only. Defaults to https://api.<E2B_DOMAIN>.
# E2B_API_URL=https://api.e2b.example.com
# Seconds a sandbox runs after its last create or resume. Then it pauses.
OH_SANDBOX_TIMEOUT_SECONDS=3600
# Running sandboxes per user. Starting one more pauses the oldest.
OH_SANDBOX_MAX_NUM_SANDBOXES=10
# The app's public URL. Agent servers post events here, so E2B must reach it.
OH_WEB_URL=https://openhands.example.com

# Template: the one built above
OH_SANDBOX_SPEC_KIND=openhands.app_server.sandbox.e2b_sandbox_spec_service.E2BSandboxSpecServiceInjector
E2B_TEMPLATE=openhands-agent-server
# Printed at the end of build_template.py.
E2B_INIT_API_KEY=...

Set both KIND lines. With only OH_SANDBOX_KIND, the app falls back to docker templates. RUNTIME=e2b, which the build script prints, selects both, but it ignores the OH_SANDBOX_* settings.

Issue Number

None.

How to Test

uv run pytest tests/unit/app_server/test_e2b_sandbox_service.py \
  tests/unit/app_server/test_e2b_sandbox_spec_service.py

TestUserScoping covers cross-user access. test_e2b_metadata_cannot_override_the_row shows that E2B metadata naming another owner does not change the owner.

For a live check against an E2B cluster, build the template and set the configuration above. Run make local-db, start the app server and start a conversation. Then:

  1. SELECT id, created_by_user_id FROM v1_remote_sandbox WHERE backend = 'e2b'; shows the row.
  2. Kill the sandbox in E2B. The conversation shows as archived.

Video/Screenshots

N/A, backend only.

Type

  • Bug fix
  • Feature
  • Refactor
  • Breaking change
  • Docs / chore

Notes

e2b==2.46.0 is a new dependency.

Metadata. Sandboxes carry oh_managed, oh_user_id and oh_spec_id metadata. It tags managed sandboxes so that one with no row can be found. The row decides ownership, not the metadata. Search asks E2B only for sandboxes with oh_managed and the caller's oh_user_id, so the list stays small. ADMIN lists without the owner filter.

Session keys. Each sandbox gets its own random key. The key is kept on pause. auto_resume can wake a sandbox on the next request without calling resume_sandbox, so the key must keep working. delete_sandbox removes the row, and the key with it.

Errors are reported, not hidden. An E2B AuthenticationException is reported as a SandboxError naming E2B_API_KEY. It is the one E2B error outside the SandboxException hierarchy, so it would otherwise escape every handler. A failed list raises instead of returning an empty page, which would tell pause_old_sandboxes the limit is clear. Deleting a sandbox that E2B already removed succeeds and removes the row. If the row fails to write after create, the sandbox is killed.

VSCode URL. The VSCode link is built from the session key, as docker's is. That needs an agent server that switches VSCode to the session key on POST /api/init, which is OpenHands/software-agent-sdk#5282. build_template.py defaults to the image for the installed SDK (1.49.4), which doesn't have it yet, so the link answers 403 until the SDK pin includes that fix.

Limitations. Both are documented in openhands/app_server/sandbox/README.md. Both come down to headless flows not being fully supported yet.

  • OH_WEB_URL must be publicly reachable. The agent server posts events back over a webhook, and this backend has no polling fallback. A sandbox started against a localhost app server runs, but its events never arrive.
  • A sandbox holds a one-hour lease by default. That is timeout_seconds, not an E2B limit. The plan sets the ceiling (one hour on Hobby, 24 hours on Pro), or the operator does on a self hosted cluster. On expiry the sandbox pauses. An interactive session wakes it on the browser's next request. A headless run has no such request, so it can stall when the lease expires. The follow-up fix is to renew the lease when the sandbox delivers a webhook.

Enterprise server image for this PR:

ghcr.io/openhands/enterprise-server:sha-2aea137

@jlav
jlav added this pull request to stack #459 September 22, 2026 12:24
@jlav jlav changed the title jl/sandbox e2b provider feat(sandbox): add an E2B sandbox backend Sep 22, 2026
@github-actions github-actions Bot added the type: feat A new feature label Sep 22, 2026
@jlav
jlav force-pushed the jl/sandbox-e2b-provider branch from 3204aa5 to 39acb25 Compare September 22, 2026 16:03
@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Coverage report

Click to see where and how coverage changed

FileStatementsMissingCoverageCoverage
(new stmts)
Lines missing
  openhands/app_server
  config.py 338, 340, 390, 392, 394
  openhands/app_server/sandbox
  e2b_sandbox_service.py 84, 186-189, 237, 515-516, 540-541, 602, 679, 702-704, 802-817
  e2b_sandbox_spec_service.py
Project Total  

This report was generated by python-coverage-comment-action

@jlav
jlav force-pushed the jl/sandbox-e2b-provider branch from 39acb25 to e4d4a07 Compare September 22, 2026 19:23
@jlav
jlav removed this pull request from stack #459 September 22, 2026 21:03
@jlav
jlav added this pull request to stack #471 September 22, 2026 21:03
@jlav
jlav force-pushed the jl/sandbox-e2b-provider branch from e4d4a07 to 50fc09a Compare September 22, 2026 21:03
@jlav
jlav removed this pull request from stack #471 September 22, 2026 21:07
@jlav
jlav added this pull request to stack #472 September 22, 2026 21:08
@jlav
jlav force-pushed the jl/sandbox-e2b-provider branch 2 times, most recently from 5a6bded to 45e04fb Compare September 23, 2026 11:48
@jlav
jlav force-pushed the jl/sandbox-e2b-provider branch from 45e04fb to 02ec42a Compare September 23, 2026 11:52
@jlav jlav mentioned this pull request Sep 23, 2026
1 of 6 tasks
@jlav
jlav marked this pull request as ready for review September 23, 2026 23:56
@jlav
jlav force-pushed the jl/sandbox-e2b-provider branch from c99e89d to ef5366d Compare September 24, 2026 11:31

@aivong-openhands aivong-openhands 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.

Review summary

Taste: 🟢 good. This is a clean, well-scoped new backend that closely mirrors the existing docker/remote sandbox services, keeps ownership authoritative in the DB row (not E2B metadata), rotates a per-sandbox session key, stores it encrypted at rest, and enforces user scoping at every mutating boundary. Tests are behavior-driven and thorough. CI is green on the current head. No material findings — approving.

Verification performed

  • ✅ Template & triage: Why / Summary / Issue Number (None) / How to Test / Video (N/A) / Type all present and filled. Not a draft.
  • ✅ CI on current head ef5366d: all checks pass (Python tests, lint, build amd64/arm64, coverage 92.69% on new lines).
  • ✅ Supply chain (e2b==2.46.0, new dependency → full scrutiny): published 2026-08-25 (~30 days, well past the 7-day window), not yanked, established SDK, requires_python >=3.10. No install hooks of concern. Clears the- ✅ Supply chain (e2b==2.46.0, new dependency .add/flush on create- ✅ **Supply chain** (e2b==2.46.0, new dependency → full scrutiny): published 2026-08-25 (~30 days, well past the 7-day window), not yanked, established SDK, requires_python >=3.10. No install hooks of concern. Clears the- ✅ **Supply chain** (e2b==2.46.0, new dependency .add/flush on create- ✅ Supply chain (e2b==2.46.0, new dependency → full scrutiny): published 2026-08-25 (~30 days, well past the 7 calls require_user_id before pause_old_sandboxes (which would reach all users as ADMIN).
    • Create → flush row → /api/init handshake; any BaseException - Create → flush row → /api/init handshake; any BaseException - Create → flush row → /api/init handshake (the - Create → flush row → /api/init handshake; any BaseException - Create → flush row → /api/init handshake; any BaseException - Create → flush row → /api/init handshake (the - Create → flush row → /api/init handshake; any BaseException - Create → flush row → /api/init handshake; any BaseException - Create → flush row → /api/init handshake (the - Create → flush row → /api/init handshake; any BaseException - Create → flush row → /api/init handshake; any BaseException - Create → flush row → /api/init handshake (the - Create → flush row → /api/init handshake; any BaseException - Create → flush row → /api/init handshake; any BaseException - Create → flush row → /api/init handshake (the - Create → flush rercised by CI: the template build, the webhook event callback path (no polling fallback; requires a publicly reachable OH_WEB_URL), and the VSCode link (answers 403 until the SDK pin includes OpenHands/software-agent-sdk#5282). These are documented as known limitations in sandbox/README.md, but a live smoke test before enabling RUNTIME=e2b in any environment would be worthwhile.
  • Both OH_SANDBOX_KIND and OH_SANDBOX_SPEC_KIND must be set together; with only the former the app silently falls back to docker templates. Already called out in the PR body — no change requested.

[RISK ASSESSMENT]

⚠️ Risk Assessment: 🟡 MEDIUM
The change introduces a new external-service integration and handles secrets (init key, per-sandbox session keys, encryption at rest), which are sensitive areas. Risk is contained because the backend is purely additive and gated behind RUNTIME=e2b / the E2B injectors, and it faithfully reuses the established docker/remote ownership, scoping, and lifecycle patterns with stroThe change introduces a new external-service integration and handles secrets (init key, per-sandbox session keys, encryption at rest), which are sensitive areas. Risk is contained because the backend is purely additive and gated behind RUNTIME=e2b / the E2B injectors, and it faithfully reuses the established docker/remote ownership, scoping, and lifecycle patterns with stroTheThe change introduces a new external-service integration and handles secrets (init key, per-sandbox session keys, encryption at rest), which are sensitive areas. Risk is contained because the backend i hold and keeps the secuThe change introduces a new externalImprove this review? If feedback seems incorrect or irrelevant, update the repository's .agents/skills/custom-codereview-guide.md (with the /codereview trigger), then re-request review. The reviewer reads the guide from the PR head.

Resolve with AI? Install the iterate skill and run /iterate.

Was this review helpful? React with 👍 or 👎.


This review was generated by an AI agent (OpenHands) on behalf of the requesting user.

@aivong-openhands

Copy link
Copy Markdown
Contributor

Mutation review of the E2B tests

I hand-wrote a set of mutants from the diff and ran them against the two touched
suites (test_e2b_sandbox_service.py, 76 tests green baseline;
test_e2b_sandbox_spec_service.py, 8 tests green). This is a test-strength
review only — not correctness or design. The suites are strong; below are a few
places where a real regression would slip through green.

Controls — these all died (the suite works)

Mutant Result
lifecycle on_timeout: 'pause' → 'kill' ❌ caught
_get_info swallows AuthenticationException as a missing sandbox (return None) ❌ caught
secret_key no longer rotated per sandbox (constant) ❌ caught

What makes these land: test_created_with_pause_on_timeout asserts the whole
lifecycle dict rather than one key; TestAuthenticationFailures drives a
rejected key through every call site and matches on E2B_API_KEY; and
test_secret_key_is_rotated_per_sandbox compares two real bodies and asserts the
init key never appears in either. That is the right shape of assertion.

Survivors — these mutants passed the suite

Mutant Result
M1 — _init_api_key drops or None (empty-string key treated as configured) ✅ 76 passed
M2 — _build_init_request drops working_dir.rstrip('/') ✅ 76 passed
M3 — _build_init_request top-level guard ('', '/') → ('',) ✅ 76 passed
M4 — _to_sandbox_info drops and session_api_key from the RUNNING guard ✅ 76 passed

M1 — an empty init key is not treated as "missing" (e2b_sandbox_service.py:91)

def _init_api_key(sandbox_spec: E2BSandboxSpecInfo) -> str | None:
    if sandbox_spec.init_api_key is None:
        return None
    return sandbox_spec.init_api_key.get_secret_value() or None  # ← the `or None`

test_a_missing_init_key_fails_before_creating_a_sandbox parametrizes
init_api_key=[None, ''], but the _service helper coerces '' to None
(SecretStr(init_api_key) if init_api_key else None), so the spec it builds has
init_api_key=None in both cases. The or None — the line that turns a
present-but-empty SecretStr('') into "unset" — is never exercised. In
production this is the config foot-gun the pre-create guard exists to stop:
OH_SANDBOX_SPEC_SPECS_0_INIT_API_KEY="" would sail past the guard and fail
later as an opaque 401 from the agent server instead of the actionable
MISSING_INIT_API_KEY error.

A test that builds the spec directly kills it:

async def test_an_empty_init_key_is_treated_as_missing(self, sdk, db_session):
    spec = E2BSandboxSpecInfo(
        id=TEMPLATE, command=None,
        working_dir='/workspace/project', init_api_key=SecretStr(''),
    )
    service = _service(db_session)
    service.sandbox_spec_service = PresetSandboxSpecService(specs=[spec])

    with pytest.raises(SandboxError, match='E2B_INIT_API_KEY'):
        await service.start_sandbox()
    sdk.create.assert_not_awaited()

Verified: passes on this branch, fails with the or None removed.

M2 / M3 — /api/init path derivation is only tested for /workspace/project (e2b_sandbox_service.py:599-602)

working_dir = sandbox_spec.working_dir.rstrip('/')   # M2 removes .rstrip('/')
workspace_dir = dirname(working_dir)
if workspace_dir in ('', '/'):                        # M3 drops the '/'
    workspace_dir = working_dir

Every init-body test uses working_dir='/workspace/project', for which the
rstrip is a no-op and the top-level guard never fires. So two normalizations
go unasserted:

  • a trailing slash ('/workspace/project/') — without rstrip, dirname
    keeps /workspace/project, so conversations_path becomes
    /workspace/project/conversations instead of /workspace/conversations.
  • a single-segment dir ('/workspace') — dirname returns '/', and without
    the guard conversations_path becomes //conversations.

Both feed conversations_path, bash_events_dir and conversation_worktree_root
that the agent server writes to, so a mis-derived path is a real misconfiguration.
One parametrized test covers both:

@pytest.mark.parametrize('working_dir', ['/workspace/project/', '/workspace'])
async def test_workspace_paths_are_derived_from_the_working_dir(
    self, sdk, db_session, working_dir
):
    spec = E2BSandboxSpecInfo(
        id=TEMPLATE, command=None,
        working_dir=working_dir, init_api_key=SecretStr(INIT_API_KEY),
    )
    service = _service(db_session)
    service.sandbox_spec_service = PresetSandboxSpecService(specs=[spec])
    agent_server = FakeAgentServer()
    service.httpx_client = agent_server

    await service.start_sandbox()

    body = agent_server.init_post_bodies[0]
    assert body['conversations_path'] == '/workspace/conversations'
    assert body['bash_events_dir'] == '/workspace/bash_events'

Verified: passes on this branch, fails with either M2 or M3 applied.

M4 — a RUNNING row with no stored key still exposes URLs (e2b_sandbox_service.py:297)

if status == SandboxStatus.RUNNING and session_api_key:   # M4 drops `and session_api_key`
    ...
else:
    session_api_key = None

Every RUNNING case in the suite carries a session key, so the and session_api_key
half of the guard is never the thing under test. With it removed, a RUNNING row
whose key is absent builds the exposed URLs anyway — including a VSCode URL of
...?tkn=None.... A test with the key nulled pins it:

async def test_a_running_row_without_a_key_exposes_no_urls(
    self, sdk, db_session, store
):
    await store(_stored(session_api_key=None))
    sdk.get_info.return_value = _e2b_info(state=SandboxState.RUNNING)

    sandbox = await _service(db_session).get_sandbox(SANDBOX_ID)

    assert sandbox is not None
    assert sandbox.exposed_urls is None

Verified: passes on this branch, fails with the guard weakened.

Not a test gap

  • STATUS_MAPPING.get(info.state, SandboxStatus.ERROR) (line 293) — I mutated
    the ERROR default to RUNNING and it survived, but e2b.SandboxState only
    has RUNNING and PAUSED, both mapped, so the default is unreachable. I think
    this is an equivalent mutant / defensive default, not a missing test.
  • if item.sandbox_id in wanted_ids in _live_infos (line 221) — replacing
    the filter with if True survived. Results are indexed by row id and only
    wanted ids are ever read back, so the filter is a narrowing optimization with
    no observable effect to assert. I believe this is equivalent too.
  • config.py RUNTIME == 'e2b' wiring (lines 337, 391) — the two branches
    selecting E2BSandboxServiceInjector / E2BSandboxSpecServiceInjector have no
    test in this PR. Not a mutation of the added tests, just adjacent code the added
    tests don't reach — flagging in case it's wanted.

This comment was generated by an AI assistant on behalf of the user.

@jlav
jlav force-pushed the jl/sandbox-e2b-provider branch from ef5366d to 2aea137 Compare September 25, 2026 16:18
@jlav
jlav merged commit 25d3526 into main Sep 25, 2026
24 of 34 checks passed
@jlav
jlav deleted the jl/sandbox-e2b-provider branch September 25, 2026 18:47
@openhands-release-bot openhands-release-bot Bot added the released: 1.66.0 Shipped in 1.66.0 label Sep 28, 2026
@openhands-release-bot

Copy link
Copy Markdown
Contributor

🚀 Released in 1.66.0.

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

Labels

released: 1.66.0 Shipped in 1.66.0 type: feat A new feature

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants