Run Herdr shells and coding-agent panes inside the Dev
Container the repository already defines. The plugin uses the official
Dev Containers CLI, keeps
devcontainer.json as the source of truth, and requires no editor.
Point it at a repository that carries a Dev Container configuration and it
resolves the repository, brings the container up, maps your current directory to
the matching path inside the container, and replaces itself with a docker exec
— so the pane you get is an ordinary interactive shell or agent session, just on
the other side of the container boundary. There is no second container format
and nothing here re-implements devcontainer.json.
Current boundary: Linux, including WSL2 — the manifest declares
platforms = ["linux"]. Installing from GitHub compiles the Rust plugin locally, so a Rust toolchain must be present. The target directory must be a Git repository with a Dev Container configuration, unless that repository is explicitly forced on in plugin config.
| Herdr | 0.8.0 or newer (min_herdr_version in the manifest) |
| Platform | Linux or WSL2 |
| Git | any recent version; the plugin shells out to git worktree list |
| Docker | with a reachable daemon |
| Dev Containers CLI | npm install -g @devcontainers/cli |
| Rust and Cargo | 1.74 or newer (rust-version in Cargo.toml) |
Rust is required because the plugin manifest's build hook runs:
cargo build --releaseThe committed Cargo.lock is resolved against that minimum rather than against
the newest published dependencies, so a 1.74 toolchain builds this checkout as
it stands. CI verifies the declared minimum on every change, so the number above
cannot drift away from what actually compiles.
Install the latest reviewed release:
herdr plugin install gambtho/herdr-devcontainer --ref v0.2.0Herdr clones the tagged source and runs the manifest's cargo build --release
hook before registering the plugin. Pass --yes to skip the confirmation
prompt.
To follow current development on main instead — which is not a release:
herdr plugin install gambtho/herdr-devcontainerVerify what got registered:
herdr plugin list --plugin devcontainer
herdr plugin action list --plugin devcontainerFor local development, build once yourself first — herdr plugin link does
not run the manifest build hook:
cargo build --release
herdr plugin link /path/to/herdr-devcontainerThree panes, and one action per pane that opens it from anywhere:
| Pane id | Action id | What it does |
|---|---|---|
shell |
devcontainer.open-shell |
The container user's shell, interactive, inside the repository's Dev Container |
command |
devcontainer.open-command |
Runs the configured command payload through that same interactive shell; default claude |
stop |
devcontainer.open-stop |
Popup that identifies the repository's container — and, for a Compose-based Dev Container, every service alongside it — names them, asks for confirmation, and stops them |
Open a pane directly:
herdr plugin pane open --plugin devcontainer --entrypoint shell
herdr plugin pane open --plugin devcontainer --entrypoint commandOr invoke the equivalent action. The CLI takes the bare action id, with
--plugin to disambiguate:
herdr plugin action invoke open-shell --plugin devcontainer
herdr plugin action invoke open-command --plugin devcontainer
herdr plugin action invoke open-stop --plugin devcontainerOpening a shell or command pane is the explicit lifecycle trigger. Container
lifecycle is never attached to repository events, nothing is stopped
automatically, and the plugin never runs docker rm — stop stops containers,
it does not remove them.
For a Compose-based Dev Container, stop takes down the whole Compose
project, not just the service the pane runs in. That is what
devcontainer.json's default shutdownAction of stopCompose asks for, and
stopping the app while its database and cache keep running is rarely what
"stop the Dev Container" meant. Project membership is read from the container's
own com.docker.compose.project label — the same label Compose itself uses —
so nothing here re-derives the project name from devcontainer.json. The
confirmation lists every container it will stop, by service, name, and id.
Containers started by docker compose run are left alone, matching
docker compose stop. The Dev Container's own service is stopped and waited on
before the rest, so it releases its database connections first. If the Dev
Container has exited on its own while its services keep running, stop says so
and offers to stop what is left — an absent Dev Container is not an absent
project.
- One development environment. Shells and agents see the same tools, dependencies, users, and workspace path the repository's Dev Container already defines for everyone else.
- No duplicated configuration. The plugin delegates to
devcontainer up; it does not parse or reinterpretdevcontainer.json. - Visible bring-up. Build and lifecycle-hook output stays in the new pane instead of disappearing behind an editor.
- No editor required. The official CLI is the only interpreter involved.
- Serialized startup. A per-repository
flockmeans two panes opened at once cannot race through bring-up. - Deterministic container selection. If more than one running container claims the repository, the plugin lists them and refuses to guess.
- Explicit, confirmed shutdown. Stop is its own action, names every
container it will stop — the whole Compose project, where there is one — and
proceeds only on
yoryes.
Bind the fully qualified action id (<plugin id>.<action id>):
[[keys.command]]
key = "prefix+d"
type = "plugin_action"
command = "devcontainer.open-shell"
description = "dev container shell"
[[keys.command]]
key = "prefix+shift+s"
type = "plugin_action"
command = "devcontainer.open-stop"
description = "stop dev container"prefix+shift+s for stop is deliberate: Herdr's default close_workspace
binding is prefix+shift+d, so binding stop to prefix+D would put a
destructive plugin action on top of a destructive built-in one.
These are examples, not reserved defaults. Check what your own map already uses
before adopting them — prefix+? opens Herdr's help, and herdr config check
validates config.toml and prints diagnostics.
Optional, at $XDG_CONFIG_HOME/herdr-devcontainer/config.toml, falling back to
~/.config/herdr-devcontainer/config.toml when XDG_CONFIG_HOME is unset or
empty:
# Any command available inside the container: claude, codex, opencode, ...
command = "claude"
# Ceiling for `devcontainer up`, in seconds.
up_timeout_secs = 300
[repos."/home/you/workspace/foo"]
enabled = "auto" # "auto" (default) | "true" | "false"
config = ".devcontainer/alt/devcontainer.json" # repo-relative
shell = "/bin/zsh" # default: the container user's login shell
env = ["ANTHROPIC_BASE_URL=http://proxy:8080"] # passed as `docker exec -e`| Setting | Meaning |
|---|---|
command |
Payload for the command pane; default claude |
up_timeout_secs |
Bring-up timeout in seconds; default 300 |
enabled = "auto" |
Require a Dev Container config at a standard or explicitly configured path |
enabled = "true" |
Skip detection entirely and let devcontainer up decide what is valid |
enabled = "false" |
Refuse to open container panes for that repository |
config |
Alternate repo-relative devcontainer.json path |
shell |
Shell to exec into, overriding the one probed from the container |
env |
KEY=value assignments passed to docker exec -e |
An env entry that is not KEY=value is dropped with a warning rather than
forwarded: docker exec -e NAME with no = exports the host's variable of
that name into the container, which is never what the setting asked for.
Under auto, detection checks, in order:
.devcontainer/devcontainer.json
.devcontainer.json
Repo keys are matched against the canonicalized repo root. A config value
is repo-relative by contract: absolute paths and .. traversal are rejected in
every mode, including enabled = "true".
Guarantees worth knowing:
- A missing config file means defaults.
- An unreadable one is an error, not a silent fallback — a permission or I/O
failure can never quietly re-enable a repo you set to
enabled = "false". - Unknown keys are warnings, not errors.
The plugin resolves the main Git worktree and uses that canonical path as
repository and container identity. That matches the devcontainer.local_folder
label the Dev Containers CLI writes itself, and it means every linked worktree
of a repository shares one Dev Container rather than racing to create
independent ones that would collide on ports and similar resources.
That label only matches when the CLI created the container. Labels are written
once, at creation, and VS Code on Windows writes local_folder as the host's
UNC view of the WSL path — \\wsl.localhost\Ubuntu\home\you\repo — which no
POSIX repository root can equal. Nothing rewrites it afterward: Docker Compose
reuses containers by its own project name, so a container VS Code created stays
reachable through devcontainer up while carrying a label the plugin cannot
match. Discovery therefore also looks containers up by devcontainer.config_file,
which the CLI resolves from inside WSL and so holds a POSIX path on those same
containers. Both are exact-match lookups; the plugin never guesses at how a host
renders a path.
That also defines the main limitation:
A linked worktree checked out outside the main repository directory is not automatically mounted inside the main repository's container. When the current directory cannot be mapped under the repository root, the pane starts at the container's workspace root and prints a notice rather than pretending the external checkout is available inside the container.
For a current directory underneath the main repository root, the relative host
path is appended to the container's remoteWorkspaceFolder and used as the
docker exec working directory. That directory comes from Herdr's invocation
context (the focused pane, then the workspace), not from the wrapper's own
working directory — Herdr runs a plugin pane from the plugin root, so reading
the process cwd would report every launch as an out-of-repo checkout.
Panes exec the container user's shell interactively — the shell from the
container's passwd entry, sh (with a printed note) when that cannot be read or
names nologin/false. Interactive is the part that matters: ~/.zshrc and
~/.bashrc are sourced only by an interactive shell, and that is where Dev
Container setup scripts put PATH entries and API endpoints. A login-only shell
reads ~/.zprofile and stops, which is how an agent in a container silently
bypasses a configured proxy.
Shells are also started as login shells, so /etc/profile and ~/.zprofile
apply — except bash, which is the one shell that reads ~/.bashrc only when
interactive and not a login shell. Images that ship a ~/.bashrc and no
profile file at all are common enough (devcontainers/base is one) that adding
-l for bash would lose exactly the environment this is here to collect, so
bash gets -i/-ic and every other known shell gets -li/-lic.
"Known" is literal: sh, ash, dash, busybox, zsh, ksh, mksh, pdksh, yash, and
fish are each verified to accept those combined flags. A shell outside that list
is driven with bare -c instead, because not every shell takes them —
tcsh -lic fails with "Unknown option", which would produce a pane that never
opens. That fallback is the one case where a command pane is not interactive
and no startup file is guaranteed: an unknown shell is run plainly and left to
its own rc conventions. Name it in shell and it is used as given, but the flag
rules above still apply to it by basename.
Set shell for a repository to override the probe.
One consequence worth knowing: rc files run, so an rc file that changes
directory wins over the mapped working directory. docker exec -w still puts
the shell in the right place — a cd /app in ~/.zshrc (some images bake one
in) simply runs afterwards. If subdirectory panes matter more than that cd,
make the rc line conditional; there is nothing this plugin can do from outside
without discarding the rc file's environment along with it.
What this does not do is apply remoteEnv from devcontainer.json. That
value is a merge of the config file, every Feature's contributed metadata, and
the image's devcontainer.metadata label, and devcontainer up does not report
the resolved result — so parsing the config file alone would produce a partial
environment that looks authoritative. A pane's environment therefore comes from
three places only: what the image itself sets, what the shell's startup files
add, and what you list in env. Often the shell covers what remoteEnv would
have, because the same setup scripts write both — but that is a coincidence of
how a repository is built, not a guarantee. Anything you actually depend on
belongs in env.
For a shell or command pane, the wrapper:
- resolves the repository's main Git worktree from Herdr context, falling back to Git on the pane and process working directories;
- detects the repository's Dev Container configuration;
- verifies the Dev Containers CLI is on
PATHand the Docker daemon answers; - discovers existing containers by the
devcontainer.local_folderanddevcontainer.config_filelabels; - refuses to continue when more than one of them is running;
- acquires a per-repository lock;
- runs
devcontainer upand validates its success result; - maps the pane's directory into
remoteWorkspaceFolder; - probes the container user's login shell; and
- replaces itself with
docker exec -i -t.
Two rules run through the whole implementation. Host-side subprocesses are
invoked as direct argv arrays with no shell in between, so repository-controlled
paths cannot inject host commands — only the configured payload is interpreted,
by the container's own shell inside the container. And uncertainty is never
absence: a Docker check that fails, or a docker ps line that will not parse,
is an error, never "there is no container."
The shell probe is the one deliberate exception, because a container that will
not name its user's shell is still a container worth opening. It degrades to
sh rather than failing — but it says so, and says why, so the degraded pane is
never mistaken for a working one.
The full design, including the wrapper flow step by step, is in
docs/superpowers/specs/2026-08-11-herdr-devcontainer-plugin-design.md.
This plugin is not a security boundary, and opening a Dev Container is not sandboxing.
- Installing a Herdr plugin runs its build and runtime commands as your user.
Review
herdr-plugin.tomland the source before installing code you do not trust. devcontainer upexecutes repository-controlled build and lifecycle code —Dockerfilesteps,postCreateCommand, and the rest. Opening a Dev Container for a repository you do not trust runs that repository's code, exactly as it does in VS Code Dev Containers. This plugin inherits that trust model and does not add to it.- What a Dev Container does give you is a consistent environment, not an isolated one.
- The pane payload never comes from repository content. It is either a plain login shell or the command in your own plugin config.
cargo build --release
cargo test
cargo test --test integration -- --ignoredThe ignored integration suite performs a real container bring-up and requires
Docker, the Dev Containers CLI, and script(1).
Nothing in this repository parses herdr-plugin.toml, so a green Rust test run
says nothing about the manifest being valid. That is only verified by linking or
installing the plugin into a real Herdr and exercising all three entrypoints.
The current release is
v0.2.0,
matching the version in both Cargo.toml and the plugin manifest. Pin to it
with --ref to install a reviewed revision:
herdr plugin install gambtho/herdr-devcontainer --ref v0.2.0An install without --ref follows the current default branch instead.
MIT. See LICENSE.