An opinionated, repeatable development environment for cow projects: one Colima VM plus one devcontainer per repository.
Corral targets Apple Silicon macOS + Homebrew + Colima. It keeps the host toolchain small, works behind host VPNs, and gives coding agents a narrower environment than your Mac account. It is a rolling environment, not a bit-for-bit reproducible build: package and tool versions update on rebuild.
The Colima VM mounts only the configured work directory (~/work/cow by
default), not your whole home directory. Devcontainers do not receive the
Docker socket, host SSH files, host Git config, or host GitHub CLI credentials.
A devcontainer is not a vault. Code running inside it can:
- modify or delete the bind-mounted repository;
- read the shared Claude/Codex state in
cow-agents; - read Git configuration in
cow-git; - reach the forwarded YubiKey agent and trigger a PIN/touch prompt.
Keep authenticated gh usage on the Mac. Do not delegate git commit or
git push to an agent; the human PIN and touch are the authorization boundary.
- Colima VM — one shared Linux VM using
vzandvirtiofs. It mounts only the configured work directory. - Per-repository devcontainer — the repository is bind-mounted from the
Mac; tools and agents run as the non-root
nodeuser. - Two Docker volumes —
cow-agentsstores Claude/Codex state andcow-gitstores Git identity, signing, and lazygit configuration.
Docker runs inside the VM, not directly on macOS. That explains why host home files, USB devices, and container-localhost listeners are not directly available inside a devcontainer.
Alpaca and Corral solve related YubiKey-protected development problems, but use different workspace models:
- Alpaca provides one isolated Lima VM. It deliberately mounts no host filesystems, forwards the host YubiKey agent into the VM, and is a good fit when the strongest practical separation from the Mac is the priority or when you want a self-contained general-purpose Linux VM.
- Corral provides one shared Colima VM and a devcontainer for each repository. It intentionally mounts one work directory and shared state volumes, which makes editor integration, per-repository tooling, and Linux dependencies convenient across cow projects. Use it when that workflow and repository-level customization matter more than fully isolating the active source tree.
Both keep YubiKey private keys on the hardware and route Git authentication and signing through a forwarded agent. Alpaca's no-mount design has the narrower VM filesystem boundary; Corral trades some of that isolation for a bind-mounted working tree and devcontainer ergonomics. Choose one as the workspace boundary for a project rather than assuming Corral's mounts provide the same isolation as Alpaca.
Corral defaults to ~/work/cow. To use another folder, export its absolute or
home-relative path before running colima-up.sh and git-setup.sh:
export CORRAL_WORK_DIR="$HOME/projects/cow"
mkdir -p "$CORRAL_WORK_DIR"Put the export in your shell configuration if those setup commands will run in
different terminals. Corral resolves the path before writing the Colima mount
and scoped Git include. It refuses / and your whole home directory so the VM
cannot silently regain broad host access.
./install.shThis installs Colima, Docker CLI/buildx, GitHub CLI, Node when missing, and the devcontainer CLI.
Authenticate GitHub CLI on the Mac, once:
gh auth login --web --git-protocol ssh --skip-ssh-keyRun gh pr, gh issue, and gh api from the Mac checkout. Corral deliberately
does not install or authenticate gh inside devcontainers.
When upgrading from an older Corral setup, remove the legacy
/home/node/.agents/gh directory from cow-agents (or reset that volume)
before treating the VM as free of GitHub API credentials.
Do this before creating the VM so Colima forwards the intended host agent.
./yubikey-prereqs.sh install
ykman fido access change-pin # only if no FIDO2 PIN is configured
./yubikey-enroll.sh # creates signing and authentication keysBoth resident Ed25519-SK keys require PIN verification and touch on every use. Private key material stays on the YubiKey; the Mac stores only handle and public-key files.
Add the two printed public keys at https://github.com/settings/ssh/new, choosing the matching Authentication or Signing key type. Then start the dedicated host agent:
./yubikey-agent-up.sh # --shell zsh|bash|fish; default zshOpen a new terminal so it receives the exported environment, then verify:
echo "$SSH_AUTH_SOCK" # ~/.ssh/agent.sock
ssh-add -l # exactly the two SK keys
ssh -T git@github.com # PIN + touchOn the first GitHub SSH connection, verify the presented host fingerprint against GitHub's published fingerprints before accepting it.
The macOS-provided ssh-keygen recognizes security-key types but lacks the
FIDO2 middleware needed to enroll them. The YubiKey scripts therefore call the
keg-only Homebrew OpenSSH binaries by full path. They do not change your
shell's PATH.
Run this from the new terminal where SSH_AUTH_SOCK points to
~/.ssh/agent.sock:
./colima-up.sh web # profile name; "web" is the default
colima ls # expect 4 CPU / 8 GiB
colima ssh --profile web -- \
env SSH_AUTH_SOCK=/run/host-services/ssh-auth.sock ssh-add -lcolima-up.sh installs the complete colima.yaml template,
recreates the profile VM, and keeps Colima's persistent container-data disk.
Current Colima requires colima delete --data to delete that disk.
Colima applies resource, mount, and agent-forwarding settings when the VM is
created. Re-run colima-up.sh after changing those settings.
Git configuration survives a normal reboot: host settings live in
~/.gitconfig and ~/.config/git/cow.gitconfig, while container settings live
in the persistent cow-git volume. The ssh-agent and Colima VM are processes,
so they must be started again.
After the normal YubiKey and VM setup above works, opt in to starting both at macOS login:
./autostart.sh install --profile web # "web" is the default
./autostart.sh statusThe selected profile is stored in the generated Colima LaunchAgent; use another
name with --profile. Re-run install after changing profiles or updating
Corral so the installed helper is refreshed. The Colima job explicitly uses
~/.ssh/agent.sock; it may start before the agent because that stable path
becomes available when the agent job starts.
Devcontainers remain project-specific and start manually or through an editor. To disable login startup and stop the managed jobs:
./autostart.sh uninstallUninstalling keeps the VM, Docker volumes, YubiKey files, Git configuration,
and logs. Logs are in ~/Library/Logs/Corral.
The LaunchAgents run only as the logged-in user and contain no credentials.
Corral copies the agent helper to ~/.local/libexec/corral so launchd never
executes code from the container-writable work directory. Starting the agent
at login widens the time during which processes can request signatures, but
both keys still require PIN verification and touch for an actual operation.
Reject any prompt you did not initiate.
The generator does not select a Colima profile; the active Docker context does:
docker context ls
DOCKER_CONTEXT=colima-test devcontainer up --workspace-folder <repo-path>Using DOCKER_CONTEXT per command avoids changing the persistent default.
Choose values from that repository's package.json, lockfile, and development
scripts:
./new-devcontainer.sh <repo-path> \
[--node 22] [--pm pnpm|yarn|npm] [--ports 3000,4200]Omitted options add no repository-specific override: the Dockerfile keeps its Node 22 default, no package install runs, and no ports are published.
The generator refuses to overwrite an existing .devcontainer. If a project
already has one, merge the required mounts, environment, port publishing, and
tooling from templates/ by hand.
Start it from an editor or terminal:
devcontainer up --workspace-folder <repo-path>Requested ports are published only on the Mac's loopback interface. The
repository's development server must listen on 0.0.0.0, not only
127.0.0.1, for traffic to cross the container boundary.
The generator handles shared tooling, volume wiring, Git ownership, agent
forwarding, port scaffolding, and package installation. Each generated
.devcontainer then belongs to its repository; edit it for native packages or
other repository-specific needs.
node_modules is not a Docker volume. Package installation runs in Linux,
but the repository is bind-mounted, so modules live at:
<repo-path>/node_modules
That directory appears on the Mac, contains Linux-built dependencies, and survives a devcontainer rebuild. Do not use it with host Node tooling. Delete it and reinstall inside the container when dependencies need a clean reset.
After the first repository's devcontainer is running, configure Git from the Corral checkout on the Mac:
./git-setup.sh <repo-path>--name and --email default to the Mac's global Git identity. Pass either
explicitly to override it; if a value is unavailable, the script tells you
which argument is required:
./git-setup.sh <repo-path> \
--name "Your Name" --email "you@example.com"The script:
- enables SSH commit and tag signing on the Mac only for repositories below
CORRAL_WORK_DIR; - copies the signing public key into
cow-git; - stores the same identity and signing settings in
cow-git; and - runs the container lifecycle helper for the target repository.
That helper is templates/post-create.sh, installed in generated images as
cow-post-create.sh. It disables lazygit background fetches, scopes the
forwarded agent to github.com, and rewrites a GitHub HTTPS origin to SSH.
Future containers run it automatically.
Host lazygit is safe only when that repository's hooks can run correctly on macOS.
Use dc lazygit when hooks or other commit tooling require Linux dependencies.
Both paths require the YubiKey PIN and touch; a signing failure aborts the
commit rather than creating an unsigned one.
Define a host alias while standing in the configured repository:
alias dc='devcontainer exec --workspace-folder .'Verify the effective host scope and complete container path:
git config --show-origin --get commit.gpgsign
git config --show-origin --get user.signingkey
dc ssh-add -l
dc ssh -T git@github.com
dc git commit --allow-empty -S -m "test signed commit"
dc git cat-file -p HEAD | grep '^gpgsig '
dc git push
./yubikey-doctor.sh --repo <repo-path>The two host git config commands must resolve to true and the Mac signing
public-key path. The gpgsig line confirms that the test commit contains an
SSH signature; GitHub verifies it against the signing key uploaded to your
account.
dc ssh-add -l must list exactly the two YubiKey SK keys. If it shows another
key, restart the dedicated agent on the Mac and recreate the VM before using
Git authentication.
The doctor is read-only. It fails when required keys are absent, unexpected keys are forwarded, the agent is unavailable, or the container's SSH signing format/signing key is missing.
Corral manages lazygit's config.yml in cow-git for dc lazygit:
- Background fetch is disabled because every SSH fetch would request the YubiKey PIN and touch. Fetch explicitly when wanted.
- Inside a devcontainer,
ocopies the URL to the Mac clipboard through the terminal's OSC 52 support; paste it into the browser. This avoids installing a browser or exposing a host URL-opening service to container code.
The container lifecycle rewrites this file with these required settings. Host lazygit continues to use its normal macOS configuration.
Skip this section if you do not use Codex or Claude Code. Their state persists
in cow-agents.
For Codex:
dc codex loginOpen the printed URL on the Mac. If its final localhost callback cannot
reach the listener inside the container, copy the failed callback URL and run:
dc curl "<callback URL>"Then configure Codex:
dc bash
cat > "$CODEX_HOME/config.toml" <<'EOF'
sandbox_mode = "danger-full-access"
approval_policy = "on-request"
EOF
exitFor Claude Code, run dc claude and complete its one-time login. The outer
devcontainer is the sandbox for both tools; the bind-mounted repository and
shared state remain accessible as described in the security boundary.
Claude Code, Codex, lazygit, and other tools installed by the devcontainer Dockerfile run in each repository's devcontainer image, not in the shared Colima VM. Rebuild a repository's container without cache to fetch their current versions:
devcontainer up --workspace-folder <repo-path> \
--remove-existing-container \
--build-no-cacheThis is per repository and reruns the configured post-create dependency
installation. cow-agents and cow-git persist across the rebuild, so agent
logins, Codex configuration, and Git signing state remain intact.
Verify agent versions with:
dc <tool> --versiondc pnpm start
dc pnpm test
dc lazygit # hooks/tooling require Linux
lazygit # host-safe hooks; signing still required
dc claude/codex/whatever
gh pr status # on the MacCommit and push inside the container whenever repository hooks use Linux
dependencies. Host Git/lazygit is available for repositories whose hooks run
correctly on macOS. Use authenticated gh only on the Mac.
colima ls
./colima-up.sh web
devcontainer up --workspace-folder . --remove-existing-container
docker volume rm cow-agents # reset Claude/Codex state
docker volume rm cow-git # reset Git identity/signing/lazygit
rm -rf ./node_modules # from the repo root, then reinstallSince Colima v0.9, recreating a VM without --data preserves its container
data disk and Docker volumes. colima delete --data -p <profile> is the
destructive reset. Before deliberately deleting data, back up anything needed;
cow-agents contains credentials and must be handled as secret material rather
than left as an unencrypted archive in the shared workspace.
If agent forwarding fails:
colima ssh --profile web -- \
test -S /run/host-services/ssh-auth.sock && echo OKIf that does not print OK, start the host agent from the current shell and
recreate the VM:
./yubikey-agent-up.sh
# open a new terminal
./colima-up.sh webIf the VM lists both keys but one existing devcontainer does not, that container has a stale socket bind from an older VM forwarding session. Recreate only that devcontainer:
devcontainer up --workspace-folder <repo-path> --remove-existing-containerinstall.sh host tooling
colima.yaml complete Colima template
colima-up.sh create/recreate a profile VM
autostart.sh optional user-level agent and Colima login startup
new-devcontainer.sh scaffold a repository devcontainer
git-setup.sh configure scoped host and shared container Git
AGENTS.md canonical instructions for coding agents
CLAUDE.md imports AGENTS.md for Claude Code
templates/ generated Dockerfile/config/post-create files
yubikey-prereqs.sh install/check host YubiKey dependencies
yubikey-enroll.sh enroll two resident FIDO2 SSH keys
yubikey-agent-up.sh start the dedicated host agent safely
yubikey-doctor.sh read-only host/VM/container diagnostics
tests/smoke.sh dependency-free generator and safety checks
