Observed since 20 Oct, 21:33 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
Run coding agents in a per-project dev container sandbox.

Run coding agents in a per-project dev container sandbox.
The agent gets a container with its own Docker daemon and nothing of the host but the project directory: no host Docker socket, no host home, no sibling repositories. One command per project, no per-project boilerplate.
Homepage: https://github.com/kevinveenbirkenbach/agentbox
flowchart LR
subgraph host["Host"]
editor["Code - OSS / VSCodium<br/>open-remote-ssh"]
cli["agentbox CLI"]
state["~/.config/agentbox<br/>aliases + keys + ssh.d/*.conf"]
dockerd["host docker daemon"]
repo[("project directory")]
end
subgraph layers["Configuration layers"]
base["1 base<br/>share/devcontainer.base.json"]
project["2 project<br/>.devcontainer/devcontainer.json"]
override["3 local<br/>.devcontainer/agentbox.local.json"]
merged["effective devcontainer.json"]
end
subgraph box["agentbox container"]
sshd["sshd on 2222"]
exthost["remote extension host<br/>agent extension"]
agent["agent CLI<br/>claude / codex / ..."]
dind["own docker daemon"]
workspace["/workspaces/PROJECT"]
nested["containers the agent starts"]
end
base --> merged
project --> merged
override --> merged
cli -- "deep merge" --> merged
merged -- "override config" --> devcli["npx @devcontainers/cli"]
cli --> devcli
devcli -- "build and start" --> dockerd
dockerd -- "creates" --> box
cli -- "ssh key, host entry" --> state
cli -- "agentbox run / shell" --> agent
editor -- "reads" --> state
editor -- "ssh 127.0.0.1 on a free port" --> sshd
sshd --> exthost
exthost <-- "~/.claude/ide + localhost" --> agent
agent --> workspace
agent --> dind
dind --> nested
repo -- "bind mount" --> workspace
What the picture says:
/workspaces/PROJECT, which is the bind-mounted project directory. No host home, no sibling repositories.~/.claude/ide plus localhost. An extension host on the host side cannot reach either.pipx install agentboxer
The distribution is named agentboxer because agentbox is taken on PyPI and agentbox-cli collides with an existing project once PyPI strips the separators; the command it installs is agentbox.
Independent of PyPI, from a checkout — this also installs what the host needs:
make install # host dependencies plus agentbox
make host-deps # host dependencies only
The package itself has no Python dependencies, but it drives host binaries and refuses to start without them:
| Binary | Used for |
|---|---|
docker | building and running the sandbox |
npx (Node.js) | fetching the devcontainer CLI |
ssh-keygen | the per-project key |
sysbox-runc | nested Docker without --privileged, see below |
On Arch-based hosts make host-deps does the whole setup: it installs docker, nodejs, npm and openssh with pacman and sysbox-ce-bin with yay, enables both services, registers the sysbox runtime in /etc/docker/daemon.json, and restarts the Docker daemon so it picks the runtime up — which stops running containers, so it says so before doing it. Every step is skipped when it is already done, so re-running costs nothing. On other distributions it prints the list and leaves the machine alone.
cd ~/Repositories/some-project
agentbox up
agentbox run claude
agentbox up builds and starts the sandbox, publishes its SSH port on a free 127.0.0.1 port, installs a per-project key, and writes an SSH host entry named after the project directory.
| Command | What it does |
|---|---|
agentbox up [--rebuild] | Build and start the sandbox for the current directory |
agentbox run <cmd…> | Run a command inside the sandbox, e.g. agentbox run claude |
agentbox shell | Open a shell inside the sandbox |
agentbox update | Reinstall the agents and skills in the running sandbox, without rebuilding it |
agentbox code [name] | Open Code - OSS / VSCodium / VS Code on the sandbox |
agentbox mount [--readonly] <dir> | Make a host folder available inside the sandbox, writable unless --readonly |
agentbox down | Remove the sandbox container |
agentbox init | Write a project-owned .devcontainer/devcontainer.json and post-create.sh, plus local and default workspace files |
All commands accept --workspace <dir> and otherwise act on the current directory.
Boxes run side by side and share nothing. Each project gets its own container, SSH port, key, host entry and agent home volume:
| Resource | Keyed by |
|---|---|
| Container | label devcontainer.local_folder=<absolute path> |
| SSH port | published by Docker on a free 127.0.0.1 port |
| Key, host entry | ~/.config/agentbox/keys/<alias>/, ~/.config/agentbox/ssh.d/<alias>.conf |
| Agent home (logins, history) | volume agentbox-home-<alias> |
The alias is the project directory name. Two repositories with the same directory name — ~/work/web and ~/client/web — would otherwise collide in all of the above, so the first one to claim web keeps it and the next gets a path digest appended: web-0ac1b2. Claims live in ~/.config/agentbox/aliases/ and are sticky, so an alias never changes under a running box.
Removing a box: agentbox down, plus docker volume rm agentbox-home-<alias> if its agent state should go too.
Later layers win; each is optional.
| Layer | File | Versioned |
|---|---|---|
| 1. agentbox default | src/agentbox/share/devcontainer.base.json | in this repo |
| 2. Project | <project>/.devcontainer/devcontainer.json | in the project |
| 3. Local override | <project>/.devcontainer/agentbox.local.json | no, gitignore it |
Layer 3 is deep-merged over whatever layer sits below it; dictionaries merge, lists and scalars are replaced. The merged result is handed to the devcontainer CLI via --override-config, so nothing needs to be edited by hand.
Example — this project needs Codex instead of Claude and a Python toolchain, but only on this machine:
{
"containerEnv": { "AGENTBOX_AGENTS": "@openai/codex" },
"features": { "ghcr.io/devcontainers/features/python:1": {} }
}
Agents are npm packages listed in AGENTBOX_AGENTS, installed on first start — see Agents. Skill collections are git repositories listed in AGENTBOX_SKILLS, installed alongside them — see Skills.
A window serves exactly one box, so folders from two boxes cannot share one window (see Editor). What works is the other direction: mount the neighbour into the box.
agentbox mount ../other-repo
That writes both halves. The mount goes into .devcontainer/agentbox.local.json:
{
"mounts": [
"source=${localWorkspaceFolder}/../other-repo,target=/workspaces/other-repo,type=bind"
]
}
A folder that sits beside the project becomes ${localWorkspaceFolder}/../<name>, the devcontainer CLI's own variable, so the entry carries no machine-specific path and survives being committed. A folder from anywhere else gets its absolute path, which is why the file it lands in is gitignored. agentbox appends its own mounts — the home volume and the /agentbox share — after the merge, so an override that sets mounts no longer drops them.
The other half goes into the workspace file agentbox code opens, so the folder shows up in the window:
{ "folders": [{ "path": "." }, { "path": "../other-repo" }] }
The relative path resolves on both sides because every mount target is /workspaces/<name>, mirroring the layout the repositories already have on the host. A folder whose name would land on the project's own target is refused rather than shadowing it, and mounting the same folder twice changes nothing.
Mounts are applied when the container is created, so this needs agentbox up --rebuild. It also asks for --trust-config: a bind mount decides what the box can reach on this host, and an agent with write access to the repository could add one.
agentbox up --rebuild --trust-config
The mount is writable, so the agent in this box can change the neighbour and its git history the way it changes the project. --readonly is the narrower shape:
agentbox mount --readonly ../other-repo
That appends ,readonly to the entry, and the box then reads the neighbour to understand it without being able to touch it — its own box stays the only place where its code is written. Re-mounting a folder with the other flag replaces its entry instead of adding a second one on the same target, so the access flips without editing the file by hand.
The agent extension must run inside the container, otherwise it cannot reach the agent CLI. That happens automatically once the editor window itself is remote.
agentbox code picks an editor — codium, then code, then code-oss — and makes sure it carries a resolver extension. Which one depends on the marketplace the editor speaks, and the binary name does not reveal that: on Arch, /usr/bin/code is a symlink to Code - OSS, which serves Open VSX. So agentbox simply tries both, jeanp413.open-remote-ssh and ms-vscode-remote.remote-ssh, and keeps the one the marketplace actually has. The Dev Containers extension is not needed and is unavailable on Open VSX.
Finding only Code - OSS, or no editor at all, it stops and asks for VSCodium to be installed (yay -S vscodium-bin) rather than opening a window that cannot connect. It does not install packages itself: that needs root, and a password prompt in the middle of a run is worse than a clear message.
It also links the VSCodium and Code - OSS user settings directories, so both editors read the same settings.json and only one has to be configured. Two directories that both already carry settings are left untouched, with a warning: merging them is the operator's call, not the tool's.
agentbox up puts its own include at the top of ~/.ssh/config, creating the file with owner-only permissions if it does not exist:
Include ~/.config/agentbox/ssh.d/*.conf
The line goes first because OpenSSH keeps the first value it obtains for an option — below an existing Host * block, that block would win over the alias.
agentbox code opens the window and installs the extensions the box is missing. A remote window carries its own extension set, so anything installed on the host is inert in there. Which ones it installs:
AGENTBOX_AGENTS, always — an agent CLI without its extension cannot talk to the editor, and mirroring the host cannot supply it because the host window is not the one that matters.customizations.vscode.extensions from the effective devcontainer config, if the project declares it — versioned in the repository, the same field every devcontainer client reads.Already-installed extensions are skipped, duplicates collapse, and the rest go in one call rather than one editor launch each.
agentbox code opens a *.code-workspace file when the project has one, and the folder otherwise:
| Call | In the project | Opened |
|---|---|---|
agentbox code | local.code-workspace | that file |
agentbox code | only default.code-workspace | that file |
agentbox code | exactly one other *.code-workspace | that file |
agentbox code | none | the folder |
agentbox code | several, none of them local or default | the folder, with a warning |
agentbox code custom | custom.code-workspace exists | that file |
agentbox code custom | it does not | nothing, with the existing files listed |
The suffix is optional: custom and custom.code-workspace mean the same. agentbox init seeds both files, each one only if it is missing, and gitignores every workspace file but the default (*.code-workspace plus !default.code-workspace):
local.code-workspace is where you work. Add folders to it, keep it out of the repository, break it without consequence — the same role agentbox.local.json plays for the container config.default.code-workspace is the project's, versioned like devcontainer.json. It is what a fresh clone opens, and what you copy from when the local one goes wrong.Both start identical. Their single folder entry is the relative path ., which resolves against the directory of the workspace file and is therefore valid both on the host and in the box, where the repository lives under /workspaces/<project>. One file, no vscode-remote:// URIs, no host and box variant to keep in sync.
Trust the folder when the editor asks. Until then it runs in Restricted Mode and keeps every extension inert, which looks exactly like a failed install.
One window serves one box. A .code-workspace whose folders point at two different aliases does not load half of it — the foreign authority is discarded and the path is looked up inside the connected container instead:
Ignoring the error while validating workspace folder
vscode-remote://ssh-remote%2Bother/workspaces/other
- Error: ENOENT: no such file or directory, stat '/workspaces/other'
Opened on the host rather than through agentbox code, such a file reaches neither box: No window found with remote authority: ssh-remote+…. To work across repositories, either put them in one box — run agentbox on the directory above them and let a workspace file pick the subset — or mount the neighbour into this one, see Mounting a neighbouring repository.
Ports change on every rebuild; agentbox up rewrites the host entry each time, so the alias stays valid.
Use VSCodium, not Code - OSS. Code - OSS publishes no remote server build of its own, and the client installs the server under its own commit hash — a VSCodium server carries a different one, so the connection dies while installing it. Pointing remote.SSH.serverDownloadUrlTemplate at a VSCodium release does not help: Code - OSS supplies no release value, which yields a malformed URL, and no VSCodium build matches the commit anyway. VSCodium ships client and server from the same source, so the pair matches by construction; agentbox code therefore prefers codium and warns when only code-oss is available.
The proprietary VS Code build works too — Microsoft publishes matching servers — at the cost of not being open source.
agentbox up uses the project's own config unchanged, so it runs the project's own postCreateCommand and agentbox's share directory is not mounted. agentbox init therefore writes a copy of the install script next to the config, as .devcontainer/post-create.sh, and points postCreateCommand at it — agents and skills are installed from there, and the script is the project's to edit.
For a config agentbox did not write, add the feature in this repository instead. It installs the agents; skills are not part of it, because they land in the home volume and a feature runs before that volume exists:
"features": { "ghcr.io/kevinveenbirkenbach/agentbox/agentbox:0": { "agents": "@anthropic-ai/claude-code" } }
The feature source lives in features/agentbox/; publish it with devcontainer features publish.
A misbehaving agent, reliably. A hostile one, only in part — the difference matters, so here is the honest split.
Agent permissions belong in the box, not in the repository. On first start agentbox up writes ~/.claude/settings.json inside the container: everything allowed, git commit and git push behind a prompt. The file lives in the box's home volume, invisible to the host and separate per project. The permissions are merged in beside whatever postCreateCommand already put there — skill collections write their plugins and hooks into the same file — and a file that already carries a permissions block is left exactly as it is. A second, weaker guard inside a container that is already the boundary buys nothing and only costs prompts — which is exactly why the seeding is skipped, with a message, when a project declares nested Docker and the box therefore runs privileged. A privileged box is no boundary, so its agent does not get free rein.
Keeping this out of the repository's own .claude/settings.json matters: that file is read by agents running on the host too, where no container protects anything.
Held: the agent reaches the project directory and nothing else of the host. No host home, no sibling repositories, no host Docker socket, no SSH keys, no cloud credentials. rm -rf in there costs you the repository, not the machine.
Not held, and unfixable by construction: the agent writes files that you later execute on the host — .git/hooks/* on your next commit, the Makefile on your next make, .vscode/tasks.json when you open the folder locally. Read diffs before running anything, and remember that .git/hooks never shows up in one.
Guarded, because agentbox opened it itself: a devcontainer configuration decides what the next container may reach, and it lives in the repository the agent can write. initializeCommand even runs on the host. The project configuration is therefore checked against a whitelist of settings that cannot widen the box — image, features, ports, environment, editor customizations, the in-container lifecycle commands. Everything else stops agentbox up until you have read it: runArgs, mounts, initializeCommand, workspaceMount, dockerComposeFile, build, and any key the spec grows in future. Nested-Docker features are flagged by name, because they are what forces --privileged.
✖ .devcontainer/devcontainer.json sets runArgs
These decide what the container may reach on this host …
agentbox up --trust-config
The approval records a hash of those layers in ~/.config/agentbox/trusted/<alias>, outside the repository. Change the configuration and the approval lapses; an agent cannot re-grant it.
Nested Docker costs the isolation, unless sysbox is installed. The docker-in-docker feature requires --privileged, which hands the container every capability, disables AppArmor and exposes the host's block devices — escaping is then a mount command, no exploit needed. It is therefore not part of the default configuration; a project that needs Docker inside the box declares it:
"features": { "ghcr.io/devcontainers/features/docker-in-docker:2": {} }
When sysbox is available as a Docker runtime, agentbox up puts the box on it automatically, which gives nested Docker without privileges and a user namespace of its own. Without sysbox it says so and proceeds privileged — the choice is yours, but it is not silent:
yay -S sysbox-ce-bin # Arch, AUR
.venv/, node_modules/) are shared with the host and can collide between host and container toolchains. Mount them as volumes in layer 3 if that bites.Five agents are installed into every box on first start:
| Agent | Package | Command | Installed with | IDE extension |
|---|---|---|---|---|
| Claude Code | @anthropic-ai/claude-code | claude | npm | Anthropic.claude-code |
| Codex | @openai/codex | codex | npm | openai.chatgpt |
| Gemini CLI | @google/gemini-cli | gemini | npm | Google.gemini-cli-vscode-ide-companion |
| Pi | @earendil-works/pi-coding-agent | pi | npm | none published |
| oh-my-pi | @oh-my-pi/pi-coding-agent | omp | bun | none published |
The last two share a description and a lineage but are different packages: pi.dev is @earendil-works/pi-coding-agent, runs on node and installs pi; @oh-my-pi/pi-coding-agent is the can1357/oh-my-pi fork, needs bun and installs omp. Both are here because the end-to-end suite exercises omp; dropping either is one word in AGENTBOX_AGENTS or AGENTBOX_BUN_AGENTS.
agentbox run codex, agentbox run gemini, agentbox run pi, agentbox run omp. After changing either list, agentbox update installs the difference into the running box — no rebuild.
agentbox code installs each agent's extension into the box's remote window, derived from AGENTBOX_AGENTS, so dropping an agent drops its extension with it. All three are on Open VSX, which is what VSCodium speaks. Both Pi packages publish no editor extension — they are terminal agents, and pi.dev's "extensions" are Pi's own TypeScript modules, not VS Code ones.
AGENTBOX_AGENTS is a space separated list of npm packages. Override it per project in .devcontainer/agentbox.local.json — fewer agents, other agents, or none:
{
"containerEnv": {
"AGENTBOX_AGENTS": "@openai/codex"
}
}
AGENTBOX_BUN_AGENTS is the same thing for agents that need bun. oh-my-pi is one: its dist/cli.js starts with #!/usr/bin/env bun and it declares engines: {bun: ">=1.3.14"}, so npm installs it happily and the binary then fails with env: 'bun': No such file or directory. bun is fetched only when this list is non-empty, into $HOME/.local, because $HOME/.local/bin is already on the box's PATH and lives in the home volume — no PATH surgery, no shell profile edits, and no 80 MB download for a box that does not ask for it.
Agents that are neither npm nor bun packages — pipx tools, curl | bash installers, plain binaries — have no install path yet. Two that come up:
curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash. The npm package named hermes is an unrelated 2016 package ("Messenger of the gods."), and hermes-agent on npm is an unofficial third-party bridge that says so in its own README. Neither is a default worth shipping.attach subcommand exists to attach Claude Code to a gateway session.Every box installs kevinveenbirkenbach/skills on first start, into ~/.claude/skills and ~/.agents/skills inside the container. AGENTBOX_SKILLS is a space separated list of git repositories; each one is cloned at its default branch and handed to its own scripts/install.sh with TARGET=$HOME, so a collection decides for itself what installing means — that repository also enables the caveman and ponytail plugins and registers the autotune and dream reminder hooks in the box's ~/.claude/settings.json.
Override it per project in .devcontainer/agentbox.local.json — a different collection, several of them, or none at all:
{
"containerEnv": {
"AGENTBOX_SKILLS": ""
}
}
postCreateCommand runs once, when the container is created, so a running box does not pick up new or changed skills on its own. agentbox update re-runs that same provisioning inside the box — the agents are reinstalled at their latest version and every repository in AGENTBOX_SKILLS is cloned and installed again:
agentbox update
A changed list is picked up too. containerEnv is fixed when the container is created, so a box started before the change still carries the old values inside it; agentbox update therefore reads AGENTBOX_AGENTS and AGENTBOX_SKILLS from the merged configuration on the host and passes them in. Adding a collection needs no rebuild, only removing what an earlier run already installed does.
A project that brings its own devcontainer.json without a .devcontainer/post-create.sh provisions itself, and agentbox update says so instead of guessing what to run.
The skills live in the box's home volume, so they survive agentbox down and are re-installed on the next agentbox up --rebuild. A repository that cannot be cloned or whose installer fails is reported and skipped: the box comes up without it rather than not at all.
Read what a collection contains before running an agent on it. Skills are instructions the agent follows, and the collection pins third-party sources of its own in skills-lock.json — inside the box they run with the box's full permissions.
The sandbox has its own network namespace, so an Ollama or LM Studio server running on the host is not reachable from inside by default. Punch one hole into .devcontainer/agentbox.local.json:
{
"runArgs": ["--add-host=host.docker.internal:host-gateway"],
"containerEnv": {
"OLLAMA_BASE_URL": "http://host.docker.internal:11434",
"OLLAMA_HOST": "http://host.docker.internal:11434"
}
}
What each agent does with that, verified in the e2e suite below:
| Agent | Ollama | LM Studio | Invocation |
|---|---|---|---|
| codex | yes | yes | codex exec -c model_provider=x -c model_providers.x.base_url=<url>/v1 -c model_providers.x.wire_api=responses -c model_providers.x.requires_openai_auth=false -m <model> |
pi (omp) | yes | catalog discovery works | omp --model ollama/<model> with OLLAMA_BASE_URL set, or omp --model lm-studio/<model> |
| Claude Code | via proxy | via proxy | Anthropic protocol only — needs a translator (e.g. LiteLLM) behind ANTHROPIC_BASE_URL |
Three constraints found the hard way, each encoded in the e2e suite:
wire_api = "responses"; the chat-completions wire was removed. Both servers implement that endpoint.codex --oss insists on a daemon at localhost:11434 and ignores OLLAMA_HOST, so a remote endpoint needs an explicit provider.smollm2:135m answers plain chat requests but fails every agent.Everything at once — unit tests plus the end-to-end suite:
make test
Unit tests alone, no containers:
make test-unit
They need nothing but python3 and its standard library, and make test-unit installs that itself when it is missing — with apt-get or pacman, whichever the system has. A box built from the base image carries python3-minimal, which has neither json nor unittest, so this is what makes the suite runnable inside the box rather than only on the host. When both are already there the step is silent and costs nothing.
End-to-end against real local LLMs, fully isolated in compose — Ollama, LM Studio in headless server mode, and a runner carrying codex and pi. No host network, no API keys, no accounts:
make test-e2e # tears the stack down afterwards
bash tests/e2e/run.sh --keep # leaves it up for debugging
It installs what it needs the same way make test-unit does: tests/e2e/run.sh runs scripts/install-e2e-deps.sh before anything else, which adds docker with the compose plugin when it is missing — from Docker's own apt repository, or pacman, whichever the system has — starts the daemon, and puts the caller into the docker group, then re-runs itself under sg docker so the fresh membership takes effect without a new login. When everything is already there the step is silent.
Inside a box that means nested Docker, so the host needs sysbox (make host-deps); without it the box would have to be --privileged and would stop being a boundary.
Nesting forces one more thing. A box's uplink can carry a smaller MTU than the 1500 a fresh daemon assumes — 1400 is common — and the oversized frames a build container then sends are dropped without a word: docker build dies mid-download with Connection reset by peer, which reads like a flaky network and is not one. The script takes the MTU off the default route and pins the daemon to it in /etc/docker/daemon.json, restarting it only when that value actually changes.
Models are pulled once into named volumes: qwen2.5:0.5b for Ollama, and for LM Studio the Hugging Face repository pinned in tests/e2e/.env — its CLI resolves search terms only against staff picks, so the source is a full URL rather than a name. Twelve checks then assert reachability, the native and OpenAI-compatible endpoints, and that codex and pi actually answer from a local model.
The runner shares the LM Studio container's network namespace, so LM Studio sits on localhost:1234 exactly as the agent CLIs expect while Ollama stays reachable by service name.
Everything runs in CI on every push and pull request, and again before a release.
MIT — see LICENSE.
Kevin Veen-Birkenbach kevin@veen.world
Download counts and popularity will appear here as the collector receives them.
| Version | Published | Direct dependencies | Open version |
|---|---|---|---|
| 1.2.0 | 31 Aug 2026 | 0 |
This describes our collected data; it is not a security assessment.
Shared repository data · GitHub
Observed since 20 Oct, 21:33 UTC
No superseded metadata states collected. Unchanged observations do not create new revisions.
package:adf2e16c800173386a5d4b6946cd0c64da98961421d45282ba8059e71db155cd