Skip to content

Repository files navigation

Corral

Corral banner

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.

Security boundary

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.

Mental model

  1. Colima VM — one shared Linux VM using vz and virtiofs. It mounts only the configured work directory.
  2. Per-repository devcontainer — the repository is bind-mounted from the Mac; tools and agents run as the non-root node user.
  3. Two Docker volumescow-agents stores Claude/Codex state and cow-git stores 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.

Corral versus Alpaca

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.

Choose a work directory

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.

1. Install host tooling

./install.sh

This 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-key

Run 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.

2. Prepare YubiKey signing and authentication

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 keys

Both 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 zsh

Open 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 + touch

On the first GitHub SSH connection, verify the presented host fingerprint against GitHub's published fingerprints before accepting it.

Why Homebrew OpenSSH?

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.

3. Create the VM

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 -l

colima-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.

Optional: start the agent and Colima after login

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 status

The 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 uninstall

Uninstalling 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.

Select a profile explicitly

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.

4. Add a repository

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.

What the generator does

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.

5. Configure Git once

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.

6. Verify Git and YubiKey

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.

lazygit customizations

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, o copies 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.

7. Optional coding agents

Skip this section if you do not use Codex or Claude Code. Their state persists in cow-agents.

For Codex:

dc codex login

Open 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
exit

For 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.

Updating installed tools

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-cache

This 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> --version

Daily workflow

dc 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 Mac

Commit 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.

Troubleshooting and reset

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 reinstall

Since 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 OK

If 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 web

If 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-container

Files

install.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

About

Opinionated MacOs dev setup (with yubikey and agents support)

Resources

Stars

Watchers

Forks

Releases

Contributors

Languages