feat(sandbox): unify a workspace's agent sandbox with the ACP session - #141
Conversation
When a harness that runs an ACP agent (Claude Code/Codex/Cursor) is used in
a workspace, the agent now runs INSIDE the workspace's sandbox instead of a
separate throwaway session-owned one, and that box is named "{Agent} ·
{Harness}". No more orphan Claude Code sandboxes disconnected from the
workspace.
Per-workspace keying (workspace.sandboxId is the one box). Respect an
explicitly-assigned sandbox; otherwise an ACP harness assigned to a workspace
auto-adopts its sandbox. First session in a fresh workspace creates a
PERSISTENT box (owned-by-default until Convex confirms the link, then it
survives teardown), records it in Convex linked to the harness + workspace,
and reuses it thereafter.
Convex:
- workspaces.ts: create/update auto-adopt an ACP/sandbox harness's sandbox
(harnessSandboxToAdopt); internalQuery resolveSandboxInternal.
- sandboxes.ts: createInternal gained workspaceId (links workspace, agent-gated
sandboxEnabled) and returns null when it LOST a create race (workspace
already links one) so the gateway reclaims the duplicate — relies on Convex
serializable mutations. removeByDaytonaId now clears workspace + harness
links. Per-user cap 5 -> 20 (each workspace keeps one auto-stopped box).
- harnesses.ts: resolveForCollab carries workspaceId (collab turns unify too).
FastAPI:
- models.py: harness_config_from_resolved carries workspace_id.
- services/convex.py: resolve_workspace_sandbox(); create_sandbox_record gained
workspace_id and returns None on a declined (lost-race) create.
- daytona_runtime.py: provision_agent_sandbox(persist=True) shapes the box as a
persistent home (cwd + label + gai.conf) but returns owns_sandbox=True — a
fail-safe so any failure between create and link reclaims it (no orphan).
- session_manager.py: _resolve_sandbox_plan picks attach-vs-create;
_register_workspace_sandbox links the box and flips it to persistent only on
a confirmed link (a None/lost-race or any failure leaves it owned).
Frontend already threads workspace_id on both /chat and /workspaces; the linked
sandbox name renders reactively — no UI change needed (cap mirror bumped).
Adversarially verified (multi-agent): fixed a concurrency-race orphan + harness
divergence, missing gai.conf/IPv6 on the persistent box's first run, and the
sandbox-cap cliff. Tests: Convex 191, FastAPI 320; tsc/biome/ruff clean.
| if not adopted: | ||
| if create_persistent: | ||
| # Just created the workspace's unified box (owned-by-default, so | ||
| # any failure reclaims it). Link it and, on success, flip it to | ||
| # persistent. Handles a lost race / Convex failure internally. | ||
| await self._register_workspace_sandbox(session, agent) |
There was a problem hiding this comment.
Bug: owned runtime from attempt 1 is orphaned when create_persistent=True and conn.start() raises a transient error
_register_workspace_sandbox runs here — after conn.start() (line 1278) and conn.initialize() (line 1280). When a transient OSError (the "freshly created sandboxes occasionally reset the first toolbox connection" case) hits conn.start(), the retry loop in _provision_with_retry runs again without having written a Convex link for Box #1.
The retry cleanup clears session.connection but not session.runtime. On the second _provision_once call:
_resolve_sandbox_planreturns(None, True)again — no Convex link was ever written for Box Initial project setup #1.provision_agent_sandboxcreates Box Feat: Setup Project Environment + Base Codebase #2 and overwritessession.runtime(line 1262).- Box Initial project setup #1 — still
owns_sandbox=True, no Convex record, no Python reference — is permanently orphaned. The owned-by-default fail-safe cannot reach it because teardown only followssession.runtime, which now points to Box Feat: Setup Project Environment + Base Codebase #2.
Fix: in _provision_with_retry, before sleeping and retrying, tear down any owned runtime whose link was never confirmed:
# Reclaim any owned runtime whose Convex link was never written —
# registration runs after conn.start(), so a transient connection
# failure leaves an unlinked box that must be destroyed before retry.
if session.runtime is not None and session.runtime.owns_sandbox:
with contextlib.suppress(Exception):
await asyncio.to_thread(session.runtime.teardown)
session.runtime = NoneAdd this after session.connection = None in _provision_with_retry, before await asyncio.sleep.
Related: _provision_with_retry retry cleanup
There was a problem hiding this comment.
Good catch — fixed in 2431d57. _provision_with_retry now reclaims an owned, unlinked box (via _destroy_runtime) before retrying; attached boxes are left in place for re-attach. Added a regression test (test_owned_box_from_failed_attempt_is_destroyed_before_retry). This also closes the latent pre-existing leak on the throwaway path.
…fore retry Sandbox registration runs only after conn.start()/initialize, so a transient connection failure there left the freshly-created, owned, unlinked box behind: the retry provisioned a new one and overwrote session.runtime, orphaning the first (owned-by-default could not reach it — teardown follows session.runtime). Destroy an owned runtime before retrying; attached boxes are left for re-attach. Flagged by code review on the persistent-box path; also fixes the latent pre-existing leak on the throwaway path.
Code reviewNo issues found. Checked for bugs and CLAUDE.md compliance. |
What
When a harness that runs an ACP agent (Claude Code / Codex / Cursor) is used in a workspace, the agent now runs inside the workspace's sandbox instead of spinning up a separate throwaway session-owned box. That box is named
{Agent} · {Harness}(e.g. "Claude Code · My Harness") and is reused across the workspace's chats — and shared with code-execution. No more orphan Claude Code sandboxes disconnected from the workspace.Behavior (per the design decisions)
workspace.sandboxIdis the one box for the workspace.How it works
workspace_idinto the harness config on both/chatand/workspaces— no UI change needed; the linked sandbox name renders reactively._resolve_sandbox_plandecides: explicit harness sandbox → attach; workspace already has a box → attach; ACP harness in a workspace with none → create a persistent box.createInternallinks the workspace and returnsnullwhen it lost a create race (the workspace already links one) — relying on Convex's serializable mutations — so the losing session reclaims its duplicate box.Changed
Convex:
workspaces.ts(auto-adopt +resolveSandboxInternal),sandboxes.ts(createInternalworkspace-link + lost-racenull+ agent-gatedsandboxEnabled;removeByDaytonaIdclears workspace/harness links; per-user cap 5 → 20),harnesses.ts(resolveForCollabcarriesworkspaceId).FastAPI:
models.py,services/convex.py(resolve_workspace_sandbox,create_sandbox_record(workspace_id=…)),daytona_runtime.py(persistshaping + owned-by-default),session_manager.py(_resolve_sandbox_plan,_register_workspace_sandbox).Frontend: cap mirror bump only.
Verification
Ran a 3-dimension adversarial multi-agent review. It found and I fixed:
harness.sandboxId. Fixed via the lost-racenull+ owned-by-default reclaim (Convex serializability guarantees exactly one winner).gai.confIPv6→IPv4 write (native tools could hang on dual-stack egress). Fixed for free by owned-by-default (theif owns_sandboxgate now covers it).Tests: new
test_workspace_sandbox_unify.py(8) + Convexsandboxes.test.ts/workspaces.test.tsadditions. Full suites: Convex 191, FastAPI 320. tsc / biome / ruff clean on changed files.Notes for review
MAX_SANDBOXES_PER_USER(mirrored in 3 files) if you want a different ceiling.harness.sandboxId, surfaced as each workspace's sandbox) — consistent with the naming + auto-assign decision, but per-workspace file isolation isn't guaranteed when the same harness is used in multiple workspaces.