Shared dev container Features so every
repo's containers come with the same baseline — without copy-pasting post-create.sh
across repos.
Installs the AI coding agents we standardize on:
- OpenAI Codex CLI (
@openai/codex, version pinned via thecodexVersionoption) - Claude Code (pulled in automatically via
dependsOnon the officialghcr.io/anthropics/devcontainer-features/claude-codefeature) - GitHub CLI (
gh, viadependsOnon the officialghcr.io/devcontainers/features/github-clifeature) - 1Password CLI (
op, installed from 1Password's apt repo) — soghcan authenticate from a 1Password-sourced token instead of persisting credentials on the host
The feature mounts a named volume (devcontainer-claude-config at /home/node/.claude)
and sets CLAUDE_CONFIG_DIR, so the Claude session survives container rebuilds — you
log in once instead of after every rebuild. A postCreateCommand chowns the volume so
the node user can write to it.
The volume is shared across all repos that use this feature (the Claude login is account-level, not repo-level), so logging in from one container carries over to the others. The mount path assumes the
noderemote user (the base image we standardize on).
Earlier versions persisted the gh login in a devcontainer-gh-config volume, which left
a long-lived GitHub token sitting on the host indefinitely. As of v2.0.0 that volume is
gone. gh authenticates from a GITHUB_TOKEN environment variable instead, resolved
from 1Password on your host and forwarded into the container — nothing is written to the
host.
The reason it's forwarded from the host rather than resolved inside the container:
1Password's desktop-app integration (Touch ID unlock) does not work inside a
container. The op CLI's app integration talks to the desktop app over a host-only socket
the container can't reach. So op runs on your host, and only the resulting token
crosses into the container — gh reads GITHUB_TOKEN directly.
One-time host setup (the part that tripped us up — note the gotchas):
-
Install the
opCLI — no package manager required. Download the macOS package or the standalone universal binary from https://1password.com/downloads/command-line, then enable 1Password app → Settings → Developer → Integrate with 1Password CLI (Touch ID). Verify withop vault list. -
Export the token from your shell rc — for zsh this is
~/.zshrc(not~/.zsh_rc, which zsh never sources):export GITHUB_TOKEN="$(op read 'op://Employee/GitHub Personal Access Token/token')"
Use the item's exact secret reference — in the 1Password app, right-click the field → Copy Secret Reference. Reload and check:
source ~/.zshrcthenecho ${#GITHUB_TOKEN}should be non-zero. -
Make the variable visible to the editor process.
${localEnv:...}is read from the editor's process environment, and GUI/Dock/Spotlight launches do not read~/.zshrc— so a Dock-launched editor won't seeGITHUB_TOKEN. Two ways to fix it:- Launch from a terminal (scopes the variable to that editor instance). Fully quit
the editor first, then from a terminal where
echo ${#GITHUB_TOKEN}is non-zero start it (code). Best when you open a folder/workspace from the CLI. launchctl setenv(works with Dock/Spotlight launches — and with the no-checkout "Clone Repository in Container Volume" flow, where you never open a folder from the CLI):This puts the variable into your GUI login session, so anything launched afterward (including a Dock-launched editor) inherits it. Caveats:launchctl setenv GITHUB_TOKEN "$(op read 'op://Employee/GitHub Personal Access Token/token')"- Relaunch any already-running editor — it only picks up the value on a fresh launch.
- Not persistent —
launchctl setenvis cleared on logout/restart; re-run it each session, or automate it with a login LaunchAgent (below). - Session-wide — it's readable by all GUI apps in your login session, not just the editor. If you'd rather keep it scoped, use the terminal launch instead.
Then build/reopen the container and check
gh auth status.Optional: re-apply it automatically at login (LaunchAgent)
Create
~/Library/LaunchAgents/com.rocicorp.github-token.plist:<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>Label</key><string>com.rocicorp.github-token</string> <key>ProgramArguments</key> <array> <string>/bin/sh</string> <string>-c</string> <string>launchctl setenv GITHUB_TOKEN "$(/usr/local/bin/op read 'op://Employee/GitHub Personal Access Token/token')"</string> </array> <key>RunAtLoad</key><true/> </dict> </plist>
Load it with
launchctl load ~/Library/LaunchAgents/com.rocicorp.github-token.plist. Caveat: it runsop readnon-interactively at login, which only succeeds if 1Password can authorize without a prompt (e.g. the app is unlocked / CLI integration allows it) — otherwise just run thelaunchctl setenvline by hand each session. - Launch from a terminal (scopes the variable to that editor instance). Fully quit
the editor first, then from a terminal where
Why this shape: only a short-lived GitHub token ever enters the container (not a credential that can read your whole vault), the secret still originates in 1Password, and nothing is persisted on a host volume. If
GITHUB_TOKENisn't set,ghis simply unauthenticated — a clean fallback; rungh auth loginmanually if you like.
In any repo's .devcontainer/devcontainer.json:
"features": {
"ghcr.io/rocicorp/devcontainer-features/agents:2": {
// optional — override the pinned Codex version
"codexVersion": "0.139.0"
}
}For gh authentication, add the remoteEnv GITHUB_TOKEN passthrough from
gh auth via 1Password above.
This single line replaces the official claude-code and github-cli feature lines, the
inline npm install -g @openai/codex, and the .claude volume / CLAUDE_CONFIG_DIR /
chown wiring that otherwise lives in each repo's devcontainer.json + post-create.sh.
- Runs
corepack enable(adds thepnpmshim) at build time. - At
postCreate, runscorepack installto pin the pnpm version from the workspace'spackage.jsonpackageManagerfield. - Removes the
npm/npxbinaries (after build-time installs have run) to enforce pnpm-only usage. This is the default; setremoveNpm: falseto keep npm available.
"features": {
"ghcr.io/rocicorp/devcontainer-features/pnpm:1": {}
}This replaces the corepack/pnpm/npm-removal block that otherwise lives in each repo's
post-create.sh. Combined with agents, a consumer repo's devcontainer.json needs no
lifecycle scripts at all.
Gives the container a working Docker daemon so tooling that shells out to Docker — most
notably testcontainers (used by the zero-cache Postgres
integration tests) — runs inside the dev container.
- Pulls in the official
ghcr.io/devcontainers/features/docker-in-dockerfeature viadependsOn, which installs the Docker engine, runs a daemon inside the container, and adds the remote user to thedockergroup (nosudoneeded). - Pins
"moby": falseso the upstream feature installs Docker CE from Docker's own apt repo instead of Microsoft'smoby-*packages, which don't exist on Debian trixie (the base of currentjavascript-nodeimages) and fail the build. - Uses Docker-in-Docker rather than docker-outside-of-docker on purpose: testcontainers relies on bind mounts and container-to-container networking, both of which break under the host-socket approach (path translation) and aren't available in every environment (Codespaces, CI). A self-contained daemon "just works" everywhere.
"features": {
"ghcr.io/rocicorp/devcontainer-features/docker:1": {}
}This replaces a per-repo docker-in-docker feature line and centralizes the pinned version
alongside the other rocicorp features.
- Bump
codexVersiondefault (and/or thedependsOnclaude-code pin) insrc/agents/devcontainer-feature.json, raise the featureversion, merge tomain. The release workflow publishes a new tag toghcr.io. - Consumer repos pick it up on next rebuild. To avoid hand-editing pins, enable
Dependabot (
devcontainersecosystem) in each consumer repo — it opens PRs that bump thedevcontainer-lock.jsondigests automatically.
.github/workflows/release.yml publishes all features under src/ to
ghcr.io/<owner>/devcontainer-features/<id> on push to main
(via devcontainers/action).
After the first publish, make the package public in the repo's Packages settings (or org package visibility) so consumer repos can pull it without auth.
npm install -g @devcontainers/cli
devcontainer features test \
--features agents \
--base-image mcr.microsoft.com/devcontainers/javascript-node:24 \
.