# Containerized agents ## Give each agent an isolated environment A container workspace gives one agent its own stack. Docker in Docker, a database, services, ports, none of them yours. ## Agents in their own stack - You own the repo, in committed `.devcontainer/` and Grove config. - Grove owns the boundary. - The agent runtime owns the work, ungated. - Twenty agents spawning tests and builds turn into a number you set, instead of a saturated machine. ## Host or container Runtime is a per workspace choice, so two workspaces in one repo can differ. | Run on the host when | Put it in a container when | |---|---| | One or two agents, sharing your machine like any program. | Twenty agents spawning sub agents, tests, and builds with no ceiling. | | You approve tool calls as they come. | Permissions off, so the blast radius has to be the workspace. | | One environment, already on your machine. | Dev and test stacks side by side, with their own services and ports. | | Fastest start, your credentials, your `PATH`, nothing to build. | The environment your `.devcontainer/` describes, the one VS Code teammates read. | ## What the container is for - Permission prompts are the host's safety mechanism. The container is the mechanism for `claude --dangerously-skip-permissions` or Codex full auto. - The reachable set shrinks to the worktree, the mounts and the nested docker daemon. Not another workspace, not your dotfiles, not the host engine. - It is a blast radius boundary, not a credential boundary. Under the default `share: full` your credentials are mounted in, so set `share: isolated` for an untrusted repository. ## How the runtime gets chosen Grove selects the runtime at creation. ```mermaid flowchart LR A["grove create"] --> B{"--runtime"} B -->|host| H["Host
workspace"] B -->|"container
(default)"| C{"Runtime
ready?"} C -->|no| F["Host, with a
persistent warning.
Fix, then respawn."] C -->|yes| D{".devcontainer/
in repo?"} D -->|yes| E["Your config.
The one
VS Code reads."] D -->|no| G["Grove's packaged
default. Still
full isolation."] E --> Z["Agent runs in
the container"] G --> Z ``` - Container is the configured default, and every create surface shows the choice. - A repo with a `.devcontainer/` gets its own config, the one VS Code reads. A repo without one gets Grove's packaged default, still isolated. - A missing Docker or devcontainer CLI is caught before Grove touches the filesystem. The workspace lands on the host with a warning naming the remedy, and `grove respawn` promotes it later. - An explicit `--runtime host` is your decision, so Grove never promotes on its own. ## Where your code lands Grove bind mounts the worktree instead of cloning it, so the container edits the same files your host sees. - The default container path is `/workspaces/`, and that is the agent's working directory. - Your own `workspaceFolder` wins when the devcontainer sets one, and Grove translates the paths the CLI reports. ```json title=".devcontainer/devcontainer.json" "workspaceMount": "source=${localWorkspaceFolder},target=${localWorkspaceFolder},type=bind", "workspaceFolder": "${localWorkspaceFolder}" ``` !!! tip "Why a linked worktree still works" A worktree's `.git` is a file pointing outside it, so Grove also binds the shared git directory at its identical host path with `GIT_COMMON_DIR` set. Those are the only two host paths it preserves. ## Sessions outlive your terminal The agent runs under tmux inside the container, not your host tmux, so your host session is only a viewport. - Detach, close the terminal, restart the daemon, or let your host tmux die. The container owns the terminal and the work continues. - `grove attach` returns you mid task with its scrollback, and `grove respawn` rebuilds the viewport, not the agent. - A detached workspace stays observable, since peek, the webapp terminal and steering read its own pane. - `grove shell WORKSPACE` goes straight to a prompt inside the container, trying the shells in `container.shell` in order. - Grove brings its own static tmux and terminfo when the image ships none, the same way it brings `iptables`. ## Reading the container status line Grove bind mounts read only terminal assets at `/grove/decor`, so attach shows a status bar instead of bare default chrome. | Behavior | Detail | |---|---| | Vocabulary follows your terminal's fonts | Nerd Font icons by default. Set `GROVE_STATUSLINE_GLYPHS=ascii` for words instead. | | Figures come from the container's own cgroup, not the host | `/proc/loadavg` isn't namespaced. The bundled script reads cgroup CPU and memory limits, with a v1 fallback. | | An absent segment means an absent fact, not a bug | An API-key login has no quota pools, so that segment is absent, not zero. | | Toggles: `container.decor.enabled`, `.statusline`, `.tmux_conf`, `.payload` | Turn pieces off or replace the bundle. A host workspace already has your own. | ## Several agents in one container A workspace runs one agent. For a second, say a reviewer reading what the first wrote, start it in the same container. - `grove agent add WORKSPACE --agent codex --name reviewer` gives it its own persistent tmux session, only when you ask. - `grove agent list`, `attach`, `peek`, `message` and `kill` manage them. - They share the worktree, branch and container, which is the point and the caveat. Two agents editing the same files collide like two people would. - A host workspace holds one agent only. ## Opening it in VS Code `grove code WORKSPACE` opens VS Code's editor inside the container. - Source stays on the bind mount and build artifacts stay in the container's own volumes. - The editor's server runs inside too, so a container built `node_modules` never confuses a host language server. - Cursor and VSCodium cannot open it this way, since Dev Containers is proprietary. `grove shell` and `grove attach` are the way in. ## Start from the devcontainer you already have A repository devcontainer is used as it stands, and `grove doctor` checks the tools it needs. ``` grove create "fix login" --agent claude # configured default grove create "fix login" --agent claude --runtime container grove create "fix login" --agent claude --runtime host ``` ## Getting secrets and environment in Choose one source for container secrets. ``` container.env_file: ".env.grove" # pick one container.env_command: "./scripts/print-secrets.sh" # or the other ``` | Fact | Detail | |---|---| | Two knobs, mutually exclusive | `env_file` loads a dotenv file (repo relative, absolute, or `~` expanded). `env_command` parses a host command's stdout as dotenv, so a secret never touches disk. | | Any command printing `KEY=value` works | `container.env_command: "acme-secrets export --format=dotenv --project=my-app"`. | | Two entry points | `--secrets-file` reaches `postCreateCommand`/`postStartCommand`. `--remote-env` reaches the agent. | | Precedence, lowest to highest | Telemetry, `env_file`/`env_command`, agent config sharing, agent's own `env`. | | A committed layer can't set `env_command` | It would run an arbitrary command on anyone who clones the repo. Use `.grove/config.local.json`. | | A committed `env_file` loads inside the repo only | An absolute path, or one escaping the repo, is ignored. | ## Extending the environment A container workspace accepts the devcontainer specification. | Tier | Mechanism | Cost | What belongs here | |---|---|---|---| | 0 | `build` or a `Dockerfile` | minutes, rare | OS packages and language runtimes | | 1 | `features` | cached layers | reusable toolchains pulled from a registry | | 2 | `postCreateCommand` | once per container | dependency sync such as `uv sync` or `npm ci` | | 3 | `postStartCommand` | every start, seconds | env seeding, trust, reachability checks | `grove init devcontainer` scaffolds the default, and the later tiers are where frequent changes belong. !!! tip "Your image needs `iproute2`. Grove brings its own `iptables`." The egress allowlist is a firewall inside the container, and a missing tool aborts the start rather than leave an unfirewalled workspace. Grove mounts its own `iptables` at `/grove/netfilter` when your image ships none, but supplies no `ip`, so `python:*-slim` and `node:*` images need `apt-get install -y iproute2`. The alternative is `container.egress.mode = "open"`. ## Setting the limits Three settings control sharing, network access, and resources. ``` container.agent_config.share: full | projects | isolated # default: full container.egress.mode: allowlist | open | deny # default: allowlist container.resources: { memory: "8g", cpus: 4, pids: 2048 } ``` | Value | What the agent sees | When to use it | |---|---|---| | `full` | Sign-in, skills, commands, project memory. Works like the host. | The default. Trusted repositories. | | `projects` | Transcripts only. No sign-in, no skills. | History persists without sharing credentials. | | `isolated` | A separate Grove-owned config directory, with its own sign-in. | An untrusted repository. | | Fact | Detail | |---|---| | Claude Code plugins cross as a read only seed | Anything installed lands in the workspace's own plugin directory, never on your host. | | Workspace folder stamped trusted before the container starts | The first-run trust dialog is a prompt nobody answers. `container.agent_config.trust: false` restores it. | | Egress allowlist is derived | From the agent's plane, your registries, your git remote, and Grove's plane. Ordinary development needs none. | | `container.egress.allow` entries are hostnames or CIDRs | Resolved once at start, so wildcards don't work. List the hosts you reach, or set `mode: open`. | | A container started outside Grove reads unprovisioned | Until you respawn, since the firewall runs from `postStartCommand`. | | Resources apply to every service the workspace runs | Take effect next launch. Default is uncapped. | !!! danger "A blast radius boundary, not a credential boundary" Under `share: full` an agent fed a hostile prompt could use your token as you. The egress allowlist bounds where a token can go, and Grove seeds `~/.claude.json` per workspace so an MCP rewrite attack dies with the workspace. For an untrusted repository, set `share: isolated`. ## Grove init scripts and container hooks A [Grove init script](configure-init-scripts.md) prepares the host worktree first, then the devcontainer's own hooks run inside. `init_script.applies_to: "host"` stops it running twice when your hooks already install what it would. `all` is the default and `container` is the other value. ## Configuration reference | Field | Default | Meaning | |---|---|---| | `container.enabled` | `true` | Cascade default for `runtime`. | | `container.docker_bin` | `docker` | Container CLI Grove shells out to. | | `container.shell` | `["bash", "sh"]` | Shells `grove shell` tries, in order. | | `container.tmux.enabled` | `true` | Agent under in-container tmux. `false` is a bare exec, no persistence. | | `container.tmux.prefer_image` | `true` | Prefer the image's own tmux. | | `container.tmux.payload` | unset | Directory with a supplied `bin//tmux` and `terminfo/`. | | `container.tmux.session` | `agent` | Agent's session. Renaming orphans a live agent. | | `container.tmux.shell_session` | `shell` | Session `grove shell` attaches to. | | `container.tmux.term_fallback` | `xterm-256color` | `TERM` retried on a refused attach. Empty disables it. | | `container.default_config` | packaged default | `devcontainer.json` for a repo with none. | | `container.decor.enabled` | `true` | Mount and compose the terminal chrome. | | `container.decor.statusline` | `true` | Statusline into the agent's settings. | | `container.decor.tmux_conf` | `true` | Grove's tmux config into the container. | | `container.decor.payload` | unset | Host assets replacing the bundled ones. | | `container.agent_config.share` | `full` | `full`, `projects`, or `isolated`. | | `container.agent_config.trust` | `true` | Folder seeded trusted, `.mcp.json` servers approved. | | `container.env_file` | unset | Dotenv file loaded into container and agent. | | `container.env_command` | unset | Host command printing dotenv. Excludes `env_file`. | | `container.egress.mode` | `allowlist` | `allowlist`, `open`, or `deny`. | | `container.egress.allow` | derived | Extra hosts and CIDRs on the allowlist. | | `container.resources.memory` | uncapped | Memory limit, for example `8g`. | | `container.resources.cpus` | uncapped | CPU limit, for example `4`. | | `container.resources.pids` | uncapped | Process count limit. | | `container.up_timeout_seconds` | `900` | Seconds before a container counts as failed. | Full schema on the [configuration reference](configure-reference.md) page. ## See also - [Workspace lifecycle](features-workspace-lifecycle.md) covers `respawn`. - [CLI](use-cli.md) lists the container commands.