feat(desktop):docker sandbox workspaces
What & why
Implements opt-in Docker sandboxing for workspaces (#3957). Today "isolated workspaces" are git worktrees — branch isolation, not a security boundary: agents launched with --dangerously-skip-permissions / --dangerously-bypass-approvals-and-sandbox have full host access. This PR makes a workspace's entire process tree — terminals, agents, setup — run inside a per-workspace Docker container instead, without changing how any host-side feature works.
How I tested it
Automated (all green, macOS + Docker Desktop):
- Unit: config validation/merge incl. the machine-local-only rule, container/exec argv builders, name slugs, hook-token verify, git sync (fast-forward, divergence, dirty-worktree), HostRuntime byte-for-byte spec equivalence with the pre-refactor launch path, create-time enable precedence (project config vs host default).
- Docker-gated integration (SUPERSET_DOCKER_TESTS=1): container ensure → isolated git bootstrap → loopback port publish → idempotent re-ensure → destroy leaves no orphans; agentConfig: false keeps ~/.claude out.
- Docker-gated end-to-end against the real app (test/integration/sandbox-e2e.integration.test.ts): real host-service on a real port with real PSK auth, workspace created via tRPC (sticky flag), PTY spec exec'd in-container, the real rendered notify hook accepted (and a spoofed token rejected), CLI auth accepted/anonymous rejected, in-container commit imported by syncSandbox, unsynced commit surviving workspace delete as refs/sandbox//….
- bun run lint exit 0, host-service + desktop typecheck clean.
Manual dogfood (dev app, this repo):
- Created sandboxed workspaces via the global settings toggle; containers provisioned at create, terminals landed inside (hostname, git rev-parse --git-dir → /sandbox/git, uname → Linux), sidebar badge + "Initializing sandbox…" step shown.
- Claude ran in-container with host auth mounted, incl. --dangerously-skip-permissions (IS_SANDBOX=1); agent status transitions reached the sidebar through the hook bridge.
- Boundary checks in-container: no docker.sock, no /.ssh//.aws, host filesystem absent outside the mounted worktree.
- Multiple sandboxed workspaces ran in parallel without collisions; workspace delete removed container + state.
- TODO before merge: publish image via CI (or keep local-build default), Linux CI job for the docker-gated suites, verify on a second machine without the pre-built image.
How it works:
- WorkspaceRuntime seam (packages/host-service/src/runtime/sandbox/): every workspace PTY already funnels through one chokepoint in terminal.ts; the runtime decides whether the PTY child is the user's shell (HostRuntime, unchanged behavior) or docker exec -it into the workspace's container (DockerRuntime). Because the child is just another PTY process, terminal persistence, adoption, resize, and exit codes work unchanged — the pty-daemon stays Docker-ignorant.
- One persistent container per workspace, named superset-<workspace/branch-slug>-, provisioned eagerly at workspace create (sidebar shows "Initializing sandbox…") with a single-flight ensure the first terminal joins. The worktree is bind-mounted at its host path, so the diff viewer, git status, file tree, and search all keep working host-side.
- Isolated git metadata: the repo's real .git is never mounted. Each workspace gets a bare-clone git dir at /sandbox/git with a read-only .git file mask over the worktree. In-container commits are sandbox-local until imported via the new workspaces.syncSandbox mutation (ancestor-checked git reset --mixed); workspace deletion auto-exports unsynced commits to refs/sandbox//* so agent work is never lost.
- Host bridges: agent lifecycle hooks reach the host via host.docker.internal with a per-workspace token (spoofed events rejected); the bundled superset CLI gets SUPERSET_HOST_ENDPOINT/SUPERSET_HOST_TOKEN_FILE to skip PID-based discovery — the org PSK never enters a container.
- Enablement: Settings → Sandboxes (host-wide "sandbox new workspaces" default + provider dropdown), or per-project sandbox config in .superset/config.json (explicit enabled: true/false overrides the global default). The decision snapshots per workspace at create; mounts/env are honored only from machine-local config so a cloned repo can't mount ~/.ssh into its own sandbox. Sandboxed rows show a container badge in the sidebar.
- Default image in packages/sandbox-image/ (node 22 + git + claude/codex CLIs + bun); dev builds default to the locally built tag. Declared sandbox.ports publish on loopback. Startup reconcile cleans up containers for workspaces deleted while Docker was down, scoped by an ownership label so multiple host instances on one daemon never sweep each other.
Known gaps (follow-ups): image not yet published to GHCR (CI job); dynamic port detection + remap UX; per-workspace agent-home volumes instead of mounting host ~/.claude rw; Linux/gVisor runtime; docker-compose service stacks per workspace.
Checklist
- PR title follows conventional commits (
type(scope): subject) -
bun run lintandbun run typecheckpass (CI fails on lint warnings too) - "Allow edits from maintainers" is checked on fork PRs
Summary by cubic
Adds opt-in Docker sandboxing: workspace terminals and agents now run inside per-workspace containers instead of directly on the host, while the worktree, git, diff, and search stay host-side. Workspaces remain host-runtime by default; a sandbox key in .superset/config.json or a host-wide settings default opts newly created workspaces into Docker (sticky at create time, migrations 0026–0027).
Sandbox runtime
- One persistent container per workspace hosts all terminals and agents, provisioned eagerly at create — the first terminal joins the in-flight ensure, stopped containers restart, and renames happen in place so live sessions survive.
- The main repo's
.gitnever enters a container; a per-workspace isolated git dir is bind-mounted at/sandbox/git, and in-container commits sync viaworkspaces.syncSandbox(guarded by the checked-out branch) or export asrefs/sandbox/<id>/*on destroy. - The
sandboxconfig covers image, runtime, network, ports, resources, mounts, env, and clone depth; mounts, env, and agent credential mounting are honored only from machine-local config sources, withagentConfigoff by default. - Startup reconcile removes only orphaned containers this host instance created; sandbox paths no-op when Docker isn't running.
Security and UI
- The org PSK never enters containers: per-workspace CLI and agent-hook tokens replace it, and
notifications.hookrejects missing or wrong hook tokens once a workspace registers one (notify hook v10). - Sandbox CLI tokens are default-deny and scoped to their own workspace's terminals, agents, and
syncSandbox; the same ACL covers WebSocket connections, with both bearer and query-token channels checked independently. - The sidebar shows a container icon with tooltip and "Initializing sandbox…" status; a new Sandboxes settings section toggles the host-wide default and reports Docker daemon availability.
packages/sandbox-imageships the default image (node:22-bookworm-slimwith git, curl, ripgrep, jq, and pinned agent CLIs) via a GHCR publish workflow publishing the:latesttag — the package must be made public before the production default resolves; dev builds default tosuperset-sandbox:dev. Sandboxed terminals injectIS_SANDBOX=1so Claude Code accepts--dangerously-skip-permissions.db.localhostjoinsdb.localtest.mefor the local database proxy so local dev works behind DNS-rebind-protecting routers.
Summary by CodeRabbit
- New Features
- Added optional Docker sandboxing for workspace terminals and agents.
- Added Sandboxes settings for host-wide defaults and provider selection.
- Workspace sidebar now displays sandbox indicators and provisioning status.
- Added sandbox-aware Git synchronization and secure sandbox authentication.
- Added automatic cleanup of sandbox containers for deleted workspaces.
- Documentation
- Added setup guidance and requirements for the default sandbox image.
- Bug Fixes
- Improved local database host detection and environment-based host connectivity.