ubuntu:26.04plus a pinned toolchain: every external binary has an explicitARG <TOOL>_VERSIONin theDockerfileand is verified — upstream's checksum file, else GitHub's recorded asset digest, else a signed package repository. The one download pinned by tag only is nvm's installer (fetched over TLS; upstream publishes no checksum file or asset digest for it), and nvm then checks Node against nodejs.org'sSHASUMS256.txt. This page covers the pinned versions, where each tool lives, the agents (OMP, Claude Code, Moshi), skills and browser automation, and how to add a tool.
Related: Installation · Docker · Secrets · Git Identities · Security Model · CLI Reference
| Tool | Version | Installed as |
|---|---|---|
herdr |
0.9.4 |
/usr/local/bin/herdr |
node |
24.21.0 |
nvm in /opt/nvm, symlinked into /usr/local/bin |
pnpm |
12.11.2 |
npm install -g, symlinked into /usr/local/bin |
gh |
2.102.0 |
pinned .deb |
lazygit |
0.66.1 |
/usr/local/bin/lazygit (alias lg) |
wt (worktrunk) |
0.80.0 |
/usr/local/bin/wt + git-wt |
terraform |
1.16.6 |
/usr/local/bin/terraform |
nvm |
0.40.8 |
/opt/nvm |
| Docker CLI | 29.9.0 |
docker-ce-cli from Docker's signed apt repository, /usr/bin/docker |
| Compose plugin | 5.6.0 |
/usr/local/lib/docker/cli-plugins/docker-compose |
| Buildx plugin | 0.38.0 |
/usr/local/lib/docker/cli-plugins/docker-buildx |
Also from the Ubuntu archive: git, git-lfs, starship, ripgrep, fd (symlinked from fdfind), jq, curl,
rsync, build-essential, python3, openssh-server/-client, nano, less, procps, iproute2, socat (backs
devbox-ports, see Project containers).
Deliberately absent: op (no 1Password account here) and gcloud (projects reach Google APIs via a per-project
service-account key; ADC needs only the key file) — see Secrets.
Check what's running — doctor fails on a tool's real exit code, not a blank line:
ssh <workstation> 'cd ~/devbox && ./bin/devbox doctor'| Location | Why |
|---|---|
/usr/local/bin |
Default non-interactive SSH PATH; herdr needs it since non-interactive SSH uses only a pre-existing compatible binary and never installs — true of saved-machine background connections. |
/opt/nvm + /opt/corepack |
Not ~/.nvm: /home/dev's bind mount would shadow anything the image installs there. node, npm, npx, corepack, pnpm symlink into /usr/local/bin to resolve without a login shell. |
~/.local/bin |
OMP, Claude Code (~/.local/bin/claude, a symlink into ~/.local/share/claude/versions) and moshi-hook, installed by bootstrap rather than baked in, so omp update, Claude's auto-update and moshi-hook update work without a rebuild. Also devbox-gh-token, the per-directory GitHub token resolver (Secrets). |
~/.local/libexec/devbox-agent |
The agent launchers (omp-launcher, claude-launcher, and the agent-launch body they share), the gh shim, and two git-fencing scripts (devbox-git-credential, devbox-git-no-ssh), plus omp and claude symlinks to the launchers. home/.bashrc.d/devbox.sh puts it first on every devbox shell's PATH, shadowing gh, omp and claude; a launcher does likewise for its own process tree on the laptop (./bin/devbox agent install, where the omp/claude symlinks sit in a launchers/ subdirectory of their own). home/.bash_profile re-asserts that order for login shells, where the distro's ~/.profile prepends ~/.local/bin after sourcing ~/.bashrc and would otherwise put the real binaries in front. See Git Identities. |
/usr/local/lib/docker/cli-plugins |
Home for docker compose/docker buildx as CLI plugins; only the client ships in the image, the daemon is the host's rootless dev daemon (below). |
Projects with their own containers reach Docker via a second, rootless host daemon, never one nested here — see
Docker for the model and the one-time sudo ./bin/devbox docker setup.
docker compose up -d
devbox-ports # mirrors published ports onto 127.0.0.1Important
Keep DOCKER_CLI_VERSION equal to the host daemon's — compose refuses an API newer than its server.
omp # TUI
omp --version
omp update # updates ~/.local/bin/omp in place; no image rebuildFrom the workstation, ./bin/devbox update runs omp update, claude update and hook --update in one go
(CLI).
omp on the PATH is the agent launcher, not the binary; omp update passes through to the real install it shadows,
which is why the launcher survives an update. See Git Identities.
bootstrap installs the binary when ~/.local/bin/omp is absent — straight from the latest GitHub release, not through
omp.sh/install.sh, which downloads the same asset and installs it unchecked. The release API's digest (the SHA-256
GitHub records for the asset) is checked first, and the file is renamed into place only then. Every bootstrap, with
OMP installed or not, first deletes any ~/.local/bin/.omp.?????? temp file older than 5 minutes: a killed run leaves
its temp file behind, while a live download writes at least every ~40 s (10 s connect timeout plus the 30 s stall
window), so a concurrent bootstrap's download is never swept. The download aborts only when stalled (under 1 KiB/s
for 30 s), never a slow one. A failed download or check skips OMP and leaves a checklist item; it never stops the rest
of bootstrap.
~/.omp/agent/config.yml seeds from home/.omp/agent/config.yml only if absent — by bootstrap on the devbox, by
./bin/devbox agent install on the laptop. That file is the one preset the laptop and the devbox share: model roles,
theme and feature flags, secrets: { enabled: true } obfuscating an API key in the environment
(~/.config/devbox/secrets.env or a project .env) before it reaches a provider, and a bash.patterns guardrail:
| Action | Commands |
|---|---|
deny |
Reaching past the agent's scoped tokens: gh auth token|login|…, an absolute-path gh, env -u, unsetting or reassigning GIT_CONFIG_GLOBAL, GIT_SSH_COMMAND, GH_CONFIG_DIR, GIT_TERMINAL_PROMPT, keychain reads, gh secret/variable/repo delete. History rewrites: --no-verify, force push. |
prompt |
env -i (this repo's smoke tests use it), op, gcloud, terraform apply |
The installers never overwrite your edits; an existing file without a bash: block is reported by both so you can
copy the block in. A project's own bash.patterns replaces this list entirely — arrays don't merge across settings
layers. What it does and does not stop: Security Model.
Local workstation-served models are opt-in: copy your model-serving repo's harnesses/omp.yml into
~/.omp/agent/models.yml, pointing the provider baseUrl at the workstation's Tailscale address.
home/.omp/agent/config.yml is the one preset. Change it there, commit, and apply it to both machines:
./bin/devbox sync omp # repo preset → laptop and devbox:~/.omp/agent/config.yml
./bin/devbox sync omp devbox-2 # a different ~/.ssh/config hostA setting changed in OMP on the laptop lands in the laptop's live file only: copy it over home/.omp/agent/config.yml
(make fmt) and commit first, or sync omp replaces it. Only config.yml moves; agent.db, history.db,
sessions/, memories/ and models.yml stay per-machine, untouched. Each machine keeps the replaced file as
config.yml.bak; a running session needs a restart for the new preset.
Warning
A preset without a top-level bash: block is refused — it would silently replace each machine's seeded guardrail —
unless you pass --allow-unguarded.
claude # TUI; same agent git override as omp
claude --version
claude update # updates ~/.local/bin/claude; it also auto-updates in the backgroundbootstrap installs it with the native installer (curl -fsSL https://claude.ai/install.sh | bash), which verifies its
download against its own manifest's checksum, when ~/.local/bin/claude is absent; like OMP it carries no ARG pin,
because it updates itself on the bind mount. claude
on the PATH is ~/.local/libexec/devbox-agent/claude → claude-launcher, so every session commits and pushes as the
identity's bot, exactly like an omp session (Git Identities).
One manual step: log in once, from a pane — run claude and follow /login (open the printed URL in a laptop browser,
paste the code back). The credentials land in ~/.claude/.credentials.json on the bind mount and survive rebuilds
(Secrets); bootstrap lists the login as outstanding until that file exists.
Everything else under ~/.claude — sessions, memory, credentials — is Claude's own state, never synced from the
laptop. The one exception is settings.json, below.
home/.claude/settings.json is the Claude Code counterpart of the OMP preset: model (opus) and permissions.deny
/ permissions.ask rules that mirror the OMP bash.patterns guardrail, with the same deny/prompt split
as the table above, written as Bash(<pattern>). OMP's op * plus bare op and gcloud * plus bare
gcloud are one rule each, because Claude's Bash(op *) also matches bare op.
Unlike OMP's config.yml, ~/.claude/settings.json has other writers — moshi-hook and IDE integrations add hooks
and statusLine, Claude itself writes model (/model) and rules (/permissions) — so the preset is merged, never
copied: jq -s '.[0] * .[1]' of the live file and the preset (cli/lib/claude_settings.sh). Every key the preset does
not set stays; the keys it sets take its value; arrays, the rule lists, are replaced whole, so a rule removed from the
preset disappears from the live files. A top-level key removed from the preset entirely stays in them.
It is seeded when absent, where absent means the file is missing or holds no permissions.deny — moshi-hook writes
its hooks into the same file, so on a box that ran bootstrap before the preset existed it is rarely missing:
bootstrap(section 14, beforemoshi-hook's section 15) seeds or merges it on the devbox; invalid JSON becomes a manual action in the checklist../bin/devbox agent installdoes the same on the laptop, keeping the previous file assettings.json.bakwhen it changed an existing one; invalid JSON is listed as a manual step. It needsjq.- A file that already has
permissions.denyis left alone by both, so your own rules are never touched implicitly.
To re-apply the preset deliberately after changing it, commit it and run, from the laptop:
./bin/devbox sync claude # preset merged into laptop and devbox:~/.claude/settings.json
./bin/devbox sync claude devbox-2 # a different ~/.ssh/config hostIt merges this laptop's file first, then fetches the devbox's over ssh (Host devbox), merges it locally and copies it
back, keeping one settings.json.bak on each machine when the file changed and skipping the copy when the devbox
already holds the preset. The source defaults to home/.claude/settings.json; CLAUDE_SETTINGS overrides it. A running
Claude Code session needs a restart to pick the new rules up. There is no --allow-unguarded here: a merge cannot drop
the live rules unless the preset sets an empty array. Details: CLI Reference.
What differs from OMP's guardrail, and what it does and does not stop: Security Model.
Note
An ANTHROPIC_API_KEY in secrets.env (there for OMP) never reaches Claude: claude-launcher unsets it, so Claude
always runs on the /login subscription rather than billing the key (Secrets).
The container is the sandbox, so bypass-permission mode (claude --dangerously-skip-permissions) is the same call here
as it is for omp: it reaches the project tree, the internet and the rootless project daemon, never the host.
Moshi connects like any SSH client (BIND_ADDR:2223, user dev, authorized key) for a terminal
and herdr panes only — not push notifications, lock-screen approvals or Chat View, which need moshi-hook, a companion
daemon bootstrap installs into ~/.local/bin and the entrypoint starts. bootstrap wires it into every agent whose
config directory exists (moshi-hook install; ~/.claude, ~/.omp, … — a binary alone is not detected, and no
directory is created).
| Piece | Where it lives | What breaks without it |
|---|---|---|
moshi-hook binary |
~/.local/bin (bind mount, self-updating) |
Everything below |
| OMP extension | ~/.omp/agent/extensions/moshi-hooks.ts |
No OMP lifecycle events emitted |
| Claude Code hooks | hooks in ~/.claude/settings.json |
No Claude lifecycle events emitted |
Daemon (moshi-hook serve) |
Started by container/entrypoint.sh |
Events go nowhere; socket silence |
| Pairing | One manual moshi-hook pair --token |
Daemon runs, sends nothing to a phone |
OMP is a Tier A Moshi agent: pairing brings the inbox, approvals and native transcript view, not just completion pings.
Pairing is the one manual step: the token is per-account, belongs to the phone, not the repo:
ssh devbox 'moshi-hook pair --token <token from Settings → Hooks in the app>'
ssh <workstation> 'cd ~/devbox && ./bin/devbox hook' # restart so it picks the pairing upbootstrap flags pairing as outstanding until done; doctor reports it running-but-unpaired. Pairing state survives
rebuilds under the bind-mounted home like other credentials.
No systemd means moshi-hook service install can't be used — container/entrypoint.sh starts the daemon, tying its
lifetime to the container's. Restart a crashed/hand-killed one with ./bin/devbox hook (a detached
docker compose exec: no recreate, no dropped SSH sessions); ./bin/devbox up alone won't revive it unless compose
recreates the container. A new release is ./bin/devbox hook --update: moshi-hook update, every agent's hooks
rewritten (moshi-hook install), then the same restart — update alone leaves the old version serving.
./bin/devbox update does the same after updating OMP and Claude Code, and reinstalls the hooks even when the
moshi-hook update fails.
A second daemon is harmless: serve exits with another moshi-hook serve is already running (pid N, lock …), and a
killed daemon's lock never blocks the next start, even across PID reuse.
Moshi also needs the daemon's gateway 127.0.0.1:24543, reached over its own SSH connection — already permitted by
AllowTcpForwarding yes in container/sshd_config — so nothing new is published.
moshi-hook status # pairing, multiplexers, per-agent hook state
moshi-hook logs -f # ~/.local/state/moshi/hook.log
moshi-hook install # rewrite every agent's hooks by hand (hook --update and update already do)Optional, recommended, and not in bootstrap since the first run downloads a ~180 MB Chrome build:
ssh <workstation> 'cd ~/devbox && ./bin/devbox skills'container/skills.sh installs three skills with --global --agent universal claude-code: the skill itself lands in
~/.agents/skills, which OMP reads, and a symlink to it in ~/.claude/skills, the only skills directory Claude Code
reads:
| Skill | Source, pinned at | What it is for |
|---|---|---|
agent-browser |
vercel-labs/agent-browser @ v${AGENT_BROWSER_VERSION}, the CLI's tag |
Browser automation: navigate, fill, screenshot, test |
skill-creator |
anthropics/skills @ SKILL_CREATOR_REF, a commit (the repo has no tags) |
Authoring, editing and evaluating skills |
find-skills |
vercel-labs/skills @ v${SKILLS_CLI_VERSION}, the skills CLI's tag |
Discovering and installing more skills mid-task |
A skill is instructions an agent follows, so it is pinned like a binary: each comes from a GitHub tree URL at that ref,
which the CLI refuses when the ref doesn't exist — a bare owner/repo would install whatever the default branch holds
that day.
Also installs the pinned agent-browser CLI and Chrome build:
agent-browser open https://example.com
agent-browser snapshot # accessibility tree, with refs to act on
agent-browser close- Chrome's shared libraries are in the image.
agent-browser install --with-depsneeds rootapt-get, unavailable todev, so the ~26 packages Chrome links against are baked into theDockerfile— the headless set, omitting GTK, Vulkan and CJK fonts on purpose (~300 MB). - The npm prefix is per call, not exported.
npm install -g --prefix "$HOME/.local"lands the binary in~/.local/bin, on the non-interactivePATHand bind mount, unlike a global install under/opt/nvm. - Chrome lives in
~/.agent-browser/browsers. Also the bind mount, so rebuilds skip re-download.
Caution
Exporting NPM_CONFIG_PREFIX (or setting prefix= in ~/.npmrc) instead of the per-call --prefix makes nvm refuse
its default Node in every interactive shell — do neither.
Re-running ./bin/devbox skills is safe: npm and npx skills add overwrite in place, Chrome skips if present. Bump
SKILLS_CLI_VERSION/AGENT_BROWSER_VERSION/SKILL_CREATOR_REF in container/skills.sh to move a pin (the first two
move their skill with them), or export any of them for one run.
node -v # v24.21.0
pnpm -v # 12.11.2pnpm installs via npm install -g pnpm@12.11.2, not corepack prepare: pnpm 12's JS wrapper loads a native binary from
its own node_modules, but corepack's cache held only the wrapper — every shimmed call re-downloaded it. corepack
stays enabled for yarn, with COREPACK_HOME set so repos declaring packageManager still work.
wt (worktrunk) is shell-integrated by bootstrap, so wt switch can change the current directory:
wt switch -c smoke # create a worktree and cd into it
wt switch main # back
wt listTip
Run wt switch directly, not piped: inside cmd | tail it runs in a subshell whose cd can't reach your shell.
Add ARG <TOOL>_VERSION=<version> next to the others in the Dockerfile.
curl -fsSL to a temp dir, verify it, install -m 0755 into /usr/local/bin. In order of preference: upstream's
checksum file (sha256sum -c); else, for a GitHub release asset, the SHA-256 GitHub records for it — the release API's
digest field, as herdr does; else a signed package repository, its key fingerprint pinned, as the Docker CLI does
(download.docker.com publishes no checksum for its static tarball).
Caution
Never invent a hash: every value checked against comes from upstream or GitHub at build time, or is a fingerprint the
vendor publishes. The one exception is nvm's installer, pinned by tag over TLS only: upstream publishes no checksum
file or asset digest for it, and nvm then checks Node against nodejs.org's SHASUMS256.txt. Don't widen it.
Add a probe to doctor's list in cli/lib/doctor_host.sh if the version matters, then run
make build and commit cli/ with the regenerated bin/devbox.
./bin/devbox deploy <workstation> && ssh <workstation> 'cd ~/devbox && ./bin/devbox rebuild'Host-specific tweaks go in docker-compose.override.yml: compose auto-loads it, gitignored, so extra mounts, env vars
or resource limits stay local to one workstation.
No — dev has no root or sudo, by design: the image records what exists. Add it to the Dockerfile and rebuild; for
throwaway tools use pnpm dlx or python3 -m venv.
Yes, via a second rootless daemon owned by a dedicated user — see Docker. The host's root socket stays
unmounted; nesting is impossible: rootless docker needs setuid helpers newuidmap/newgidmap, ruled out by
cap_drop: [ALL] and no-new-privileges.
No — the image rebuilds from scratch; /home/dev is a host bind mount, untouched. Only things installed into the
image disappear.
So updates work without a rebuild: bootstrap installs each into ~/.local/bin only if absent, never overwriting an
install already there — a build-time pin would be shadowed on PATH and falsified by self-update anyway.
ARG <TOOL>_VERSION's only three exceptions, and all still verified: moshi-hook against upstream's checksums.txt,
OMP against GitHub's digest for its release asset — each fetched directly, since OMP's installer never checks and
moshi-hook's skips the check whenever it can't run it — and Claude Code by its own installer, against its manifest's
checksum.
nvm install <version> works normally — writes to /opt/nvm, writable by dev, persisting until the next rebuild. Pin
the default by bumping NODE_VERSION.
Expected — prompt, aliases and nvm loading are interactive-guarded in home/.bashrc.d/devbox.sh. PATH isn't guarded,
so tools still resolve.
Something exported NPM_CONFIG_PREFIX (an earlier home/.bashrc.d/devbox.sh did), and nvm's nvm_die_on_prefix
refuses the default Node. unset NPM_CONFIG_PREFIX clears the shell; fix with --prefix on each npm i -g call.
It symlinks them into ~45 per-agent dotdirs (~/.aider-desk/skills, ~/.astrbot/data/skills, etc.) for tools not
installed here, reporting Failed to install 2 for two rejecting global installs. --agent universal writes only
~/.agents/skills; add --agent claude for one reading its own directory.
No — it launches and drives Chrome headless as dev with cap_drop: [ALL] unchanged, verified via agent-browser open
plus snapshot. Headed mode needs the omitted GTK packages and a virtual display.
Yes — ~/.agents/skills is on the bind mount. Only Chrome's shared libraries live in the image, rebuilt with it.
npx skills update keeps each skill at the ref recorded in ~/.agents/.skill-lock.json (verified with skills 1.5.26
and 1.7.1), so it changes nothing here; to move a skill, bump its pin in container/skills.sh and re-run
./bin/devbox skills.