diff --git a/api/oss/src/utils/env.py b/api/oss/src/utils/env.py index 121f0a2ce4..8b4ded8f3b 100644 --- a/api/oss/src/utils/env.py +++ b/api/oss/src/utils/env.py @@ -561,6 +561,18 @@ class DaytonaConfig(BaseModel): model_config = ConfigDict(extra="ignore") +# --------------------------------------------------------------------------- +# e2b +# --------------------------------------------------------------------------- + + +class E2BConfig(BaseModel): + api_key: str | None = os.getenv("E2B_API_KEY") + template: str | None = os.getenv("E2B_TEMPLATE") + + model_config = ConfigDict(extra="ignore") + + # --------------------------------------------------------------------------- # docker # --------------------------------------------------------------------------- @@ -1239,6 +1251,7 @@ class EnvironSettings(BaseModel): crisp: CrispConfig = CrispConfig() daytona: DaytonaConfig = DaytonaConfig() docker: DockerConfig = DockerConfig() + e2b: E2BConfig = E2BConfig() identity: IdentityConfig = IdentityConfig() llm: LLMConfig = LLMConfig() loops: LoopsConfig = LoopsConfig() diff --git a/docs/design/agent-workflows/documentation/running-the-agent.md b/docs/design/agent-workflows/documentation/running-the-agent.md index 81db96622b..ae2988edff 100644 --- a/docs/design/agent-workflows/documentation/running-the-agent.md +++ b/docs/design/agent-workflows/documentation/running-the-agent.md @@ -166,7 +166,7 @@ These are the agent-relevant variables. The example file lists them commented ou `http://sandbox-agent:8765`. When unset, the Python service spawns the runner CLI locally instead (see `runner_url` and `select_backend` in `services/oss/src/agent/`). - `AGENTA_AGENT_ENABLE_MCP`. Gates MCP server resolution. Default `false`. -- `SANDBOX_AGENT_PROVIDER`. `local` or `daytona`. Default `local`. +- `SANDBOX_AGENT_PROVIDER`. `local`, `daytona`, or `e2b`. Default `local`. - `SANDBOX_AGENT_DAYTONA_API_KEY`, `_API_URL`, `_TARGET`, `_SNAPSHOT`, `_IMAGE`, `_INSTALL_PI`. Daytona credentials the runner reads for the `daytona` sandbox provider. - `SANDBOX_AGENT_DAYTONA_AUTOSTOP_MINUTES`. Idle minutes before Daytona auto-stops a sandbox. @@ -174,6 +174,13 @@ These are the agent-relevant variables. The example file lists them commented ou this non-zero auto-stop so a sandbox the runner leaks (a process KILL skips the per-run teardown) self-reaps instead of burning credit. Values below `1` fall back to the default (a `0` would re-disable auto-stop and reintroduce the leak). +- `E2B_API_KEY`. E2B API key (required for `sandbox="e2b"`). Also exposed as `env.e2b.api_key`. +- `E2B_TEMPLATE`. E2B template name. Default `agenta-sandbox-agent`. Build with + `npx @e2b/cli template create agenta-sandbox-agent -d sandbox-images/e2b/e2b.Dockerfile`. +- `E2B_TIMEOUT_MS`. E2B sandbox timeout in milliseconds. Default `1800000` (30 min). Leak + backstop: E2B auto-kills a sandbox at its timeout so a process-KILL-leaked sandbox self-reaps. + A restricted `network` policy on E2B is refused (the `sandbox-agent/e2b` provider exposes no + egress control); use `daytona` for enforced network boundaries. The `sandbox-agent` container deliberately has no `env_file`. The harness sandbox must not inherit the stack's secrets. The compose block comments explain this diff --git a/docs/design/agent-workflows/projects/add-sandbox-e2b/research.md b/docs/design/agent-workflows/projects/add-sandbox-e2b/research.md new file mode 100644 index 0000000000..fb79e86dc2 --- /dev/null +++ b/docs/design/agent-workflows/projects/add-sandbox-e2b/research.md @@ -0,0 +1,102 @@ +# Add the E2B sandbox (running Pi) — investigation + +## Goal (this worktree only) + +Make `sandbox="e2b"` a selectable sandbox provider, proven by running the **Pi** harness on +it. One new variable: the sandbox. The harness is held constant at Pi — the most-supported +harness, which already owns all the remote-sandbox asset-prep code (it is the Daytona +reference path). Codex/opencode/Claude on E2B are out of scope (later matrix-fill); they need +the non-Pi remote-bootstrap generalization we are deferring. + +## The seam (sandbox axis) + +Sandbox selection is a thin provider switch in the Node runner — there is no provider class +hierarchy, just a pattern-match. `sandbox` is a loose string on the wire (no Python enum), so +extending the set is largely runner-side + env config. + +``` +buildRunPlan: sandboxId = request.sandbox || SANDBOX_AGENT_PROVIDER || "local" (run-plan.ts:153) + isDaytona = sandboxId === "daytona" (run-plan.ts:179) +buildSandboxProvider(sandboxId, env, binary, piExtEnv, secrets, perm): (provider.ts) + if (sandboxId === "daytona") return daytona({...}) + return local({ env, binaryPath, log }) ← fallback +``` + +## The big finding: `sandbox-agent` already exports an e2b provider + +`sandbox-agent@0.4.2` ships `sandbox-agent/e2b` (alongside `local`, `daytona`, docker, vercel, +cloudflare, modal, computesdk, sprites). So the provider runtime exists — E2B is **wiring an +existing export**, not building a provider. The rivet daemon (which carries the harnesses and +the `ensure_installed` auto-install) runs inside the E2B sandbox the same way it does on +Daytona; only the provisioning/lifecycle API differs. + +## What is Daytona-shaped today and needs an E2B sibling + +The remote-sandbox path has three Daytona-specific pieces. For **Pi on E2B** we replicate the +Pi-relevant ones; we do NOT need to generalize non-Pi bootstrap (that is the deferred matrix-fill). + +| Piece | Daytona today | E2B action (Pi-only) | +|---|---|---| +| Provider construction | `daytona({ image, create })` w/ `buildDaytonaCreate` (snapshot, autostop, ephemeral, network fields, envVars) | `e2b({...})` from `sandbox-agent/e2b` with the equivalent create/env + network policy | +| cwd | `defaultDaytonaCwd()` `/home/sandbox/agenta-` (run-plan.ts:138) | `defaultE2bCwd()` (E2B's user home) | +| Asset-prep (Pi) | `prepareDaytonaPiAssets` (daytona.ts): install `pi`, upload auth/extension/skills/system-prompt | E2B sibling using E2B's fs/process API; reuse `pi-assets.ts` uploaders which take a `sandbox` handle | +| Auth transport | `createCookieFetch` (Daytona preview-proxy cookie) | plain `createAcpFetch` — E2B uses an `E2B_API_KEY` + a per-sandbox host, no preview-proxy cookie jar; the PoC connected with `SandboxAgent.connect({baseUrl})` and no cookie. Confirm at impl. | +| Network policy | `daytonaNetworkFields` → `networkBlockAll`/`networkAllowList` | **E2B exposes NO egress block/allow in the `sandbox-agent/e2b` wrapper** (PoC: E2B egress is open by default; it relied on that for its tunnel). → refuse restricted-network E2B under strict, the way local is gated. | +| Image/snapshot | `rivetdev/sandbox-agent:-full` + baked `pi` (`build_snapshot.py`) | a **baked E2B template** (daemon + pi), via `E2BProviderOptions.template`. The PoC built one named `agenta-sandbox-agent`. Do what Daytona does. | + +## What is held constant (Pi) + +- Pi's local + Daytona asset-prep, extension, usage file, system-prompt handling, skills + materialization — all already exist and are Pi-shaped. We point them at the E2B handle. +- Tool delivery: Pi uses its native extension + the file relay; the relay already works on + remote (`sandboxRelayHost`). Reuse as-is. +- Tracing: Pi self-instruments via the extension under the propagated traceparent (works + remote on Daytona today; same on E2B). + +## The `sandbox-agent/e2b` provider surface (verified from the installed types) + +`e2b(options)` accepts: `create` (passthrough to E2B `SandboxBetaCreateOpts`), `connect`, +`template` (string name or resolver), `agentPort`, `timeoutMs`, `autoPause`. So template +selection, the agent port, and the lifecycle timeout are all first-class — no `as any` +needed (unlike Daytona's create-field cast). + +## Resolved (Phase 0 confirmed — `sandbox-agent/e2b` types + the green PoC matrix) + +The PoC ran **Pi (and all 4 harnesses) green on E2B** (template `agenta-sandbox-agent`). + +1. **Auth/connection — `E2B_API_KEY` + plain `createAcpFetch`** (no preview-proxy cookie). E2B + gives a per-sandbox host; the PoC connected with `SandboxAgent.connect({baseUrl})`, no cookie + jar. Confirm at impl, but do NOT port `createCookieFetch`. +2. **Network egress — refuse restricted-network under strict.** Mirror what Daytona does unless + E2B forces a mandatory new mechanism — and it doesn't: the `sandbox-agent/e2b` wrapper exposes no egress + block/allow, and E2B is open-egress by default. So mirror the LOCAL gate + (`LOCAL_NETWORK_UNSUPPORTED_MESSAGE` analogue) — no new mechanism, no silent unenforced boundary. +3. **Template — BAKED, like Daytona** (do what we do now). Build an E2B template carrying + the daemon + pi (`E2BProviderOptions.template`), the E2B equivalent of `build_snapshot.py`. + Not runtime auto-install. +4. **Leak backstop — `timeoutMs` + `autoPause`** on the e2b provider. E2B auto-kills a sandbox at + its timeout; set a non-zero timeout so a process-KILL-leaked E2B sandbox self-reaps, the + functional equivalent of Daytona's `ephemeral + autoStopInterval`. + +## PoC gotchas to carry into the template build + +- **E2B template build**: `npx @e2b/cli template create -d e2b.Dockerfile` (v2; `build` is + wrong for the installed CLI). `install-agent` HANGS in E2B's remote builder at the ACP-adapter + step — replicate manually with `npm install @agentclientprotocol/-acp` + native binary curl. + ENV vars do NOT persist across RUN layers in the E2B builder — hardcode paths. `printf '\n'` + mangled — write launcher scripts via `base64 -d`. Agents under `/root/.local` (USER root); + run the server as root; cwd `/root/work`. +- **pi needs node ≥ 22.19**: E2B base image ships node 20 → pi-acp crashes at runtime + (`AcpRpcError: Cannot call write after a stream was destroyed`). Install node 22 (nodesource) + in the E2B template. Pi-only symptom — but this worktree IS Pi-on-E2B, so it's load-bearing here. + +## Files (verified) + +- `services/agent/src/engines/sandbox_agent/provider.ts` — `buildSandboxProvider` (add e2b branch) + a `buildE2bCreate` sibling to `buildDaytonaCreate` +- `services/agent/src/engines/sandbox_agent/run-plan.ts` — `sandboxId`/`isDaytona` (add `isE2b` + `defaultE2bCwd`); network/asset gates +- `services/agent/src/engines/sandbox_agent/daytona.ts` — reference for the E2B asset-prep sibling (new `e2b.ts`); `pi-assets.ts` uploaders are reusable +- `services/agent/src/engines/sandbox_agent.ts` — the prepare-assets dispatch (`if (plan.isDaytona) prepareDaytonaPiAssets`) gains an e2b arm +- `api/oss/src/utils/env.py` — add `E2bConfig` (`E2B_API_KEY`, ...) alongside `DaytonaConfig` +- `sdks/python/agenta/sdk/agents/dtos.py` — sandbox stays a loose string; no enum change +- `sandbox-images/e2b/` — new E2B template recipe (follow-up if baking) +- Tests: provider create-object unit (mirror the Daytona create test); local-vs-e2b gate tests diff --git a/docs/design/agent-workflows/projects/add-sandbox-e2b/specs.md b/docs/design/agent-workflows/projects/add-sandbox-e2b/specs.md new file mode 100644 index 0000000000..07a6efdc92 --- /dev/null +++ b/docs/design/agent-workflows/projects/add-sandbox-e2b/specs.md @@ -0,0 +1,57 @@ +# Add the E2B sandbox (running Pi) — specs + +## Scope + +In: `sandbox="e2b"` runs the **Pi** harness, using the existing `sandbox-agent/e2b` provider; +a **baked E2B template** (daemon + pi, node 22) like the Daytona snapshot; Pi's existing remote +asset-prep retargeted to the E2B handle; `E2B_API_KEY` config; a `timeoutMs`-based leak +backstop. Out: any non-Pi harness on E2B (deferred — needs the non-Pi remote bootstrap), +restricted-network enforcement on E2B (refused under strict instead). + +## Behavior + +- A run with `sandbox="e2b"` (or `SANDBOX_AGENT_PROVIDER=e2b`) starts the baked-template E2B + sandbox (daemon + pi already present), runs Pi there, streams the result, and **always tears the + sandbox down** on every normal/error/disconnect path (the `finally`), with a `timeoutMs`-based + self-reap backstop for the process-KILL case (E2B auto-kills at its timeout — the functional + equivalent of Daytona's `ephemeral + autoStop`). +- Pi authenticates with the resolved provider key (managed `env`) or its uploaded own login + (`runtime_provided`), exactly as on Daytona — the `shouldUploadOwnLogin` decision is reused. +- The ACP connection uses the plain `createAcpFetch` (E2B has no preview-proxy cookie). +- Pi's extension, forced skills, system prompts, and usage file are provisioned into the E2B + sandbox; tools run via the file relay (already remote-capable); tracing is Pi-self-instrumented + under the propagated traceparent. +- A restricted `network` policy on E2B is **refused loud under `strict`** (the `sandbox-agent/e2b` + wrapper exposes no egress control; no silent unenforced boundary), mirroring the local gate. + +## Contracts + +- Wire unchanged: `sandbox` is a free string; no golden change required for selection. (Add an + e2b example fixture only if a new wire field is introduced — none expected.) +- `E2bConfig` in `env.py` exposes `E2B_API_KEY` + the template name var via the shared `env` + object (never `os.getenv` directly in app code). + +## Decisions — LOCKED (see research.md for evidence) + +1. **Auth/connection: `E2B_API_KEY` + plain `createAcpFetch`** (no cookie jar). +2. **Restricted network: refuse under strict** (no E2B egress control exists; don't invent one). +3. **Template: BAKED** (daemon + pi + node 22), like the Daytona snapshot — not auto-install. +4. **Leak backstop: `timeoutMs` + `autoPause`** on the e2b provider (E2B auto-kills at timeout). + +## Non-goals / invariants preserved + +- No harness code changes; Pi is held constant. The only Python change is `E2bConfig`. (The + baked-template build is a new artifact under `sandbox-images/e2b/`, not app code.) +- The provider stays a thin branch in `buildSandboxProvider`; no provider class hierarchy. +- Teardown + leak backstop parity with Daytona is mandatory — an E2B sandbox must never outlive + its run (cost/security). +- Restricted boundaries are enforced or refused, never silently accepted. + +## Acceptance + +- Unit: `buildE2bCreate` produces the expected provider options (env, `template`, `timeoutMs`/ + `autoPause` leak backstop) — mirror the Daytona create-object test; `run-plan` sets `isE2b` and + an E2B cwd; a restricted-network E2B run under strict is REFUSED with a clear message. +- Integration: a Pi-on-E2B run returns `ok:true` with output + a trace; the sandbox is gone + after the run (verify via the E2B API); a tool run delivers via the relay. +- Ungated endpoint → both editions per test-account convention. diff --git a/docs/design/agent-workflows/projects/add-sandbox-e2b/tasks.md b/docs/design/agent-workflows/projects/add-sandbox-e2b/tasks.md new file mode 100644 index 0000000000..ac10b3b60a --- /dev/null +++ b/docs/design/agent-workflows/projects/add-sandbox-e2b/tasks.md @@ -0,0 +1,49 @@ +# Add the E2B sandbox (running Pi) — tasks + +Decisions are LOCKED (see research.md / specs.md); Phase 0 is a quick re-verify. + +## Phase 0 — re-verify E2B + provider reality (no app code) +> Reference first: the `vibes/sessions/demo` PoC ran Pi-on-E2B green (template +> `agenta-sandbox-agent`) and documented the template-build + node-22 gotchas. Confirm, reuse. +- [ ] T0.1 Re-confirm `sandbox-agent/e2b` options: `create`, `connect`, `template`, `agentPort`, + `timeoutMs`, `autoPause` (already read from types — sanity-check at impl). +- [ ] T0.2 Run Pi in a baked E2B template sandbox; confirm `SandboxAgent.connect({baseUrl})` with + plain `createAcpFetch` (no cookie) and that node ≥ 22.19 is present (pi requirement). + +## Phase 1 — baked E2B template (the build artifact) +- [ ] T1.1 `sandbox-images/e2b/` recipe + `e2b.Dockerfile` baking the rivet daemon + pi + node 22, + mirroring Daytona's `build_snapshot.py`. Apply the PoC gotchas: `npx @e2b/cli template + create -d e2b.Dockerfile`; manual `npm install @agentclientprotocol/-acp` + native + binary curl (install-agent hangs in the builder); hardcode paths (env doesn't persist across + RUN); base64-write launcher scripts; USER root, cwd `/root/work`. Template name → env (T3.1). + +## Phase 2 — Node provider +- [ ] T2.1 `provider.ts`: add `buildE2bCreate` (env via the `daytonaEnvVars` equivalent, + `template` name, `timeoutMs`/`autoPause` leak backstop) + an `e2b({...})` branch in + `buildSandboxProvider`. +- [ ] T2.2 `run-plan.ts`: add `isE2b`, `defaultE2bCwd()`, and **refuse** restricted-network E2B + under strict (mirror the `LOCAL_NETWORK_UNSUPPORTED_MESSAGE` gate — no E2B egress control). + +## Phase 3 — Pi asset-prep + wiring on E2B +- [ ] T3.1 `api/oss/src/utils/env.py`: add `E2bConfig` (`E2B_API_KEY`, template name) on the + shared `env` object; wire into `EnvironSettings`. +- [ ] T3.2 New `engines/sandbox_agent/e2b.ts` mirroring the Pi parts of `daytona.ts` + (`prepareE2bPiAssets`): reuse `pi-assets.ts` uploaders against the E2B handle. pi is baked + in the template, so no in-sandbox install needed. +- [ ] T3.3 `sandbox_agent.ts`: extend the prepare dispatch (`if (plan.isDaytona) ...`) with an + e2b arm; use plain `createAcpFetch` (no cookie); verify `inFlightSandboxes` + `finally` + teardown cover the E2B handle (they operate on the generic handle — confirm). + +## Phase 4 — tests & docs +- [ ] T4.1 Unit: `buildE2bCreate` options (template, `timeoutMs`/`autoPause`, env — mirror the + Daytona create-object test); `run-plan` `isE2b` + cwd; restricted-network E2B under strict + is REFUSED with a clear message. +- [ ] T4.2 Integration: Pi-on-E2B returns output + trace; sandbox deleted after run (E2B API + check); relay tool run works. Both editions (ungated convention). +- [ ] T4.3 `documentation/` sandbox doc + comparison table updated with E2B (baked template, + refuse-restricted-network, `timeoutMs` backstop; deferred: non-Pi harnesses on E2B). + +## Verify before merge +- [ ] `ruff format`/`ruff check`; `pnpm test`/`pnpm run typecheck`. +- [ ] Diff scoped vs origin/main; drop findings also on main. +- [ ] Teardown/leak parity with Daytona explicitly verified — no E2B sandbox outlives its run. diff --git a/services/runner/package.json b/services/runner/package.json index 5954c4d1fd..de8bf67c3a 100644 --- a/services/runner/package.json +++ b/services/runner/package.json @@ -21,6 +21,7 @@ }, "dependencies": { "@daytonaio/sdk": "^0.187.0", + "@e2b/code-interpreter": "^1.0.0", "@earendil-works/pi-coding-agent": "0.79.4", "@opentelemetry/api": "1.9.0", "@opentelemetry/exporter-trace-otlp-proto": "0.54.0", diff --git a/services/runner/pnpm-lock.yaml b/services/runner/pnpm-lock.yaml index 51ea22acd4..d340535bdc 100644 --- a/services/runner/pnpm-lock.yaml +++ b/services/runner/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@daytonaio/sdk': specifier: ^0.187.0 version: 0.187.0(ws@8.21.0) + '@e2b/code-interpreter': + specifier: ^1.0.0 + version: 1.5.1 '@earendil-works/pi-coding-agent': specifier: 0.79.4 version: 0.79.4(ws@8.21.0)(zod@4.4.3) @@ -40,7 +43,7 @@ importers: version: 0.0.29 sandbox-agent: specifier: 0.4.2 - version: 0.4.2(@daytonaio/sdk@0.187.0(ws@8.21.0))(zod@4.4.3) + version: 0.4.2(@daytonaio/sdk@0.187.0(ws@8.21.0))(@e2b/code-interpreter@1.5.1)(zod@4.4.3) undici: specifier: 8.3.0 version: 8.3.0 @@ -306,6 +309,20 @@ packages: resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==} engines: {node: '>=18'} + '@bufbuild/protobuf@2.12.1': + resolution: {integrity: sha512-BvAMfS6LrgZiryOAZ4pBYucu4wG/Ei/9o9DZ9akbREnMLbPJiom2i8b9C8IsKErQoiKqVhrerzt3kOT/RrzLHg==} + + '@connectrpc/connect-web@2.0.0-rc.3': + resolution: {integrity: sha512-w88P8Lsn5CCsA7MFRl2e6oLY4J/5toiNtJns/YJrlyQaWOy3RO8pDgkz+iIkG98RPMhj2thuBvsd3Cn4DKKCkw==} + peerDependencies: + '@bufbuild/protobuf': ^2.2.0 + '@connectrpc/connect': 2.0.0-rc.3 + + '@connectrpc/connect@2.0.0-rc.3': + resolution: {integrity: sha512-ARBt64yEyKbanyRETTjcjJuHr2YXorzQo0etyS5+P6oSeW8xEuzajA9g+zDnMcj1hlX2dQE93foIWQGfpru7gQ==} + peerDependencies: + '@bufbuild/protobuf': ^2.2.0 + '@daytona/api-client@0.187.0': resolution: {integrity: sha512-riKOJ6eSuy67DL6iJlAa3Bfjnm4iQmkOdJk0B5hqrYMZeZmVDsgdiZtYvFpyoa+2KCZFNb0Gs5dQwO1d6NhGCw==} @@ -316,6 +333,10 @@ packages: resolution: {integrity: sha512-j6PfT6735Uu34t4JoxBi4IMh1JLNrEDg5w3ZUaT0Mgkas2UfoAAhQ2Eg1LqMhy4n1CTffvCyJID9W6Ldi4xEGQ==} deprecated: 'Moved to @daytona/sdk, same API, no breaking changes. Please update: npm uninstall @daytonaio/sdk && npm i @daytona/sdk' + '@e2b/code-interpreter@1.5.1': + resolution: {integrity: sha512-mkyKjAW2KN5Yt0R1I+1lbH3lo+W/g/1+C2lnwlitXk5wqi/g94SEO41XKdmDf5WWpKG3mnxWDR5d6S/lyjmMEw==} + engines: {node: '>=18'} + '@earendil-works/pi-agent-core@0.79.4': resolution: {integrity: sha512-xkaZ3yK2XbP9HYdHrrdj/6HqZPM0o/mwbjMSU4RTJyR3HjDG0ZrPz76Hg6s0W+G4u6PpJr1mGx/srCG+3eQA8A==} engines: {node: '>=22.19.0'} @@ -1390,6 +1411,9 @@ packages: resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==} engines: {node: '>= 0.8'} + compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -1430,6 +1454,10 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + e2b@1.13.2: + resolution: {integrity: sha512-m8acE/MzMAJo1A57DakR2X1Sl5Mt1tcQO2aJfygNaQHLXby/4xsjF0UeJUB70jF7xntiR41pAMbZEHnkzrT9tw==} + engines: {node: '>=18'} + ecdsa-sig-formatter@1.0.11: resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==} @@ -1870,6 +1898,12 @@ packages: zod: optional: true + openapi-fetch@0.9.8: + resolution: {integrity: sha512-zM6elH0EZStD/gSiNlcPrzXcVQ/pZo3BDvC6CDwRDUt1dDzxlshpmQnpD6cZaJ39THaSmwVCxxRrPKNM1hHrDg==} + + openapi-typescript-helpers@0.0.8: + resolution: {integrity: sha512-1eNjQtbfNi5Z/kFhagDIaIRj6qqDzhjNJKz8cmMW0CVdGwT6e1GLbAfgI0d28VTJa1A8jz82jm/4dG8qNoNS8g==} + p-retry@4.6.2: resolution: {integrity: sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==} engines: {node: '>=8'} @@ -1912,6 +1946,9 @@ packages: resolution: {integrity: sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==} engines: {node: '>=12'} + platform@1.3.6: + resolution: {integrity: sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg==} + postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} @@ -2740,6 +2777,17 @@ snapshots: '@bcoe/v8-coverage@1.0.2': {} + '@bufbuild/protobuf@2.12.1': {} + + '@connectrpc/connect-web@2.0.0-rc.3(@bufbuild/protobuf@2.12.1)(@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.12.1))': + dependencies: + '@bufbuild/protobuf': 2.12.1 + '@connectrpc/connect': 2.0.0-rc.3(@bufbuild/protobuf@2.12.1) + + '@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.12.1)': + dependencies: + '@bufbuild/protobuf': 2.12.1 + '@daytona/api-client@0.187.0': dependencies: axios: 1.18.0 @@ -2784,6 +2832,10 @@ snapshots: - supports-color - ws + '@e2b/code-interpreter@1.5.1': + dependencies: + e2b: 1.13.2 + '@earendil-works/pi-agent-core@0.79.4(ws@8.21.0)(zod@4.4.3)': dependencies: '@earendil-works/pi-ai': 0.79.4(ws@8.21.0)(zod@4.4.3) @@ -3836,6 +3888,8 @@ snapshots: dependencies: delayed-stream: 1.0.0 + compare-versions@6.1.1: {} + convert-source-map@2.0.0: {} cross-spawn@7.0.6: @@ -3864,6 +3918,15 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + e2b@1.13.2: + dependencies: + '@bufbuild/protobuf': 2.12.1 + '@connectrpc/connect': 2.0.0-rc.3(@bufbuild/protobuf@2.12.1) + '@connectrpc/connect-web': 2.0.0-rc.3(@bufbuild/protobuf@2.12.1)(@connectrpc/connect@2.0.0-rc.3(@bufbuild/protobuf@2.12.1)) + compare-versions: 6.1.1 + openapi-fetch: 0.9.8 + platform: 1.3.6 + ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer: 5.2.1 @@ -4285,6 +4348,12 @@ snapshots: ws: 8.21.0 zod: 4.4.3 + openapi-fetch@0.9.8: + dependencies: + openapi-typescript-helpers: 0.0.8 + + openapi-typescript-helpers@0.0.8: {} + p-retry@4.6.2: dependencies: '@types/retry': 0.12.0 @@ -4316,6 +4385,8 @@ snapshots: picomatch@4.0.4: {} + platform@1.3.6: {} + postcss@8.5.15: dependencies: nanoid: 3.3.13 @@ -4411,12 +4482,13 @@ snapshots: safe-buffer@5.2.1: {} - sandbox-agent@0.4.2(@daytonaio/sdk@0.187.0(ws@8.21.0))(zod@4.4.3): + sandbox-agent@0.4.2(@daytonaio/sdk@0.187.0(ws@8.21.0))(@e2b/code-interpreter@1.5.1)(zod@4.4.3): dependencies: '@sandbox-agent/cli-shared': 0.4.2 acp-http-client: 0.4.2(zod@4.4.3) optionalDependencies: '@daytonaio/sdk': 0.187.0(ws@8.21.0) + '@e2b/code-interpreter': 1.5.1 '@sandbox-agent/cli': 0.4.2 transitivePeerDependencies: - zod diff --git a/services/runner/sandbox-images/e2b/README.md b/services/runner/sandbox-images/e2b/README.md new file mode 100644 index 0000000000..d5d038208a --- /dev/null +++ b/services/runner/sandbox-images/e2b/README.md @@ -0,0 +1,89 @@ +# E2B Sandbox Template + +Baked E2B template for the Agenta sandbox-agent runner. Contains the rivet daemon +(`sandbox-agent`) and four harnesses — Pi, Codex, OpenCode, and Claude — pre-installed so a +cold E2B sandbox never pays a runtime `install-agent` fetch. E2B sandboxes are ephemeral (never +reused across runs), so this is the dominant remote cold-start cost every one of these harnesses +would otherwise pay on EVERY run. + +## Build + +```bash +npx @e2b/cli template create agenta-sandbox-agent -d e2b.Dockerfile +``` + +The template name `agenta-sandbox-agent` is the default the runner reads from +`E2B_TEMPLATE`. Rebuild after changing `e2b.Dockerfile` or pinned package versions. + +## Configure the runner + +```bash +SANDBOX_AGENT_PROVIDER=e2b +E2B_API_KEY=... +E2B_TEMPLATE=agenta-sandbox-agent +``` + +`E2B_TEMPLATE` defaults to `agenta-sandbox-agent`; omit it if you kept the default name. + +## What is baked in + +- `sandbox-agent` daemon binary (rivet, Apache-2.0) +- **Pi**: `pi-acp` ACP adapter (MIT) + `@earendil-works/pi-coding-agent` CLI (MIT), versions + pinned to `services/runner/package.json` +- **Codex**: `@zed-industries/codex-acp` ACP adapter (npm) + the native `codex` CLI (Rust, + GitHub release binary) +- **OpenCode**: the native `opencode` binary (GitHub release; speaks ACP natively, no separate + adapter package) +- **Claude**: `@zed-industries/claude-agent-acp` ACP adapter (npm) + the native `claude` CLI, + fetched directly from Anthropic's own release bucket (never a third-party mirror — see the + licensing note in `services/runner/sandbox-images/daytona/build_snapshot.py` for why that + boundary matters) +- Node 22 (the E2B base ships Node 20; `pi-acp` requires >=22.19) + +Every harness is laid out at the exact path `sandbox-agent install-agent ` would have used +(`~/.local/share/sandbox-agent/bin/agent_processes/`), replicated by hand because +`install-agent` hangs inside the E2B builder. Credentials are never baked; they are injected at +runtime. + +### Why baking still helps even without a daemon-side skip flag + +Unlike Pi (which the daemon never auto-installs — the runner alone decides whether to install +it, gated by `AGENTA_AGENT_SANDBOX_PI_INSTALLED`), Codex and Claude are installed +UNCONDITIONALLY by the `sandbox-agent/e2b` provider's `create()` on every sandbox +(`DEFAULT_AGENTS = ["claude", "codex"]` in the daemon's compiled binary), and there is no env +var or `SandboxProvider` hook that lets the runner skip that call. OpenCode is not even in that +list, so it is never daemon-auto-installed at all today. + +So `AGENTA_AGENT_SANDBOX_{CODEX,OPENCODE,CLAUDE}_INSTALLED` (mirroring +`AGENTA_AGENT_SANDBOX_PI_INSTALLED`'s naming) do NOT gate any runner-side install call the way +Pi's flag does — there is none to gate for these three. They are carried into the sandbox env +for visibility only (see `e2b.ts`/`provider.ts`). The bake still pays off because the daemon's +own agent installer checks for an existing install before doing any work +(`agent_manager.install_agent_process: already installed`, observed in the compiled daemon +binary) — baking turns the daemon's `install-agent` call into a fast no-op instead of a fresh +npm/binary fetch. If a future `sandbox-agent` release adds a real daemon-side skip mechanism for +these three, wire it through these same env vars. + +## E2B_TIMEOUT_MS is an idle backstop, not a run budget + +`E2B_TIMEOUT_MS` (default 30 minutes, see `DEFAULT_E2B_TIMEOUT_MS` in `provider.ts`) is a leak +backstop: it exists to self-reap a sandbox whose owning runner process was killed (`docker stop` +/ SIGKILL / OOM) before its `finally` could call `destroySandbox`. E2B enforces it as an +absolute deadline from sandbox creation, which would otherwise kill a legitimately long-running +turn mid-flight — unlike Daytona's `autoStopInterval`, which measures IDLE time and never fires +on a busy sandbox. + +The runner closes that gap with an idle-refresh keepalive +(`src/engines/sandbox_agent/e2b-keepalive.ts`): once a run's E2B sandbox exists, the runner +calls `Sandbox.setTimeout(sandboxId, E2B_TIMEOUT_MS)` (a static method on +`@e2b/code-interpreter`, a direct dependency of `services/runner` — reachable independently of +the `sandbox-agent` wrapper, whose own `SandboxProvider` interface exposes no extend-timeout +affordance) on an interval of `E2B_TIMEOUT_MS / 3`. So in practice: + +- A live run keeps pushing its deadline forward — `E2B_TIMEOUT_MS` is a rolling idle window + ("time since last liveness proof"), exactly Daytona's semantics, not a cap on total run time. +- A killed runner simply stops refreshing, and the sandbox self-reaps within `E2B_TIMEOUT_MS` of + the kill — the original leak-backstop guarantee is unchanged. + +`E2B_TIMEOUT_MS` remains the one knob for both meanings (idle window length AND leak-backstop +bound); there is no separate "run budget" setting. diff --git a/services/runner/sandbox-images/e2b/e2b.Dockerfile b/services/runner/sandbox-images/e2b/e2b.Dockerfile new file mode 100644 index 0000000000..0eb34b99a4 --- /dev/null +++ b/services/runner/sandbox-images/e2b/e2b.Dockerfile @@ -0,0 +1,94 @@ +# E2B baked template: sandbox-agent daemon + Pi, Codex, OpenCode, Claude harnesses. +# Build: npx @e2b/cli template create agenta-sandbox-agent -d e2b.Dockerfile +FROM e2bdev/code-interpreter:latest + +USER root + +RUN apt-get update && apt-get install -y --no-install-recommends \ + bash ca-certificates curl git procps \ + && rm -rf /var/lib/apt/lists/* + +# node 22 — base ships node 20; pi-acp requires >=22.19 +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \ + && apt-get install -y nodejs \ + && node --version + +# rivet sandbox-agent daemon (install-agent hangs in the E2B builder; replicate manually) +RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.4.x/install.sh | sh \ + && sandbox-agent --version + +# pi-acp adapter; versions match services/runner/package.json pins +# ENV does not persist across RUN layers in the E2B builder — paths are hardcoded +# launcher written via base64 -d because printf '\n' is mangled in the builder +RUN mkdir -p /root/.local/share/sandbox-agent/bin/agent_processes/pi \ + && cd /root/.local/share/sandbox-agent/bin/agent_processes/pi \ + && npm install pi-acp@0.0.29 +RUN npm install -g --ignore-scripts @earendil-works/pi-coding-agent@0.79.4 \ + && pi --version || true +RUN echo 'IyEvdXNyL2Jpbi9lbnYgc2gKc2V0IC1lCmV4ZWMgL3Jvb3QvLmxvY2FsL3NoYXJlL3NhbmRib3gtYWdlbnQvYmluL2FnZW50X3Byb2Nlc3Nlcy9waS9ub2RlX21vZHVsZXMvLmJpbi9waS1hY3AgIiRAIgo=' \ + | base64 -d > /root/.local/share/sandbox-agent/bin/agent_processes/pi-acp \ + && chmod +x /root/.local/share/sandbox-agent/bin/agent_processes/pi-acp + +# --------------------------------------------------------------------------- +# codex — the daemon's own registry pins codex-acp@0.1.0 (ancient); we pin a current release +# instead, same discipline as the pi-acp pin above (0.0.29 vs the daemon's registry 0.0.23). +# Two parts, replicating what `sandbox-agent install-agent codex` does: the codex-acp ACP +# adapter (npm, installed into its own `codex-adapter` dir so it never collides with the +# `codex` launcher filename below) that speaks ACP to the daemon, and the native `codex` CLI +# binary (Rust, GitHub release) that the adapter shells out to. Pin-drift risk: codex-acp and +# the codex CLI version independently; bump both together and re-smoke-test on install-agent bump. +# `@zed-industries/codex-acp` is deprecated in favor of `@agentclientprotocol/codex-acp`; kept +# on the old scope here to match what THIS daemon version's (`sandbox-agent` 0.4.2) own +# registry and adapters.json still expect — re-pin both together on the next daemon bump. +RUN mkdir -p /root/.local/share/sandbox-agent/bin/agent_processes/codex-adapter \ + && cd /root/.local/share/sandbox-agent/bin/agent_processes/codex-adapter \ + && npm install --ignore-scripts @zed-industries/codex-acp@0.16.0 +RUN curl -fsSL -o /tmp/codex.tar.gz \ + https://github.com/openai/codex/releases/download/rust-v0.142.5/codex-x86_64-unknown-linux-musl.tar.gz \ + && tar -xzf /tmp/codex.tar.gz -C /tmp \ + && mv /tmp/codex-x86_64-unknown-linux-musl /usr/local/bin/codex \ + && chmod +x /usr/local/bin/codex \ + && rm -f /tmp/codex.tar.gz \ + && codex --version || true +RUN echo 'IyEvdXNyL2Jpbi9lbnYgc2gKc2V0IC1lCmV4ZWMgL3Jvb3QvLmxvY2FsL3NoYXJlL3NhbmRib3gtYWdlbnQvYmluL2FnZW50X3Byb2Nlc3Nlcy9jb2RleC1hZGFwdGVyL25vZGVfbW9kdWxlcy8uYmluL2NvZGV4LWFjcCAiJEAiCg==' \ + | base64 -d > /root/.local/share/sandbox-agent/bin/agent_processes/codex \ + && chmod +x /root/.local/share/sandbox-agent/bin/agent_processes/codex + +# --------------------------------------------------------------------------- +# opencode — speaks ACP natively (`agent_manager.resolve_agent_process: resolved opencode +# native` in the daemon), so there is no separate ACP adapter package: only the binary, unpacked +# into its own dir with the launcher as a symlink (same collision-avoidance as codex/claude +# above). E2B sandboxes are x86-64, so linux-x64 is the correct asset regardless of the +# builder's host arch. +RUN curl -fsSL -o /tmp/opencode.tar.gz \ + https://github.com/anomalyco/opencode/releases/download/v1.17.13/opencode-linux-x64.tar.gz \ + && mkdir -p /root/.local/share/sandbox-agent/bin/agent_processes/opencode-bin \ + && tar -xzf /tmp/opencode.tar.gz -C /root/.local/share/sandbox-agent/bin/agent_processes/opencode-bin \ + && chmod +x /root/.local/share/sandbox-agent/bin/agent_processes/opencode-bin/opencode \ + && rm -f /tmp/opencode.tar.gz \ + && ln -sf /root/.local/share/sandbox-agent/bin/agent_processes/opencode-bin/opencode \ + /root/.local/share/sandbox-agent/bin/agent_processes/opencode \ + && /root/.local/share/sandbox-agent/bin/agent_processes/opencode --version || true + +# --------------------------------------------------------------------------- +# claude — like codex, the daemon shells the native `claude` CLI through the claude-agent-acp +# ACP adapter. Adapter installed into its own `claude-adapter` dir (collision-avoidance, see +# codex above); CLI fetched straight from Anthropic's own release bucket (never a third-party +# mirror — see build_snapshot.py's licensing note for why that boundary matters for Daytona). +# Pin note: this bucket has no immutable version-tag guarantee beyond the manifest.json at each +# path, so a version bump here is a deliberate re-pin, same as the other three harnesses. +# `@zed-industries/claude-agent-acp` is deprecated in favor of +# `@agentclientprotocol/claude-agent-acp`; kept on the old scope to match both the daemon's own +# pin and services/runner/package.json's `^0.23.1` — migrate all three together. +RUN mkdir -p /root/.local/share/sandbox-agent/bin/agent_processes/claude-adapter \ + && cd /root/.local/share/sandbox-agent/bin/agent_processes/claude-adapter \ + && npm install --ignore-scripts @zed-industries/claude-agent-acp@0.23.1 +RUN curl -fsSL -o /usr/local/bin/claude \ + https://storage.googleapis.com/claude-code-dist-86c565f3-f756-42ad-8dfa-d59b1c096819/claude-code-releases/2.1.187/linux-x64/claude \ + && chmod +x /usr/local/bin/claude \ + && claude --version || true +RUN echo 'IyEvdXNyL2Jpbi9lbnYgc2gKc2V0IC1lCmV4ZWMgL3Jvb3QvLmxvY2FsL3NoYXJlL3NhbmRib3gtYWdlbnQvYmluL2FnZW50X3Byb2Nlc3Nlcy9jbGF1ZGUtYWRhcHRlci9ub2RlX21vZHVsZXMvLmJpbi9jbGF1ZGUtYWdlbnQtYWNwICIkQCIK' \ + | base64 -d > /root/.local/share/sandbox-agent/bin/agent_processes/claude \ + && chmod +x /root/.local/share/sandbox-agent/bin/agent_processes/claude + +WORKDIR /root/work diff --git a/services/runner/src/engines/sandbox_agent.ts b/services/runner/src/engines/sandbox_agent.ts index d3d816edab..38ce4fad5c 100644 --- a/services/runner/src/engines/sandbox_agent.ts +++ b/services/runner/src/engines/sandbox_agent.ts @@ -57,6 +57,13 @@ import { createCookieFetch, prepareDaytonaPiAssets, } from "./sandbox_agent/daytona.ts"; +import { prepareE2BPiAssets } from "./sandbox_agent/e2b.ts"; +import { + extendE2BSandboxTimeout, + startE2BKeepalive, + type E2BKeepaliveHandle, +} from "./sandbox_agent/e2b-keepalive.ts"; +import { e2bTimeoutMs } from "./sandbox_agent/provider.ts"; import { conciseError } from "./sandbox_agent/errors.ts"; import { buildSessionMcpServers } from "./sandbox_agent/mcp.ts"; import { applyModel } from "./sandbox_agent/model.ts"; @@ -222,6 +229,9 @@ export interface SandboxAgentDeps extends BuildRunPlanDeps { unmountStorage?: typeof unmountStorage; discoverTunnelEndpoint?: typeof discoverTunnelEndpoint; responderFactory?: (permissionPolicy: string | undefined) => Responder; + prepareE2BPiAssets?: typeof prepareE2BPiAssets; + startE2BKeepalive?: typeof startE2BKeepalive; + extendE2BSandboxTimeout?: typeof extendE2BSandboxTimeout; log?: Log; } @@ -311,6 +321,7 @@ export async function runSandboxAgent( sandboxProvider: deps.sandboxProvider, createLocalCwd: deps.createLocalCwd, createDaytonaCwd: deps.createDaytonaCwd, + createE2BCwd: deps.createE2BCwd, durableCwd, resolveSkillDirs: deps.resolveSkillDirs, log: logger, @@ -337,7 +348,7 @@ export async function runSandboxAgent( // via the Agenta extension. Tool execution always relays back to this runner, which keeps // private specs, scoped env, callback endpoints, and callback auth in memory. const piExtEnv = plan.isPi - ? buildPiExtensionEnv(request, !plan.isDaytona, { + ? buildPiExtensionEnv(request, !plan.isRemoteSandbox, { relayDir: plan.relayDir, usageOutPath: plan.usageOutPath, // The materialized skill names (author + forced `_agenta.*`) so Pi's own agent span @@ -368,11 +379,18 @@ export async function runSandboxAgent( // Internal gateway-tool MCP server closer (set when an internal channel is built for a non-Pi // harness with executable tools; a no-op otherwise). Released in the `finally`. let closeToolMcp: (() => Promise) | undefined; + // E2B idle-refresh keepalive (D3): refreshes the sandbox timeout on an interval so + // E2B_TIMEOUT_MS measures idle-since-last-liveness-proof instead of run duration since + // creation. Started once the sandbox exists (below); stopped in the `finally` so a killed + // runner simply stops refreshing and the sandbox self-reaps within timeoutMs (no leak). + let e2bKeepalive: E2BKeepaliveHandle | undefined; // Durable cwd: set to the host mountpoint once a session-owned local run geesefs-mounts its // store prefix, so the `finally` can unmount it. Undefined for non-session/remote/unmounted runs. + // Remote sandboxes (Daytona, E2B) are excluded: the local-mount machinery below only applies + // to a host-side mount for the local provider. let mountedCwd: string | undefined; const mountLocalDurableCwd = async (reason: string): Promise => { - if (!mountCreds || plan.isDaytona) return false; + if (!mountCreds || plan.isRemoteSandbox) return false; logger( `local durable cwd mount (${reason}) session=${sessionForMount} cwd=${plan.cwd}`, ); @@ -384,7 +402,7 @@ export async function runSandboxAgent( }; let localDurableCwdEnotconnRemounts = 0; const reSignAndRemountLocalCwd = async (): Promise => { - if (!sessionForMount || !runCred || plan.isDaytona) return false; + if (!sessionForMount || !runCred || plan.isRemoteSandbox) return false; if ( localDurableCwdEnotconnRemounts >= LOCAL_DURABLE_CWD_ENOTCONN_REMOUNT_LIMIT @@ -412,7 +430,7 @@ export async function runSandboxAgent( }; let runtimeRemount: Promise | undefined; const remountLocalCwdAfterRuntimeEnotconn = (event: unknown): void => { - if (plan.isDaytona || !mountCreds || !mountedCwd) return; + if (plan.isRemoteSandbox || !mountCreds || !mountedCwd) return; if (runtimeRemount || !containsTransportEndpointDisconnected(event)) return; logger( `local durable cwd ENOTCONN observed in ACP event session=${sessionForMount} cwd=${plan.cwd}; re-signing and remounting`, @@ -424,11 +442,12 @@ export async function runSandboxAgent( return false; }); }; - let workspace: { cleanup: () => Promise } | undefined = plan.isDaytona - ? undefined - : { - cleanup: async () => rmSync(plan.cwd, { recursive: true, force: true }), - }; + let workspace: { cleanup: () => Promise } | undefined = + plan.isRemoteSandbox + ? undefined + : { + cleanup: async () => rmSync(plan.cwd, { recursive: true, force: true }), + }; try { // Persist events in-process so a follow-up turn can resume by session id. @@ -465,18 +484,34 @@ export async function runSandboxAgent( // normal exit so it is never double-deleted. if (sandbox) inFlightSandboxes.add(sandbox); - // On Daytona, push the harness login, the extension, and AGENTS.md into the remote - // sandbox via the filesystem API (nothing secret is baked into the image). Locally - // these use the host filesystem and the harness's own login (PI_CODING_AGENT_DIR). + // On Daytona/E2B, push the harness login, the extension, and AGENTS.md into the remote + // sandbox via the filesystem API. Locally these use the host filesystem. if (plan.isDaytona) { await prepareDaytonaPiAssets({ sandbox, plan, log: logger }); + } else if (plan.isE2B) { + await (deps.prepareE2BPiAssets ?? prepareE2BPiAssets)({ + sandbox, + plan, + log: logger, + }); + } + + // Start the E2B idle-refresh keepalive as soon as the sandbox ID is known (D3): from this + // point on E2B_TIMEOUT_MS is a rolling idle window, not a hard cap on the whole run. + if (plan.isE2B && sandbox?.sandboxId) { + e2bKeepalive = (deps.startE2BKeepalive ?? startE2BKeepalive)( + sandbox.sandboxId, + e2bTimeoutMs(), + deps.extendE2BSandboxTimeout ?? extendE2BSandboxTimeout, + logger, + ); } // Durable cwd: reuse the pre-signed creds (signed before buildRunPlan so the prefix drove the // cwd derivation). The mount lands BEFORE createSession so the session opens inside it. // Local: on-host geesefs; scoped creds never enter agent space. // Remote (Daytona): geesefs inside the sandbox over the ngrok tunnel. - if (mountCreds && !plan.isDaytona) { + if (mountCreds && !plan.isRemoteSandbox) { // Mount before local workspace materialization so AGENTS.md, harness files, and skills // land in the durable prefix instead of being hidden under the later FUSE mount. await mountLocalDurableCwd("initial"); @@ -490,7 +525,7 @@ export async function runSandboxAgent( }); } catch (err) { if ( - !plan.isDaytona && + !plan.isRemoteSandbox && mountCreds && isTransportEndpointDisconnected(err) && (await reSignAndRemountLocalCwd()) @@ -558,8 +593,9 @@ export async function runSandboxAgent( isPi: plan.isPi, capabilities, harness: plan.harness, - // Daytona: skip the internal loopback HTTP MCP channel (unreachable from the in-sandbox - // harness); gateway tools are delivered through the Daytona file relay started below. + // Any remote sandbox: skip the internal loopback HTTP MCP channel (unreachable from the + // in-sandbox harness); gateway tools are delivered through the file relay started below. + isRemote: plan.isRemoteSandbox, isDaytona: plan.isDaytona, toolSpecs: plan.toolSpecs, userMcpServers: request.mcpServers, @@ -597,7 +633,7 @@ export async function runSandboxAgent( endpoint: request.telemetry?.exporters?.otlp?.endpoint, authorization: request.telemetry?.exporters?.otlp?.headers?.authorization, captureContent: request.telemetry?.capture?.content?.enabled, - emitSpans: !plan.isPi || plan.isDaytona, + emitSpans: !plan.isPi || plan.isRemoteSandbox, emit, }); otel = run; @@ -696,7 +732,7 @@ export async function runSandboxAgent( // permission degrades to the run's headless permission policy (the same policy the // PolicyResponder uses for Claude builtins above). toolRelay = (deps.startToolRelay ?? startToolRelay)( - plan.isDaytona + plan.isRemoteSandbox ? (deps.sandboxRelayHost ?? sandboxRelayHost)(sandbox) : (deps.localRelayHost ?? localRelayHost)(), plan.relayDir, @@ -775,7 +811,7 @@ export async function runSandboxAgent( const usage = await resolveRunUsage({ sandbox, usageOutPath: plan.usageOutPath, - isDaytona: plan.isDaytona, + isRemote: plan.isRemoteSandbox, promptResult: result, streamUsage: run.usage(), }); @@ -792,7 +828,7 @@ export async function runSandboxAgent( // sandbox). const swallowedPiError = plan.isPi && - !plan.isDaytona && + !plan.isRemoteSandbox && !run.output().trim() && !run.events().some((e) => e.type === "tool_call") ? findSwallowedPiError(plan.sourcePiAgentDir, plan.cwd) @@ -846,6 +882,9 @@ export async function runSandboxAgent( error, }; } finally { + // Stop the E2B keepalive before tearing down the sandbox: no point refreshing a timeout on + // a sandbox we are about to delete, and it must stop even if `destroySandbox` below throws. + e2bKeepalive?.stop(); await runtimeRemount?.catch(() => {}); if (sandbox) inFlightSandboxes.delete(sandbox); await toolRelay?.stop().catch(() => {}); diff --git a/services/runner/src/engines/sandbox_agent/e2b-keepalive.ts b/services/runner/src/engines/sandbox_agent/e2b-keepalive.ts new file mode 100644 index 0000000000..fa28409049 --- /dev/null +++ b/services/runner/src/engines/sandbox_agent/e2b-keepalive.ts @@ -0,0 +1,103 @@ +/** + * E2B idle-refresh keepalive (D3): makes `E2B_TIMEOUT_MS` behave like Daytona's idle-based + * autostop instead of a hard wall-clock cap on the run. + * + * `DEFAULT_E2B_TIMEOUT_MS` (see provider.ts) is a leak backstop: it self-reaps a sandbox a + * process KILL orphaned (the per-run `finally` never ran to call `destroySandbox`). But E2B + * enforces that timeout as an absolute deadline from sandbox creation — with no refresh, a + * legitimately long-running turn (a big agent loop, a parked HITL wait) is killed mid-run at + * the cap, unlike Daytona's `autoStopInterval`, which measures IDLE time and never fires while + * the sandbox is busy (see `daytonaAutoStopMinutes` in provider.ts). + * + * The E2B SDK supports extending a live sandbox's timeout: `Sandbox.setTimeout(sandboxId, + * timeoutMs)` (a static method on `@e2b/code-interpreter`, which is a direct dependency of this + * package — see package.json — not just a transitive dependency of the `sandbox-agent` wrapper). + * This is the affordance the runner needs. It is reachable from here even though the + * `sandbox-agent` SDK's own `SandboxAgent.sandbox` getter does NOT expose it: that getter + * returns the `SandboxProvider` wrapper interface (`{name, create, destroy, pause, kill, + * getUrl, ensureServer}`; see `node_modules/sandbox-agent/dist/types-DdcvY5CI.d.ts`), which has + * no extend-timeout method, and the real E2B `Sandbox` instance the `sandbox-agent/e2b` + * provider connects to is a private local inside that module's closures — never handed back to + * the caller. So the keepalive calls `Sandbox.setTimeout` directly against the sandbox ID + * (`SandboxAgent.sandboxId`), independent of the `sandbox-agent` wrapper. + * + * Semantics once wired: `E2B_TIMEOUT_MS` becomes "time since last liveness proof", exactly like + * Daytona's autostop. A live run refreshes the deadline every `timeoutMs / 3` (comfortably + * inside the window even under scheduling jitter); a killed runner stops refreshing and the + * sandbox self-reaps within `timeoutMs` of the kill, preserving the original leak-backstop + * guarantee. + */ + +import { Sandbox } from "@e2b/code-interpreter"; + +type Log = (message: string) => void; + +/** Minimum viable refresh interval so a tiny configured timeout cannot busy-loop. */ +const MIN_REFRESH_INTERVAL_MS = 1000; + +/** How often to refresh, as a fraction of the timeout: comfortably inside the deadline. */ +const REFRESH_FRACTION = 3; + +export interface E2BKeepaliveHandle { + /** Stop refreshing. Idempotent; safe to call from a `finally` even if never started. */ + stop: () => void; +} + +export type ExtendE2BTimeout = ( + sandboxId: string, + timeoutMs: number, +) => Promise; + +/** + * Compute the refresh interval for a given timeout: timeoutMs / 3, clamped to + * `MIN_REFRESH_INTERVAL_MS` so a very small (e.g. test) timeout cannot spin. + */ +export function e2bKeepaliveIntervalMs(timeoutMs: number): number { + return Math.max(MIN_REFRESH_INTERVAL_MS, Math.floor(timeoutMs / REFRESH_FRACTION)); +} + +/** + * Start refreshing an E2B sandbox's timeout on an interval so it measures idle time (since the + * last successful refresh) rather than run duration since creation. + * + * `extend` is injectable so this is unit-testable with a fake handle; the real caller passes a + * thin wrapper around `Sandbox.setTimeout` from `@e2b/code-interpreter`. Failures are logged and + * swallowed — a transient API error should not crash the run; worst case the sandbox reaps at + * the original deadline, same as before this feature existed. + */ +export function startE2BKeepalive( + sandboxId: string, + timeoutMs: number, + extend: ExtendE2BTimeout, + log: Log = () => {}, +): E2BKeepaliveHandle { + const intervalMs = e2bKeepaliveIntervalMs(timeoutMs); + + const refresh = (): void => { + void extend(sandboxId, timeoutMs).catch((err) => { + log( + `e2b keepalive refresh failed sandbox=${sandboxId}: ${err instanceof Error ? err.message : String(err)}`, + ); + }); + }; + + const interval = setInterval(refresh, intervalMs); + // Allow the Node process to exit even if the interval is still pending (mirrors the alive + // watchdog in sessions/alive.ts). + if ((interval as unknown as { unref?: () => void }).unref) { + (interval as unknown as { unref: () => void }).unref(); + } + + return { + stop: () => clearInterval(interval), + }; +} + +/** + * The real `extend` implementation: `Sandbox.setTimeout` is a static method, so it needs no + * live `Sandbox` instance — only the sandbox ID (`SandboxAgent.sandboxId`, already available to + * the caller after `startSandboxAgent`) and an API key, which defaults to `E2B_API_KEY` from + * the environment exactly like the `sandbox-agent/e2b` provider's own `Sandbox.connect` calls. + */ +export const extendE2BSandboxTimeout: ExtendE2BTimeout = (sandboxId, timeoutMs) => + Sandbox.setTimeout(sandboxId, timeoutMs); diff --git a/services/runner/src/engines/sandbox_agent/e2b.ts b/services/runner/src/engines/sandbox_agent/e2b.ts new file mode 100644 index 0000000000..0d9eb6976d --- /dev/null +++ b/services/runner/src/engines/sandbox_agent/e2b.ts @@ -0,0 +1,143 @@ +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; + +import { + uploadPiExtensionToSandbox, + uploadSkillsToSandbox, + uploadSystemPromptToSandbox, +} from "./pi-assets.ts"; +import { shouldUploadOwnLogin, type RunPlan } from "./run-plan.ts"; + +type Log = (message: string) => void; + +/** In-sandbox Pi agent dir (daemon runs as root in the E2B template). */ +export const E2B_PI_DIR = + process.env.AGENTA_AGENT_SANDBOX_PI_DIR ?? "/root/.pi/agent"; + +/** + * Per-harness "already baked" levers, mirroring `AGENTA_AGENT_SANDBOX_PI_INSTALLED`. + * + * Pi's lever works by skipping a RUNNER-side step (`installPiInSandbox` in daytona.ts): Pi is + * never one of the daemon's `install-agent` targets, so the runner is the only thing that can + * decide to install it, and the env var gates that call directly. + * + * Codex, opencode, and claude are different: the `sandbox-agent/e2b` provider's `create()` + * unconditionally shells `sandbox-agent install-agent ` for claude/codex on every E2B + * sandbox (opencode is not even in its `DEFAULT_AGENTS` list, so it is never daemon-installed + * today). That call happens inside the `sandbox-agent` npm package the runner imports, before + * the runner sees the sandbox — there is no hook in `SandboxProvider`/`E2BProviderOptions` for + * the runner to skip it, and no env var the daemon itself reads to no-op the call. So these + * three levers do NOT reach a daemon skip flag the way Pi's does. + * + * What they document instead: the e2b.Dockerfile bakes each harness's ACP adapter + native CLI + * at the exact paths `sandbox-agent install-agent ` would have used + * (`~/.local/share/sandbox-agent/bin/agent_processes/`), and the daemon's own installer + * checks for an existing install before doing any work (`agent_manager.install_agent_process: + * already installed`, observed in the compiled daemon binary). So a baked template still turns + * every `install-agent` call into a fast no-op — the win is real, it just happens inside the + * daemon's own idempotency check rather than a runner-side skip. These constants exist so a + * baked custom template can flip the default to document that; they intentionally do not gate + * any runner behavior (there is none to gate). + */ +export const E2B_CODEX_INSTALLED = + process.env.AGENTA_AGENT_SANDBOX_CODEX_INSTALLED !== "false"; +export const E2B_OPENCODE_INSTALLED = + process.env.AGENTA_AGENT_SANDBOX_OPENCODE_INSTALLED !== "false"; +export const E2B_CLAUDE_INSTALLED = + process.env.AGENTA_AGENT_SANDBOX_CLAUDE_INSTALLED !== "false"; + +/** + * In-sandbox env for the E2B daemon: provider keys + Agenta extension env so the remote + * Pi traces and runs tools exactly like local. Pi is baked into the template so no + * PI_ACP_PI_COMMAND override is needed. + * + * The bake-status flags are informational only (see the doc comment on + * `E2B_CODEX_INSTALLED`/`E2B_OPENCODE_INSTALLED`/`E2B_CLAUDE_INSTALLED`): the daemon has no env + * var that skips `install-agent`, so these do not change daemon behavior. They are surfaced in + * the sandbox env so a `sandbox-agent server` log/support bundle can show what the operator's + * template intends to have baked, for debugging a template that silently fell out of date. + */ +export function e2bEnvVars( + piExtEnv: Record, + secrets: Record, +): Record { + return { + PI_CODING_AGENT_DIR: E2B_PI_DIR, + AGENTA_AGENT_SANDBOX_CODEX_INSTALLED: String(E2B_CODEX_INSTALLED), + AGENTA_AGENT_SANDBOX_OPENCODE_INSTALLED: String(E2B_OPENCODE_INSTALLED), + AGENTA_AGENT_SANDBOX_CLAUDE_INSTALLED: String(E2B_CLAUDE_INSTALLED), + ...piExtEnv, + ...secrets, + }; +} + +/** + * Upload Pi's fallback `auth.json` into an E2B sandbox. Best-effort. + */ +export async function uploadPiAuthToE2BSandbox( + sandbox: any, + log: Log = () => {}, +): Promise { + const localDir = + process.env.PI_CODING_AGENT_DIR || join(process.env.HOME ?? "", ".pi/agent"); + const authPath = join(localDir, "auth.json"); + if (!existsSync(authPath)) return; + try { + await sandbox.mkdirFs({ path: E2B_PI_DIR }); + await sandbox.writeFsFile( + { path: `${E2B_PI_DIR}/auth.json` }, + readFileSync(authPath, "utf-8"), + ); + const settingsPath = join(localDir, "settings.json"); + if (existsSync(settingsPath)) { + await sandbox.writeFsFile( + { path: `${E2B_PI_DIR}/settings.json` }, + readFileSync(settingsPath, "utf-8"), + ); + } + } catch (err) { + log(`pi auth upload skipped: ${(err as Error).message}`); + } +} + +export interface PrepareE2BPiAssetsInput { + sandbox: any; + plan: Pick< + RunPlan, + | "isPi" + | "hasApiKey" + | "credentialMode" + | "skillDirs" + | "hasSystemPrompt" + | "systemPrompt" + | "appendSystemPrompt" + >; + log?: Log; +} + +/** + * Push the Pi login fallback, Agenta extension, forced skills, and system prompts into an + * E2B sandbox. Pi is baked into the template — no in-sandbox install needed. + */ +export async function prepareE2BPiAssets({ + sandbox, + plan, + log = () => {}, +}: PrepareE2BPiAssetsInput): Promise { + if (!plan.isPi) return; + + if (shouldUploadOwnLogin(plan)) await uploadPiAuthToE2BSandbox(sandbox, log); + await uploadPiExtensionToSandbox(sandbox, E2B_PI_DIR, log); + if (plan.skillDirs.length > 0) { + await uploadSkillsToSandbox(sandbox, E2B_PI_DIR, plan.skillDirs, log); + } + if (plan.hasSystemPrompt) { + await uploadSystemPromptToSandbox( + sandbox, + E2B_PI_DIR, + plan.systemPrompt, + plan.appendSystemPrompt, + log, + ); + } +} diff --git a/services/runner/src/engines/sandbox_agent/mcp.ts b/services/runner/src/engines/sandbox_agent/mcp.ts index 668d999bf3..9d7970acbb 100644 --- a/services/runner/src/engines/sandbox_agent/mcp.ts +++ b/services/runner/src/engines/sandbox_agent/mcp.ts @@ -159,14 +159,15 @@ export interface BuildSessionMcpServersInput { capabilities: HarnessCapabilities; harness: string; /** - * True when the run executes in a REMOTE Daytona sandbox (the harness runs IN the sandbox, - * not on the runner host). Gates the internal gateway-tool channel: the channel's loopback - * (`127.0.0.1`) HTTP MCP URL resolves to the SANDBOX's loopback there, not the runner's, so - * advertising it would hand the in-sandbox harness an unreachable URL. On Daytona the channel - * is skipped and gateway tools are delivered through the file relay instead (the relay loop - * already polls the sandbox filesystem on Daytona — see `engines/sandbox_agent.ts`). See the - * Daytona guard in `buildSessionMcpServers`. + * True when the run executes in ANY remote sandbox (Daytona or E2B — the harness runs IN the + * sandbox, not on the runner host). Gates the internal gateway-tool channel: the channel's + * loopback (`127.0.0.1`) HTTP MCP URL resolves to the SANDBOX's loopback there, not the + * runner's, so advertising it would hand the in-sandbox harness an unreachable URL. On any + * remote sandbox the channel is skipped and gateway tools are delivered through the file relay + * instead (the relay loop already polls the sandbox filesystem — see `engines/sandbox_agent.ts`). */ + isRemote: boolean; + /** True specifically for Daytona; only used to name the provider in the relay log line. */ isDaytona: boolean; toolSpecs: ResolvedToolSpec[]; userMcpServers?: McpServerConfig[]; @@ -205,6 +206,7 @@ export async function buildSessionMcpServers({ isPi, capabilities, harness, + isRemote, isDaytona, toolSpecs, userMcpServers, @@ -223,16 +225,16 @@ export async function buildSessionMcpServers({ } // Layer 1: INTERNAL gateway-tool channel (do not merge with the user gate below). LOCAL ONLY: - // its advertised URL is a runner loopback (`127.0.0.1`), unreachable from a remote Daytona - // sandbox where the harness runs. On Daytona, skip the loopback HTTP advertisement and let the - // file relay deliver the tools (the relay loop polls the sandbox filesystem; see the Daytona + // its advertised URL is a runner loopback (`127.0.0.1`), unreachable from a remote sandbox + // where the harness runs. On any remote sandbox, skip the loopback HTTP advertisement and let + // the file relay deliver the tools (the relay loop polls the sandbox filesystem; see the // tool relay in `engines/sandbox_agent.ts`). - const internal = isDaytona + const internal = isRemote ? { servers: [], close: async () => {} } : await buildToolMcpServers(toolSpecs, relayDir, log); - if (isDaytona && toolSpecs.length > 0) { + if (isRemote && toolSpecs.length > 0) { log( - `daytona: ${toolSpecs.length} gateway tool(s) delivered via the file relay, not a ` + + `${isDaytona ? "daytona" : "e2b"}: ${toolSpecs.length} gateway tool(s) delivered via the file relay, not a ` + `loopback MCP URL (unreachable from the sandbox)`, ); } diff --git a/services/runner/src/engines/sandbox_agent/pi-assets.ts b/services/runner/src/engines/sandbox_agent/pi-assets.ts index 282aca706e..a14f4b1d9b 100644 --- a/services/runner/src/engines/sandbox_agent/pi-assets.ts +++ b/services/runner/src/engines/sandbox_agent/pi-assets.ts @@ -198,7 +198,7 @@ export interface PrepareLocalPiAssetsInput { plan: Pick< RunPlan, | "isPi" - | "isDaytona" + | "isRemoteSandbox" | "skillDirs" | "hasSystemPrompt" | "systemPrompt" @@ -219,7 +219,7 @@ export function prepareLocalPiAssets({ env, log = () => {}, }: PrepareLocalPiAssetsInput): string | undefined { - if (!plan.isPi || plan.isDaytona) return undefined; + if (!plan.isPi || plan.isRemoteSandbox) return undefined; if (plan.skillDirs.length > 0 || plan.hasSystemPrompt) { const runAgentDir = prepareLocalAgentDir( diff --git a/services/runner/src/engines/sandbox_agent/provider.ts b/services/runner/src/engines/sandbox_agent/provider.ts index 17baa12a21..72bef18200 100644 --- a/services/runner/src/engines/sandbox_agent/provider.ts +++ b/services/runner/src/engines/sandbox_agent/provider.ts @@ -1,8 +1,14 @@ import { local } from "sandbox-agent/local"; import { daytona } from "sandbox-agent/daytona"; +import { e2b } from "sandbox-agent/e2b"; import type { SandboxPermission } from "../../protocol.ts"; import { daytonaEnvVars } from "./daytona.ts"; +import { + E2B_CLAUDE_INSTALLED, + E2B_CODEX_INSTALLED, + E2B_OPENCODE_INSTALLED, +} from "./e2b.ts"; /** * Translate the Layer 2 network policy into Daytona create fields. Daytona enforces egress @@ -98,6 +104,52 @@ export function buildDaytonaCreate( }; } +/** Default E2B sandbox timeout (ms): self-reaps a leaked sandbox that process-KILL skips the `finally`. */ +export const DEFAULT_E2B_TIMEOUT_MS = 30 * 60 * 1000; // 30 minutes + +/** E2B sandbox timeout ms from env, clamped to >= 1 ms (0 would disable the backstop). */ +export function e2bTimeoutMs( + rawValue: string | undefined = process.env.E2B_TIMEOUT_MS, +): number { + const parsed = Number(rawValue); + if (!Number.isFinite(parsed) || parsed < 1) return DEFAULT_E2B_TIMEOUT_MS; + return Math.floor(parsed); +} + +export interface E2BCreateOptions { + envs: Record; + timeoutMs: number; + autoPause: boolean; +} + +/** + * Build the E2B provider options from the runner's env + the resolved run inputs. + * + * Pulled out as a pure function so the create options can be tested without constructing + * a real E2B client (which needs E2B_API_KEY). + * + * The `AGENTA_AGENT_SANDBOX_{CODEX,OPENCODE,CLAUDE}_INSTALLED` flags are carried into the + * sandbox env for visibility only (see the doc comment on `E2B_CODEX_INSTALLED` in e2b.ts): + * unlike Pi's `AGENTA_AGENT_SANDBOX_PI_INSTALLED`, the daemon has no corresponding skip + * mechanism for these three, so setting them to "false" does not change what the daemon does. + */ +export function buildE2BCreate( + piExtEnv: Record, + secrets: Record, +): E2BCreateOptions { + return { + envs: { + AGENTA_AGENT_SANDBOX_CODEX_INSTALLED: String(E2B_CODEX_INSTALLED), + AGENTA_AGENT_SANDBOX_OPENCODE_INSTALLED: String(E2B_OPENCODE_INSTALLED), + AGENTA_AGENT_SANDBOX_CLAUDE_INSTALLED: String(E2B_CLAUDE_INSTALLED), + ...piExtEnv, + ...secrets, + }, + timeoutMs: e2bTimeoutMs(), + autoPause: true, + }; +} + /** * Build the sandbox-agent provider for the requested axis. * @@ -124,6 +176,17 @@ export function buildSandboxProvider( }); } + if (sandboxId === "e2b") { + const template = process.env.E2B_TEMPLATE ?? "agenta-sandbox-agent"; + const { envs, timeoutMs, autoPause } = buildE2BCreate(piExtEnv, secrets); + return e2b({ + template, + create: { envs } as any, + timeoutMs, + autoPause, + }); + } + // local: spawn `sandbox-agent server` on this host with the daemon env merged in. const logMode = (process.env.SANDBOX_AGENT_LOG_LEVEL ?? "silent") as any; return local({ env, binaryPath, log: logMode }); diff --git a/services/runner/src/engines/sandbox_agent/run-plan.ts b/services/runner/src/engines/sandbox_agent/run-plan.ts index 7e92ade992..b69caa8586 100644 --- a/services/runner/src/engines/sandbox_agent/run-plan.ts +++ b/services/runner/src/engines/sandbox_agent/run-plan.ts @@ -37,6 +37,11 @@ export const LOCAL_NETWORK_UNSUPPORTED_MESSAGE = "Network sandbox policy is not enforceable on the local sandbox (the sidecar runs on this " + "host with no egress control); run on daytona, or remove sandbox_permission.network."; +/** A restricted `network` policy on E2B is not enforceable (the e2b provider exposes no egress control). */ +export const E2B_NETWORK_UNSUPPORTED_MESSAGE = + "Network sandbox policy is not enforceable on the e2b sandbox (the sandbox-agent/e2b " + + "provider exposes no egress control); run on daytona, or remove sandbox_permission.network."; + /** `filesystem` confinement is declared on the wire but applied by no backend. */ export const FILESYSTEM_UNSUPPORTED_MESSAGE = "Filesystem sandbox policy is not implemented (no backend applies a filesystem jail); " + @@ -48,6 +53,9 @@ export interface RunPlan { sandboxId: string; isPi: boolean; isDaytona: boolean; + isE2B: boolean; + /** True for any remote sandbox (`isDaytona || isE2B`); use for remoteness-only checks. */ + isRemoteSandbox: boolean; prompt: string; turnText: string; agentsMd?: string; @@ -104,6 +112,7 @@ export interface BuildRunPlanDeps { sandboxProvider?: string; createLocalCwd?: (durableCwd?: string) => string; createDaytonaCwd?: (durableCwd?: string) => string; + createE2BCwd?: () => string; /** Pre-computed durable cwd derived from the sign prefix; when set, skips the ephemeral helpers. */ durableCwd?: string; resolveSkillDirs?: typeof defaultResolveSkillDirs; @@ -149,12 +158,17 @@ function defaultDaytonaCwd(durableCwd?: string): string { return durableCwd ?? `/home/sandbox/agenta-${randomBytes(6).toString("hex")}`; } +function defaultE2BCwd(): string { + return `/root/work/agenta-${randomBytes(6).toString("hex")}`; +} + export function buildRunPlan( request: AgentRunRequest, { sandboxProvider = process.env.SANDBOX_AGENT_PROVIDER, createLocalCwd = defaultLocalCwd, createDaytonaCwd = defaultDaytonaCwd, + createE2BCwd = defaultE2BCwd, durableCwd, resolveSkillDirs = defaultResolveSkillDirs, log = () => {}, @@ -188,6 +202,8 @@ export function buildRunPlan( const isPi = acpAgent === "pi"; const isDaytona = sandboxId === "daytona"; + const isE2B = sandboxId === "e2b"; + const isRemoteSandbox = isDaytona || isE2B; const secrets = request.secrets ?? {}; const legacyHarnessApiKeyVar = @@ -209,10 +225,14 @@ export function buildRunPlan( // A restricted `network` policy on the LOCAL sandbox cannot be enforced (the sidecar runs on // this host with no per-run egress control), so it errors regardless of `enforcement`. On - // Daytona the policy IS applied (`provider.ts` `daytonaNetworkFields`). + // Daytona the policy IS applied (`provider.ts` `daytonaNetworkFields`). E2B exposes no egress + // control in the sandbox-agent/e2b wrapper, so it is refused like local. const network = request.sandboxPermission?.network; const networkRestricted = !!network && (network.mode ?? "on") !== "on"; - if (networkRestricted && !isDaytona) { + if (networkRestricted && isE2B) { + return { ok: false, error: E2B_NETWORK_UNSUPPORTED_MESSAGE }; + } + if (networkRestricted && !isDaytona && !isE2B) { return { ok: false, error: LOCAL_NETWORK_UNSUPPORTED_MESSAGE }; } @@ -266,13 +286,21 @@ export function buildRunPlan( } } - const cwd = isDaytona ? createDaytonaCwd(durableCwd) : createLocalCwd(durableCwd); + const cwd = isDaytona + ? createDaytonaCwd(durableCwd) + : isE2B + ? createE2BCwd() + : createLocalCwd(durableCwd); // The tool-relay scratch (req/res JSON) is ephemeral runner<->child IPC, NOT durable session // data — keep it OFF the geesefs-mounted cwd. A relay dir inside the mount routes every tool // call through FUSE/S3, so a flaky mount surfaces as ENOTCONN on the relay file. Use an // ephemeral sibling: a plain host tmp dir (local) or an in-VM dir (daytona), never the mount. + // E2B: the relay is polled through the sandbox FS API, so the dir must live IN the sandbox; + // the E2B cwd is never geesefs-mounted, so nesting under it is safe. const relayBase = isDaytona ? "/home/sandbox/agenta/relay" : join(tmpdir(), "agenta", "relay"); - const relayDir = join(relayBase, basename(cwd)); + const relayDir = isE2B + ? `${cwd}/.agenta-tools` + : join(relayBase, basename(cwd)); // Skills materialize once from the resolved inline packages. Pi/Agenta consume the dirs // through Pi's agent-dir user scope; Claude consumes the same packages from the project-local @@ -312,6 +340,8 @@ export function buildRunPlan( sandboxId, isPi, isDaytona, + isE2B, + isRemoteSandbox, prompt, turnText: buildTurnText(request), agentsMd: request.agentsMd?.trim() || undefined, diff --git a/services/runner/src/engines/sandbox_agent/usage.ts b/services/runner/src/engines/sandbox_agent/usage.ts index 1d08b3447b..02ee423c95 100644 --- a/services/runner/src/engines/sandbox_agent/usage.ts +++ b/services/runner/src/engines/sandbox_agent/usage.ts @@ -6,12 +6,12 @@ import type { AgentRunResult, AgentUsage } from "../../protocol.ts"; export async function readRunUsage( sandbox: any, path: string | undefined, - isDaytona: boolean, + isRemote: boolean, ): Promise { if (!path) return undefined; try { let raw: string; - if (isDaytona) { + if (isRemote) { const bytes = await sandbox.readFsFile({ path }); raw = typeof bytes === "string" ? bytes : new TextDecoder().decode(bytes); } else { @@ -43,18 +43,18 @@ export function mergePromptAndStreamUsage( export async function resolveRunUsage({ sandbox, usageOutPath, - isDaytona, + isRemote, promptResult, streamUsage, }: { sandbox: any; usageOutPath: string | undefined; - isDaytona: boolean; + isRemote: boolean; promptResult: any; streamUsage: AgentUsage | undefined; }): Promise { return ( - (await readRunUsage(sandbox, usageOutPath, isDaytona)) ?? + (await readRunUsage(sandbox, usageOutPath, isRemote)) ?? mergePromptAndStreamUsage(promptResult, streamUsage) ); } diff --git a/services/runner/src/engines/sandbox_agent/workspace.ts b/services/runner/src/engines/sandbox_agent/workspace.ts index b5224bcf30..32bfceb16d 100644 --- a/services/runner/src/engines/sandbox_agent/workspace.ts +++ b/services/runner/src/engines/sandbox_agent/workspace.ts @@ -1,5 +1,6 @@ import { cpSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; -import { dirname, join } from "node:path"; +import { dirname, join, resolve, sep } from "node:path"; +import { dirname as posixDirname, join as posixJoin, resolve as posixResolve } from "node:path/posix"; import type { RunPlan } from "./run-plan.ts"; import { uploadDirToSandbox } from "./pi-assets.ts"; @@ -14,7 +15,7 @@ export interface PrepareWorkspaceInput { sandbox: any; plan: Pick< RunPlan, - | "isDaytona" + | "isRemoteSandbox" | "isPi" | "cwd" | "relayDir" @@ -27,6 +28,20 @@ export interface PrepareWorkspaceInput { log?: Log; } +/** Rejects a harnessFile path that resolves outside `cwd` (e.g. `../escape`). */ +function assertContained(cwd: string, filePath: string, resolvedPath: string): void { + if (resolvedPath !== cwd && !resolvedPath.startsWith(cwd + sep)) { + throw new Error(`harnessFile path escapes workspace cwd: ${filePath}`); + } +} + +/** Posix variant of `assertContained` for remote sandbox paths (always posix, regardless of host OS). */ +function assertContainedPosix(cwd: string, filePath: string, resolvedPath: string): void { + if (resolvedPath !== cwd && !resolvedPath.startsWith(cwd + "/")) { + throw new Error(`harnessFile path escapes workspace cwd: ${filePath}`); + } +} + /** * Prepare the run cwd, relay directory, optional AGENTS.md, generic `harnessFiles`, and * non-Pi skill packages for local or Daytona runs. `harnessFiles` are written blind: the @@ -42,7 +57,7 @@ export async function prepareWorkspace({ const harnessFiles = plan.harnessFiles ?? []; const projectSkillRoot = plan.isPi ? undefined : `.${plan.acpAgent}/skills`; - if (plan.isDaytona) { + if (plan.isRemoteSandbox) { await sandbox.mkdirFs({ path: plan.cwd }).catch((err: Error) => { log(`workspace mkdir skipped: ${err.message}`); }); @@ -55,8 +70,9 @@ export async function prepareWorkspace({ await sandbox.writeFsFile({ path: `${plan.cwd}/AGENTS.md` }, plan.agentsMd); } for (const file of harnessFiles) { - const path = `${plan.cwd}/${file.path}`; - const parent = dirname(path); + const path = posixJoin(plan.cwd, file.path); + assertContainedPosix(posixResolve(plan.cwd), file.path, posixResolve(path)); + const parent = posixDirname(path); await sandbox.mkdirFs({ path: parent }).catch((err: Error) => { log(`harness file dir mkdir skipped: ${err.message}`); }); @@ -80,6 +96,7 @@ export async function prepareWorkspace({ if (plan.agentsMd) writeFileSync(join(plan.cwd, "AGENTS.md"), plan.agentsMd, "utf-8"); for (const file of harnessFiles) { const path = join(plan.cwd, file.path); + assertContained(resolve(plan.cwd), file.path, resolve(path)); mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, file.content, "utf-8"); } diff --git a/services/runner/tests/unit/sandbox-agent-e2b-keepalive.test.ts b/services/runner/tests/unit/sandbox-agent-e2b-keepalive.test.ts new file mode 100644 index 0000000000..f8c9e23187 --- /dev/null +++ b/services/runner/tests/unit/sandbox-agent-e2b-keepalive.test.ts @@ -0,0 +1,112 @@ +/** + * Unit tests for the E2B idle-refresh keepalive (D3). + * + * `startE2BKeepalive` takes an injectable `extend` function so this never touches the real + * `@e2b/code-interpreter` SDK (no E2B_API_KEY / network needed) — see `extendE2BSandboxTimeout` + * in e2b-keepalive.ts for the real wiring. + * + * Run: pnpm test (or: pnpm exec vitest run tests/unit/sandbox-agent-e2b-keepalive.test.ts) + */ +import { afterEach, beforeEach, describe, it, vi } from "vitest"; +import assert from "node:assert/strict"; + +import { + e2bKeepaliveIntervalMs, + startE2BKeepalive, +} from "../../src/engines/sandbox_agent/e2b-keepalive.ts"; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("e2bKeepaliveIntervalMs", () => { + it("divides the timeout by 3", () => { + assert.equal(e2bKeepaliveIntervalMs(30 * 60 * 1000), 10 * 60 * 1000); + }); + + it("clamps to a 1s floor so a tiny timeout cannot busy-loop", () => { + assert.equal(e2bKeepaliveIntervalMs(300), 1000); + }); + + it("floors a fractional result", () => { + assert.equal(e2bKeepaliveIntervalMs(10_000), 3333); + }); +}); + +describe("startE2BKeepalive", () => { + it("does not refresh immediately on start (the sandbox already has a fresh timeout)", () => { + const calls: Array<[string, number]> = []; + const extend = async (sandboxId: string, timeoutMs: number) => { + calls.push([sandboxId, timeoutMs]); + }; + const handle = startE2BKeepalive("sbx-1", 30_000, extend); + assert.equal(calls.length, 0); + handle.stop(); + }); + + it("refreshes on each interval tick with the sandbox id and configured timeout", async () => { + const calls: Array<[string, number]> = []; + const extend = async (sandboxId: string, timeoutMs: number) => { + calls.push([sandboxId, timeoutMs]); + }; + const handle = startE2BKeepalive("sbx-2", 30_000, extend); + + await vi.advanceTimersByTimeAsync(10_000); + assert.equal(calls.length, 1); + assert.deepEqual(calls[0], ["sbx-2", 30_000]); + + await vi.advanceTimersByTimeAsync(10_000); + assert.equal(calls.length, 2); + + await vi.advanceTimersByTimeAsync(10_000); + assert.equal(calls.length, 3); + + handle.stop(); + }); + + it("stop() halts further refreshes", async () => { + const calls: Array<[string, number]> = []; + const extend = async (sandboxId: string, timeoutMs: number) => { + calls.push([sandboxId, timeoutMs]); + }; + const handle = startE2BKeepalive("sbx-3", 30_000, extend); + + await vi.advanceTimersByTimeAsync(10_000); + assert.equal(calls.length, 1); + + handle.stop(); + + await vi.advanceTimersByTimeAsync(60_000); + assert.equal(calls.length, 1, "no more refreshes should fire after stop()"); + }); + + it("stop() is idempotent (safe to call twice, e.g. from a finally after an early return)", () => { + const extend = async () => {}; + const handle = startE2BKeepalive("sbx-4", 30_000, extend); + handle.stop(); + assert.doesNotThrow(() => handle.stop()); + }); + + it("swallows a rejected extend() and keeps refreshing on the next tick", async () => { + let attempt = 0; + const extend = async () => { + attempt += 1; + if (attempt === 1) throw new Error("transient e2b api error"); + }; + const logs: string[] = []; + const handle = startE2BKeepalive("sbx-5", 30_000, extend, (m) => logs.push(m)); + + await vi.advanceTimersByTimeAsync(10_000); + assert.equal(attempt, 1); + assert.ok(logs.some((l) => l.includes("sbx-5")), "expected the failure to be logged"); + + await vi.advanceTimersByTimeAsync(10_000); + assert.equal(attempt, 2, "a failed refresh must not stop the interval"); + + handle.stop(); + }); +}); diff --git a/services/runner/tests/unit/sandbox-agent-e2b-provider.test.ts b/services/runner/tests/unit/sandbox-agent-e2b-provider.test.ts new file mode 100644 index 0000000000..f2c50e7915 --- /dev/null +++ b/services/runner/tests/unit/sandbox-agent-e2b-provider.test.ts @@ -0,0 +1,96 @@ +/** + * Unit tests for the E2B provider options and leak-backstop logic. + * + * `buildE2BCreate` is tested directly because the real `e2b()` provider constructs an + * E2B client (needs E2B_API_KEY), so it cannot be inspected through `buildSandboxProvider`. + * + * Run: pnpm test (or: pnpm exec vitest run tests/unit/sandbox-agent-e2b-provider.test.ts) + */ +import { afterEach, describe, it } from "vitest"; +import assert from "node:assert/strict"; + +import { + DEFAULT_E2B_TIMEOUT_MS, + buildE2BCreate, + e2bTimeoutMs, +} from "../../src/engines/sandbox_agent/provider.ts"; + +const TIMEOUT_ENV = "E2B_TIMEOUT_MS"; +const previousTimeout = process.env[TIMEOUT_ENV]; + +afterEach(() => { + if (previousTimeout === undefined) delete process.env[TIMEOUT_ENV]; + else process.env[TIMEOUT_ENV] = previousTimeout; +}); + +describe("e2bTimeoutMs (leak backstop)", () => { + it("uses the env value when it is a positive integer", () => { + assert.equal(e2bTimeoutMs("60000"), 60000); + }); + + it("floors a fractional env value", () => { + assert.equal(e2bTimeoutMs("12000.9"), 12000); + }); + + it("falls back to the default when the env is unset", () => { + assert.equal(e2bTimeoutMs(undefined), DEFAULT_E2B_TIMEOUT_MS); + }); + + it("falls back to the default for a non-numeric env value", () => { + assert.equal(e2bTimeoutMs("soon"), DEFAULT_E2B_TIMEOUT_MS); + }); + + it("clamps 0 to the default so the backstop is never disabled", () => { + assert.equal(e2bTimeoutMs("0"), DEFAULT_E2B_TIMEOUT_MS); + }); + + it("clamps a negative value to the default", () => { + assert.equal(e2bTimeoutMs("-5000"), DEFAULT_E2B_TIMEOUT_MS); + }); + + it("the default is a positive backstop (never zero)", () => { + assert.ok(DEFAULT_E2B_TIMEOUT_MS >= 1); + }); +}); + +describe("buildE2BCreate (leak backstop on the create object)", () => { + it("carries a positive timeoutMs + autoPause by default (self-reaps a leak)", () => { + delete process.env[TIMEOUT_ENV]; + const create = buildE2BCreate({}, {}); + assert.equal(create.timeoutMs, DEFAULT_E2B_TIMEOUT_MS); + assert.ok(create.timeoutMs > 0, "timeoutMs must be > 0 or the sandbox never self-reaps on process KILL"); + assert.equal(create.autoPause, true); + }); + + it("carries the env-configured timeoutMs", () => { + process.env[TIMEOUT_ENV] = "120000"; + const create = buildE2BCreate({}, {}); + assert.equal(create.timeoutMs, 120000); + assert.equal(create.autoPause, true); + }); + + it("merges piExtEnv and secrets into envs", () => { + const create = buildE2BCreate( + { TRACEPARENT: "trace-id", OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://otel" }, + { OPENAI_API_KEY: "sk-test" }, + ); + assert.equal(create.envs.TRACEPARENT, "trace-id"); + assert.equal(create.envs.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT, "http://otel"); + assert.equal(create.envs.OPENAI_API_KEY, "sk-test"); + }); + + it("carries the bake-status flags by default (true = baked)", () => { + const create = buildE2BCreate({}, {}); + assert.equal(create.envs.AGENTA_AGENT_SANDBOX_CODEX_INSTALLED, "true"); + assert.equal(create.envs.AGENTA_AGENT_SANDBOX_OPENCODE_INSTALLED, "true"); + assert.equal(create.envs.AGENTA_AGENT_SANDBOX_CLAUDE_INSTALLED, "true"); + }); + + it("piExtEnv/secrets are applied after the bake-status flags (can override)", () => { + const create = buildE2BCreate( + { AGENTA_AGENT_SANDBOX_CODEX_INSTALLED: "false" }, + {}, + ); + assert.equal(create.envs.AGENTA_AGENT_SANDBOX_CODEX_INSTALLED, "false"); + }); +}); diff --git a/services/runner/tests/unit/sandbox-agent-e2b-run-plan.test.ts b/services/runner/tests/unit/sandbox-agent-e2b-run-plan.test.ts new file mode 100644 index 0000000000..02c608b2bb --- /dev/null +++ b/services/runner/tests/unit/sandbox-agent-e2b-run-plan.test.ts @@ -0,0 +1,152 @@ +/** + * Unit tests for E2B-specific run-plan normalization. + * + * Run: pnpm test (or: pnpm exec vitest run tests/unit/sandbox-agent-e2b-run-plan.test.ts) + */ +import { describe, it } from "vitest"; +import assert from "node:assert/strict"; + +import type { AgentRunRequest } from "../../src/protocol.ts"; +import { + buildRunPlan, + E2B_NETWORK_UNSUPPORTED_MESSAGE, +} from "../../src/engines/sandbox_agent/run-plan.ts"; + +describe("buildRunPlan — E2B sandbox", () => { + it("sets isE2B and uses the E2B cwd factory", () => { + const result = buildRunPlan( + { + harness: "pi_core", + sandbox: "e2b", + messages: [{ role: "user", content: "hello" }], + } as AgentRunRequest, + { createE2BCwd: () => "/root/work/agenta-abc123" }, + ); + + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.plan.isE2B, true); + assert.equal(result.plan.isDaytona, false); + assert.equal(result.plan.isRemoteSandbox, true); + assert.equal(result.plan.sandboxId, "e2b"); + assert.equal(result.plan.cwd, "/root/work/agenta-abc123"); + assert.equal(result.plan.relayDir, "/root/work/agenta-abc123/.agenta-tools"); + }); + + it("local runs have isE2B=false and isRemoteSandbox=false", () => { + const result = buildRunPlan( + { + harness: "pi_core", + sandbox: "local", + messages: [{ role: "user", content: "hello" }], + } as AgentRunRequest, + { createLocalCwd: () => "/tmp/local-cwd" }, + ); + + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.plan.isE2B, false); + assert.equal(result.plan.isRemoteSandbox, false); + }); + + it("daytona runs have isE2B=false and isRemoteSandbox=true", () => { + const result = buildRunPlan( + { + harness: "claude", + sandbox: "daytona", + messages: [{ role: "user", content: "hello" }], + } as AgentRunRequest, + { createDaytonaCwd: () => "/home/sandbox/agenta-fixed" }, + ); + + assert.equal(result.ok, true); + if (!result.ok) return; + assert.equal(result.plan.isE2B, false); + assert.equal(result.plan.isDaytona, true); + assert.equal(result.plan.isRemoteSandbox, true); + }); + + it("refuses a restricted-network E2B run under strict (no egress control)", () => { + const result = buildRunPlan( + { + harness: "pi_core", + sandbox: "e2b", + messages: [{ role: "user", content: "hello" }], + sandboxPermission: { + network: { mode: "off" }, + enforcement: "strict", + }, + } as AgentRunRequest, + { createE2BCwd: () => "/root/work/agenta-abc123" }, + ); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.match(result.error, /not enforceable on the e2b sandbox/); + }); + + it("refuses a restricted-network E2B run even under best_effort (no silent unenforced boundary)", () => { + const result = buildRunPlan( + { + harness: "pi_core", + sandbox: "e2b", + messages: [{ role: "user", content: "hello" }], + sandboxPermission: { + network: { mode: "allowlist", allowlist: ["10.0.0.0/8"] }, + enforcement: "best_effort", + }, + } as AgentRunRequest, + { createE2BCwd: () => "/root/work/agenta-abc123" }, + ); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.match(result.error, /not enforceable on the e2b sandbox/); + }); + + it("the E2B refusal message is the E2B_NETWORK_UNSUPPORTED_MESSAGE constant", () => { + const result = buildRunPlan( + { + harness: "pi_core", + sandbox: "e2b", + messages: [{ role: "user", content: "hello" }], + sandboxPermission: { network: { mode: "off" }, enforcement: "strict" }, + } as AgentRunRequest, + { createE2BCwd: () => "/root/work/agenta-abc123" }, + ); + + assert.equal(result.ok, false); + if (result.ok) return; + assert.equal(result.error, E2B_NETWORK_UNSUPPORTED_MESSAGE); + }); + + it("allows an unrestricted (network: on) E2B run", () => { + const result = buildRunPlan( + { + harness: "pi_core", + sandbox: "e2b", + messages: [{ role: "user", content: "hello" }], + sandboxPermission: { + network: { mode: "on" }, + enforcement: "strict", + }, + } as AgentRunRequest, + { createE2BCwd: () => "/root/work/agenta-abc123" }, + ); + + assert.equal(result.ok, true); + }); + + it("allows an E2B run with no sandbox permission", () => { + const result = buildRunPlan( + { + harness: "pi_core", + sandbox: "e2b", + messages: [{ role: "user", content: "hello" }], + } as AgentRunRequest, + { createE2BCwd: () => "/root/work/agenta-abc123" }, + ); + + assert.equal(result.ok, true); + }); +}); diff --git a/services/runner/tests/unit/sandbox-agent-run-plan.test.ts b/services/runner/tests/unit/sandbox-agent-run-plan.test.ts index 6612b08510..8b37ca0a68 100644 --- a/services/runner/tests/unit/sandbox-agent-run-plan.test.ts +++ b/services/runner/tests/unit/sandbox-agent-run-plan.test.ts @@ -77,6 +77,7 @@ describe("buildRunPlan", () => { assert.equal(result.plan.harness, "pi_agenta"); assert.equal(result.plan.acpAgent, "pi"); assert.equal(result.plan.sandboxId, "local"); + assert.equal(result.plan.isRemoteSandbox, false); assert.equal(result.plan.cwd, "/tmp/local-cwd"); // The relay dir + usage capture are ephemeral runner files kept OFF the (possibly geesefs) // cwd: an ephemeral sibling whose leaf is the cwd basename. @@ -593,6 +594,7 @@ describe("buildRunPlan", () => { assert.equal(result.plan.acpAgent, "claude"); assert.equal(result.plan.isPi, false); assert.equal(result.plan.isDaytona, true); + assert.equal(result.plan.isRemoteSandbox, true); assert.equal(result.plan.cwd, "/home/sandbox/agenta-fixed"); assert.equal(result.plan.usageOutPath, undefined); assert.equal(result.plan.legacyHarnessApiKeyVar, "ANTHROPIC_API_KEY"); diff --git a/services/runner/tests/unit/sandbox-agent-usage.test.ts b/services/runner/tests/unit/sandbox-agent-usage.test.ts index a43b7c2bb9..6022d6093c 100644 --- a/services/runner/tests/unit/sandbox-agent-usage.test.ts +++ b/services/runner/tests/unit/sandbox-agent-usage.test.ts @@ -72,7 +72,7 @@ describe("resolveRunUsage", () => { await resolveRunUsage({ sandbox: {}, usageOutPath: file, - isDaytona: false, + isRemote: false, promptResult: { usage: { inputTokens: 99, outputTokens: 99 } }, streamUsage: { input: 0, output: 0, total: 0, cost: 1 }, }), diff --git a/services/runner/tests/unit/sandbox-agent-workspace.test.ts b/services/runner/tests/unit/sandbox-agent-workspace.test.ts index 25a95ea1ad..d9206cf40a 100644 --- a/services/runner/tests/unit/sandbox-agent-workspace.test.ts +++ b/services/runner/tests/unit/sandbox-agent-workspace.test.ts @@ -30,7 +30,7 @@ describe("prepareWorkspace", () => { const workspace = await prepareWorkspace({ sandbox: {}, plan: { - isDaytona: false, + isRemoteSandbox: false, cwd, relayDir: join(cwd, ".agenta-tools"), useToolRelay: true, @@ -69,7 +69,7 @@ describe("prepareWorkspace", () => { const workspace = await prepareWorkspace({ sandbox: {}, plan: { - isDaytona: false, + isRemoteSandbox: false, cwd, relayDir: join(cwd, ".agenta-tools"), useToolRelay: false, @@ -95,7 +95,7 @@ describe("prepareWorkspace", () => { await prepareWorkspace({ sandbox: {}, plan: { - isDaytona: false, + isRemoteSandbox: false, cwd, relayDir: join(cwd, ".agenta-tools"), useToolRelay: false, @@ -120,7 +120,7 @@ describe("prepareWorkspace", () => { const workspace = await prepareWorkspace({ sandbox, plan: { - isDaytona: true, + isRemoteSandbox: true, cwd: "/home/sandbox/agenta-fixed", relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", useToolRelay: true, @@ -155,7 +155,7 @@ describe("prepareWorkspace", () => { await prepareWorkspace({ sandbox, plan: { - isDaytona: true, + isRemoteSandbox: true, cwd: "/home/sandbox/agenta-fixed", relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", useToolRelay: false, @@ -193,7 +193,7 @@ describe("prepareWorkspace", () => { await prepareWorkspace({ sandbox, plan: { - isDaytona: true, + isRemoteSandbox: true, cwd: "/home/sandbox/agenta-fixed", relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", useToolRelay: false, @@ -219,7 +219,7 @@ describe("prepareWorkspace", () => { await prepareWorkspace({ sandbox: {}, plan: { - isDaytona: false, + isRemoteSandbox: false, cwd, relayDir: join(cwd, ".agenta-tools"), useToolRelay: false, @@ -251,7 +251,7 @@ describe("prepareWorkspace", () => { await prepareWorkspace({ sandbox, plan: { - isDaytona: true, + isRemoteSandbox: true, cwd: "/home/sandbox/agenta-fixed", relayDir: "/home/sandbox/agenta-fixed/.agenta-tools", useToolRelay: false, @@ -271,4 +271,183 @@ describe("prepareWorkspace", () => { "SKILL.md is uploaded to Claude's project-local skill tree", ); }); + + it("prepares an E2B cwd through the sandbox fs API (same path as Daytona)", async () => { + const calls: Array<{ op: "mkdir" | "write"; path: string; body?: string }> = []; + const sandbox = { + mkdirFs: async ({ path }: { path: string }) => calls.push({ op: "mkdir", path }), + writeFsFile: async ({ path }: { path: string }, body: string) => + calls.push({ op: "write", path, body }), + }; + + const workspace = await prepareWorkspace({ + sandbox, + plan: { + isRemoteSandbox: true, + cwd: "/root/work/agenta-e2btest", + relayDir: "/root/work/agenta-e2btest/.agenta-tools", + useToolRelay: true, + agentsMd: "agent instructions", + acpAgent: "claude", + isPi: false, + skillDirs: [], + }, + }); + await workspace.cleanup(); + + assert.deepEqual(calls, [ + { op: "mkdir", path: "/root/work/agenta-e2btest" }, + { op: "mkdir", path: "/root/work/agenta-e2btest/.agenta-tools" }, + { + op: "write", + path: "/root/work/agenta-e2btest/AGENTS.md", + body: "agent instructions", + }, + ]); + }); + + it("writes a nested harnessFiles entry on E2B via the fs API", async () => { + const calls: Array<{ op: "mkdir" | "write"; path: string; body?: string }> = []; + const sandbox = { + mkdirFs: async ({ path }: { path: string }) => calls.push({ op: "mkdir", path }), + writeFsFile: async ({ path }: { path: string }, body: string) => + calls.push({ op: "write", path, body }), + }; + const content = JSON.stringify( + { permissions: { defaultMode: "acceptEdits", deny: ["WebFetch"] } }, + null, + 2, + ); + + await prepareWorkspace({ + sandbox, + plan: { + isRemoteSandbox: true, + cwd: "/root/work/agenta-e2btest", + relayDir: "/root/work/agenta-e2btest/.agenta-tools", + useToolRelay: false, + agentsMd: undefined, + acpAgent: "claude", + isPi: false, + harnessFiles: [{ path: ".claude/settings.json", content }], + skillDirs: [], + }, + }); + + const claudeDir = calls.find( + (c) => c.op === "mkdir" && c.path === "/root/work/agenta-e2btest/.claude", + ); + assert.ok(claudeDir, ".claude dir is created via the fs API on E2B"); + const write = calls.find( + (c) => + c.op === "write" && c.path === "/root/work/agenta-e2btest/.claude/settings.json", + ); + assert.ok(write, "settings.json is written via the fs API on E2B"); + assert.equal(write!.body, content); + }); + + it("uploads Claude skills into the project-local .claude/skills tree on E2B", async () => { + const calls: Array<{ op: "mkdir" | "write"; path: string; body?: string }> = []; + const skillDir = tempDir(); + writeFileSync(join(skillDir, "SKILL.md"), "skill-content", "utf-8"); + const sandbox = { + mkdirFs: async ({ path }: { path: string }) => calls.push({ op: "mkdir", path }), + writeFsFile: async ({ path }: { path: string }, body: string) => + calls.push({ op: "write", path, body }), + }; + + await prepareWorkspace({ + sandbox, + plan: { + isRemoteSandbox: true, + cwd: "/root/work/agenta-e2btest", + relayDir: "/root/work/agenta-e2btest/.agenta-tools", + useToolRelay: false, + acpAgent: "claude", + isPi: false, + skillDirs: [{ name: "my-skill", dir: skillDir }], + }, + }); + + assert.ok( + calls.some( + (c) => + c.op === "write" && + c.path === "/root/work/agenta-e2btest/.claude/skills/my-skill/SKILL.md", + ), + "SKILL.md is uploaded to Claude's project-local skill tree on E2B", + ); + }); + + it("writes no harness file on E2B for a plan with no harnessFiles", async () => { + const calls: Array<{ op: "mkdir" | "write"; path: string; body?: string }> = []; + const sandbox = { + mkdirFs: async ({ path }: { path: string }) => calls.push({ op: "mkdir", path }), + writeFsFile: async ({ path }: { path: string }, body: string) => + calls.push({ op: "write", path, body }), + }; + + await prepareWorkspace({ + sandbox, + plan: { + isRemoteSandbox: true, + cwd: "/root/work/agenta-e2btest", + relayDir: "/root/work/agenta-e2btest/.agenta-tools", + useToolRelay: false, + acpAgent: "pi", + isPi: true, + skillDirs: [], + }, + }); + + assert.ok( + !calls.some((c) => c.path.includes(".claude")), + "no .claude path is touched on a Pi-on-E2B run", + ); + }); + + it("rejects a harnessFiles path-traversal escape for a local run", async () => { + const cwd = tempDir(); + + await assert.rejects( + prepareWorkspace({ + sandbox: {}, + plan: { + isRemoteSandbox: false, + cwd, + relayDir: join(cwd, ".agenta-tools"), + useToolRelay: false, + acpAgent: "claude", + isPi: false, + harnessFiles: [{ path: "../escape", content: "evil" }], + skillDirs: [], + }, + }), + /escapes workspace cwd/, + ); + }); + + it("rejects a harnessFiles path-traversal escape for a remote sandbox run", async () => { + const sandbox = { + mkdirFs: async () => {}, + writeFsFile: async () => {}, + }; + + await assert.rejects( + prepareWorkspace({ + sandbox, + plan: { + isRemoteSandbox: true, + cwd: "/root/work/agenta-e2btest", + relayDir: "/root/work/agenta-e2btest/.agenta-tools", + useToolRelay: false, + acpAgent: "claude", + isPi: false, + harnessFiles: [{ path: "../escape", content: "evil" }], + skillDirs: [], + }, + }), + /escapes workspace cwd/, + ); + }); }); diff --git a/services/runner/tests/unit/session-mcp-layering.test.ts b/services/runner/tests/unit/session-mcp-layering.test.ts index 48c7f9b9d6..db27c19f05 100644 --- a/services/runner/tests/unit/session-mcp-layering.test.ts +++ b/services/runner/tests/unit/session-mcp-layering.test.ts @@ -57,6 +57,7 @@ describe("buildSessionMcpServers layering (do-not-merge regression guard)", () = it("(a) gateway tools + no user MCP -> internal channel present, no throw", async () => { const { servers } = await build({ isPi: false, + isRemote: false, isDaytona: false, capabilities: mcpCapable, harness: "claude", @@ -84,6 +85,7 @@ describe("buildSessionMcpServers layering (do-not-merge regression guard)", () = () => buildSessionMcpServers({ isPi: false, + isRemote: false, isDaytona: false, capabilities: mcpCapable, harness: "claude", @@ -99,6 +101,7 @@ describe("buildSessionMcpServers layering (do-not-merge regression guard)", () = it("(c) gateway tools + user http MCP -> BOTH delivered; user stdio still refused", async () => { const { servers } = await build({ isPi: false, + isRemote: false, isDaytona: false, capabilities: mcpCapable, harness: "claude", @@ -126,6 +129,7 @@ describe("buildSessionMcpServers layering (do-not-merge regression guard)", () = () => buildSessionMcpServers({ isPi: false, + isRemote: false, isDaytona: false, capabilities: mcpCapable, harness: "claude", @@ -140,6 +144,7 @@ describe("buildSessionMcpServers layering (do-not-merge regression guard)", () = it("Pi gets [] (native delivery, no MCP channel) even with gateway tools", async () => { const { servers } = await build({ isPi: true, + isRemote: false, isDaytona: false, capabilities: mcpCapable, harness: "pi_agenta", @@ -156,6 +161,7 @@ describe("buildSessionMcpServers layering (do-not-merge regression guard)", () = it("a non-MCP harness gets [] (capability gate), no internal server started", async () => { const { servers } = await build({ isPi: false, + isRemote: false, isDaytona: false, capabilities: { mcpTools: false, toolCalls: false }, harness: "no-mcp", @@ -168,6 +174,7 @@ describe("buildSessionMcpServers layering (do-not-merge regression guard)", () = it("the internal channel advertisement carries no credential (server-side invariant)", async () => { const { servers } = await build({ isPi: false, + isRemote: false, isDaytona: false, capabilities: mcpCapable, harness: "claude", @@ -194,6 +201,7 @@ describe("buildSessionMcpServers layering (do-not-merge regression guard)", () = // Finding-1 regression guard. const { servers } = await build({ isPi: false, + isRemote: true, isDaytona: true, capabilities: mcpCapable, harness: "claude", @@ -217,6 +225,7 @@ describe("buildSessionMcpServers layering (do-not-merge regression guard)", () = // delivered on Daytona unchanged. const { servers } = await build({ isPi: false, + isRemote: true, isDaytona: true, capabilities: mcpCapable, harness: "claude",