# pi-devbox A self-contained Docker image for running [pi](https://pi.dev) — the pi coding-agent — in an isolated, reproducible Linux environment with a curated set of developer tooling, AI memory, and shell improvements. pi-devbox is opinionated about what's inside but unopinionated about how you use it: a single `docker compose up` gives you an interactive container with pi, a stack of modern CLI tools, MemPalace for persistent agent memory across sessions, and a UID-aligned `/workspace` mount so files you edit inside the container appear with your normal ownership on the host. ## What's inside ### The pi coding-agent - `pi` — the pi-coding-agent CLI (`@earendil-works/pi-coding-agent`) - `pi-toolkit` — keybindings, AWS env loader, settings template - `pi-extensions` — TypeScript extensions for pi (preview, MCP bridges, mempalace integration, etc.) - `pi-fork` — the `fork` tool for spawning sub-agents - `pi-observational-memory` — the `recall` tool for session compaction - `pi-atelier` — TUI sidebar: ordered panels, split-pane, themes. Pinned to an audited tag; see [Version pins](#version-pins-pi-pi-atelier-mempalace) ### MemPalace (AI memory) - `mempalace` — local-first agent memory system (29 MCP tools) - `mempalace-toolkit` — bash wrappers for session/docs mining - ChromaDB embedding model pre-warmed at build time (`all-MiniLM-L6-v2`) The host-mounted palace at `~/.mempalace` is shared across the host and this container so all your agents share one brain. To instead share a palace across *several* containers/harnesses, set `MEMPALACE_REMOTE_URL` to a shared MemPalace HTTP endpoint (see `.env.example` and `docker-compose.mempalace.yml`); the bridge then connects over HTTP and spawns no local server. ### Modern CLI tooling | Tool | Purpose | |---|---| | `nvim` | Neovim text editor (modal / vi-style) | | `nano` | Small non-modal editor (on-screen shortcut hints) | | `micro` | Modern non-modal editor (Ctrl+S/Ctrl+Q keys, mouse, syntax highlighting) | | `tmux` | Terminal multiplexer (configured for 0-indexed sessions) | | `ripgrep`, `fd` | Fast file content / filename search | | `fzf` | Fuzzy finder | | `bat` | Syntax-highlighted `cat` | | `eza` | Modern `ls` | | `zoxide` | Smart `cd` | | `jq`, `yq` | JSON / YAML query and transformation | | `tldr` (tealdeer) | Quick command examples | | `git`, `git-lfs`, `git-crypt` | Git + extensions | | `gitleaks` | Secret scanning pre-commit hook | | `gosu` | Privilege de-escalation in entrypoint | | `htop`, `tree`, `less` | Inspection utilities | The default `$EDITOR` is `nvim`. Three editors ship so you can pick your comfort level — if you'd rather not use a vi-style editor, `nano` and `micro` are both non-modal. Set your preference with `export EDITOR=micro` (or `nano`) in your shell profile, and/or `git config --global core.editor micro`. Neovim ships with a system-wide default (`/etc/xdg/nvim/sysinit.vim`) that turns on `termguicolors`, so its colours render in 24-bit instead of a muddy 256-colour fallback over ssh/kitty. The `kitty-terminfo` entry is also bundled so `TERM=xterm-kitty` is understood. Override either in your own `~/.config/nvim`. ### Document and image tooling - `pandoc` — universal Markdown↔HTML/Org/RST/etc. converter - `graphviz` — `dot` rendering for diagram pipelines - `imagemagick` — image conversion / resizing (invoked as `magick`) ### Language toolchains - `python3` + `python3-venv` + `python3-pip` (system Python) - `uv` + `uvx` — fast Python package manager (preferred over pip/venv) - `nodejs` (v22) + `npm` - `gcc`, `g++`, `make` — C/C++ build tools - `rustup-init` — Rust toolchain installer (toolchains opt-in at runtime) - Optional `INSTALL_GO=true` build arg for Go For Python REPLs and notebooks beyond the system interpreter, see the [uv-driven REPL recipes](#uv-driven-repl-recipes) section. ### Cloud + secrets - AWS CLI v2 — for SSO + Bedrock auth - `gitea-mcp` — MCP server for Gitea API - `age`, `git-crypt` — encryption tooling ### SSH and networking - OpenSSH client with **ControlMaster auto** preconfigured on a writable socket path (`/tmp/sshcm/`). Mitigates ssh banner-exchange failures behind CGNAT-restricted residential ISPs (~4-flow caps) by multiplexing many ssh calls over one TCP flow. - A LAN-access helper that auto-configures ssh jump-via-host on VM-backed hosts (OrbStack / Docker Desktop on macOS) so the container can reach the host's directly-attached LAN peers. - Read-only `~/.ssh` is handled transparently: a per-host `ControlPath` under it (common CGNAT configs like `~/.ssh/cm/...`) is redirected to a writable socket dir for both `pi --ssh` and `dssh`/`dscp`. ## Quickstart ### Prerequisites - Docker or OrbStack (recommended on macOS) - Optional: AWS credentials configured on the host if you'll use the Bedrock LLM provider ### Pull and run ```bash git clone https://gitea.jordbo.se/joakimp/pi-devbox cd pi-devbox cp .env.example .env # edit if needed docker compose up -d docker compose exec -u developer devbox bash ``` You're now in the container as user `developer` with `pi` on PATH and your host workspace mounted at `/workspace`. To start pi: ```bash pi ``` First-run pi-toolkit and pi-extensions install steps run automatically on container start; symlinks are written to `~/.pi/agent/` on the named volume (so they persist across container recreations). ### Stop / recreate / update ```bash docker compose down # stop, keep volumes docker compose down -v # stop, wipe per-container volumes (palace data is bind-mounted, so unaffected) docker compose pull # fetch latest image docker compose up -d --force-recreate ``` ## Image variants Currently published: | Tag | Includes | Size (approx.) | |---|---|---| | `joakimp/pi-devbox:latest` | base + pi + tooling | ~3.2 GB | | `joakimp/pi-devbox:vX.Y.Z` | pinned-version equivalent | ~3.2 GB | | `joakimp/pi-devbox:latest-studio` | `latest` + [pi-studio](https://github.com/omaclaren/pi-studio) (browser prompt editor, KaTeX/Mermaid preview, tmux-backed literate REPLs) | ~3.25 GB | | `joakimp/pi-devbox:vX.Y.Z-studio` | pinned-version studio equivalent | ~3.25 GB | Planned for an upcoming minor release: - *(shipped in Unreleased/base)* **PDF export from Studio/pandoc** now works: the base image ships **`typst`** as the PDF engine (`pandoc --pdf-engine=typst`), a single ~30 MB static binary — no separate `-tex` variant needed. `texlive-xetex` stays the higher-fidelity fallback (install on demand). ## Using pi-studio (`-studio` variant) The `-studio` images bundle [pi-studio](https://github.com/omaclaren/pi-studio): a two-pane browser workspace with a prompt/response editor, live KaTeX/Mermaid preview, and tmux-backed literate REPLs (Shell / Python / IPython / Julia / R / GHCi / Clojure). It is registered automatically on container start (no `pi install` needed) and exposes the `/studio` slash command plus the `studio_repl_send` / `studio_export_*` agent tools. Inside a pi session in the container: ``` /studio --no-browser --port 8765 # pin a fixed port; STUDIO_PORT=8765 is the baked default /studio --status # reprint the tokenized URL ``` ### Reaching the UI from your browser (the container caveat) pi-studio **hard-binds its server to `127.0.0.1` inside the container** (`index.ts`: `.listen(port, "127.0.0.1")`) and serves a tokenized URL. There is no `--host`/bind flag. This matters for a container: a plain `docker run -p 8765:8765` publish forwards to the container's *external* interface, **not** its loopback, so it will not reach Studio. Two paths work: **A. Host networking (simplest — OrbStack / single-host, no bridge).** Run the container with host networking so the container's loopback is the host's loopback: ```yaml services: devbox: network_mode: host # container 127.0.0.1 == host 127.0.0.1 ``` Then `http://127.0.0.1:8765/?token=…` works in a browser on the Docker host. This is the most secure option (Studio never leaves loopback). Note: host networking changes `host.docker.internal` semantics, so weigh it against the LAN-jump SSH feature if you use that. **B. `studio-expose` bridge (portable — any networking mode).** Publish a port and run the bundled `studio-expose` helper, which uses `socat` to bridge the container's loopback to its external interface (binding the egress IP on the same port, so the token URL Studio printed works verbatim): ```yaml services: devbox: ports: - "127.0.0.1:8765:8765" # host-localhost only environment: - STUDIO_EXPOSE=1 # auto-start the bridge on container boot ``` With `STUDIO_EXPOSE=1`, the entrypoint starts the bridge for you; just run `/studio --port 8765` in your pi session. To bridge manually instead (leave `STUDIO_EXPOSE` unset), run `studio-expose` in a container shell: ```bash studio-expose & # bridges $STUDIO_PORT (default 8765); --help for details ``` > **`studio-expose` runs in the foreground** (it's a `socat` relay) — it > blocks the shell until Ctrl-C. Background it with `&` or run it in its > own tmux pane. It only relays traffic; it does **not** print a token. > The lines it prints ending in `...token=...` are literal help text, not > a truncated URL — the real token comes from `/studio` (see below). > **Security:** the bridge intentionally exposes Studio beyond loopback; > its tokenized URL is the only auth. Keep the host-side publish on > `127.0.0.1:` and use `ssh -L` for remote access. Default is **off**. ### Remote host (SSH / mosh) When the Docker host is remote, keep Studio on localhost and forward the port from your laptop: ```bash ssh -L 8765:127.0.0.1:8765 user@docker-host # then open the token URL locally ``` **mosh cannot forward ports** (no `-L`/`-R` equivalent). To use Studio over a mosh session, run a *separate* `ssh -L 8765:127.0.0.1:8765 host` tunnel alongside mosh (mosh for the shell, ssh for the port), or reach the host's published port directly over a trusted network (LAN / Tailscale / WireGuard). #### End-to-end recipe: remote host, mosh shell, `studio-expose` bridge The full path has four network hops, each added by one step: ```mermaid flowchart LR browser["laptop browser"] host["host :8765"] eth0["container eth0 :8765"] loop["container 127.0.0.1 :8765"] studio["pi-studio"] browser -->|"ssh -L"| host host -->|"docker -p"| eth0 eth0 -->|"studio-expose (socat)"| loop studio -->|"binds"| loop ``` Assuming the compose file publishes `127.0.0.1:8765:8765` (see method B): 1. **In a container shell** — start the bridge (skip if `STUDIO_EXPOSE=1` is set in compose, which auto-starts it): ```bash studio-expose & ``` 2. **In your pi session** (the pi TUI in the container) — start Studio and print the tokenized URL. `/studio` is a slash command you type in the TUI, not a shell command: ``` /studio --no-browser --port 8765 /studio --status # reprint the URL anytime ``` Copy the `http://…:8765/?token=` it prints. **This** is where the real token comes from — not `studio-expose`. 3. **On your laptop** — open the ssh port-forward alongside mosh: ```bash ssh -L 8765:127.0.0.1:8765 user@docker-host ``` 4. **In your laptop browser** — open `http://127.0.0.1:8765/?token=` (keep the port and token verbatim; only the host part is `127.0.0.1`). > **Order check:** nothing listens on the container's `127.0.0.1:8765` > until step 2 runs. If the browser can't connect, verify Studio is up > (`/studio --status`) and the bridge is running (`ps aux | grep socat`). > PDF export (`/studio-pdf`, `studio_export_pdf`) uses **`typst`**, shipped in > the base image as the pandoc PDF engine (`pandoc --pdf-engine=typst`). For > LaTeX-exact output you can install `texlive-xetex` on demand as a heavier > fallback. HTML export, KaTeX, Mermaid, and all REPL features work regardless. ### Generating a PDF with pandoc + typst The base ships `pandoc` (front-end) and `typst` (PDF engine), so Markdown → PDF works out of the box: ```bash pandoc doc.md --pdf-engine=typst -o doc.pdf ``` The base patches pandoc's bundled typst template so it defaults to the **Libertinus Serif** font. Without that patch a naked `--pdf-engine=typst` fails with `error: font fallback list must not be empty`, because the upstream template leaves the font unset. To pick a different face, pass one of the fonts typst can see (`typst fonts` lists them — DejaVu Serif/Sans/Mono, Libertinus Serif, New Computer Modern): ```bash pandoc doc.md --pdf-engine=typst -V mainfont="New Computer Modern" -o doc.pdf ``` ### Graphviz diagrams in Studio: `dot-watch` pi-studio renders **Mermaid** natively but has **no Graphviz/DOT renderer**. Its markdown preview *does* render local image links (`.png`/`.jpg`/`.gif`/ `.webp`), so the workflow for Graphviz is: write a `.dot` file, render it to PNG with `dot`, and preview the PNG (directly, or embedded in a markdown file). The bundled **`dot-watch`** helper automates the re-render so edits show up on Studio's *refresh-from-disk*: ```bash dot-watch graph.dot # dot engine, 150 dpi -> graph.png dot-watch graph.dot neato 200 # pick layout engine + dpi ``` It polls the file's mtime (no `inotify` dependency) and regenerates `.png` on every save, printing timestamped status and indenting any DOT syntax errors instead of crashing. Then in Studio: open the PNG (or a `.md` that embeds it) and hit **refresh-from-disk** after each edit. Note: SVG is **not** in Studio's local-image-link allowlist — use PNG. ## Using pi-atelier (TUI sidebar) `pi-atelier` is bundled in **both** variants (vendored at `/opt/pi-atelier`, pinned — see [Version pins](#version-pins-pi-pi-atelier-mempalace)). It adds two things to pi's terminal UI: - a **status rail** — activity, token/cost metrics, context usage, model, git state, extension statuses, and a menu; - a **sidebar** — ordered panels (agent, activity, alerts, TODOs, context, workspace, usage, tools) in a split pane beside the transcript. Nothing needs installing; the entrypoint registers it on container start, and it binds on the next pi start (or `/reload`). | Action | How | |---|---| | Open the atelier menu | `alt+a`, or `/atelier` | | Toggle the sidebar for this session | `/atelier sidebar on` / `off` | | Change settings persistently | atelier menu → **Settings**, then **Save** | | Turn the whole thing off | `DEVBOX_ATELIER=0` in `.env` | If your terminal or keymap swallows `alt+a`, use `/atelier` and pick a different `shortcut` in the config file below. ### Config Config lives at `~/.pi/agent/pi-atelier.json` on the `devbox-pi-config` volume, seeded from pi-toolkit with container-appropriate defaults: compact density, context warnings at 60/85 % (earlier than upstream's 70/90), sidebar tool names on, and desktop completion notifications **off** (a container has nowhere useful to pop a toast). It is **copied, not symlinked** — atelier rewrites this exact path when you hit **Save**, using write-temp-then-`rename(2)`, and `rename` replaces a symlink with a regular file instead of following it. A symlink would silently detach on your first save. Consequently pi-toolkit's `install.sh` only seeds the file when it is absent: once you have saved your own preferences, image upgrades leave them alone, and `install.sh` prints a diff hint instead of clobbering. The seeded file uses atelier's **current** schema — `segmentLayout` with explicit per-segment visibility, plus `showSidebarAgent` / `showSidebarTodos` / `showSidebarOnStartup`. Older configs written against the pre-0.7 vocabulary (`segments`, `ornament`, `showExtensionStatuses`) still load, but only through upstream's legacy-compatibility shims — so if you are carrying one on an old volume, expect it to keep working while missing every sidebar control added since. `sidebarPanelLayout` is deliberately left unset so the panel set follows upstream's product default as atelier adds panels; set it only if you want to pin the order yourself. The sidebar auto-hides below 92 terminal columns and keeps the main pane at least 64 columns wide, so a narrow terminal degrades to the plain TUI rather than a squeezed one. ## docker-compose.yml — basic shape ```yaml name: pi-devbox services: devbox: image: joakimp/pi-devbox:latest container_name: pi-devbox stdin_open: true tty: true # pi-studio (only on `-studio` images): publish loopback + enable the # socat bridge so the browser UI is reachable. See "Using pi-studio". # ports: # - "127.0.0.1:8765:8765" # host-localhost only; use ssh -L for remote env_file: - .env environment: - TERM=xterm-256color # - STUDIO_EXPOSE=1 # -studio only: auto-start the socat bridge on boot # Secrets (GITEA_*, GITHUB_*, …) come from env_file: .env above — not # duplicated here. An environment: entry overrides env_file and is # interpolated from the host shell, so a stale shell export would # silently shadow your .env. See .env.example for the full list. volumes: # Workspace: your host source tree - ${WORKSPACE_PATH:-.}:/workspace # SSH keys: read-only from host - ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro # Per-container persistent state - devbox-pi-config:/home/developer/.pi - devbox-ssh-local:/home/developer/.ssh-local - devbox-shell-history:/home/developer/.cache/bash - devbox-zoxide:/home/developer/.local/share/zoxide - devbox-nvim-data:/home/developer/.local/share/nvim - devbox-uv:/home/developer/.local/share/uv # Optional (uncomment to enable): # - ~/.aws:/home/developer/.aws # AWS creds # - devbox-palace:/home/developer/.mempalace # persist palace # - devbox-chroma-cache:/home/developer/.cache/chroma # embedding cache volumes: devbox-pi-config: devbox-ssh-local: devbox-shell-history: devbox-zoxide: devbox-nvim-data: devbox-uv: # devbox-palace: # devbox-chroma-cache: ``` See `docker-compose.yml` and `.env.example` in the repo for the full template (build-from-source args, LAN-jump and skillset mounts, MemPalace persistence). To share one palace between host pi and the container, bind-mount your host `~/.mempalace` to `/home/developer/.mempalace`. ## uv-driven REPL recipes uv is installed in the base image and is the recommended way to run Python interpreters and notebooks without bloating the image: | Goal | One-liner | |---|---| | IPython REPL | `uv run --with ipython ipython` | | IPython + scientific stack | `uv run --with ipython --with numpy --with matplotlib --with pandas ipython` | | JupyterLab (browser, port-forward needed) | `uv run --with jupyterlab jupyter lab --no-browser --port 8888` | | Marimo (modern alternative) | `uv run --with marimo marimo edit --port 8889` | For long-lived environments, prefer a project venv: ```bash cd /workspace/myproj uv init && uv add ipython numpy matplotlib # then: uv run ipython ``` `pyproject.toml` + `uv.lock` then capture the dependency state and travel with the project in git. uv only manages Python. For other languages: | Toolchain | How to add | |---|---| | R | `sudo apt-get install r-base-core` (~200 MB) | | GHCi (Haskell) | `sudo apt-get install ghc` (~700 MB) | | Clojure | `sudo apt-get install clojure` (~150 MB + JVM) | | Julia | `juliaup` is planned for an upcoming release | These are runtime opt-ins and persist only in the container's writable layer — they don't survive `docker compose down -v` or image updates. ## tldr — first-run cache The `tldr` command (provided by tealdeer) shows a "Page cache not found" message on first invocation. To populate the cache: ```bash tldr --update ``` This fetches ~1500 command pages from the [tldr-pages](https://tldr.sh) project and caches them in `~/.cache/tealdeer/`. After that, `tldr ls`, `tldr docker`, etc. work instantly. Re-run `tldr --update` periodically to refresh. ## Volumes and persistence | Path inside container | Volume | What survives | |---|---|---| | `/workspace` | host bind-mount (`WORKSPACE_PATH`) | host filesystem | | `~/.ssh` | host bind-mount (read-only, `SSH_KEY_PATH`) | host filesystem | | `~/.pi` | named volume `devbox-pi-config` | `down -v` wipes | | `~/.ssh-local` | named volume `devbox-ssh-local` | `down -v` wipes | | `~/.cache/bash` | named volume `devbox-shell-history` | `down -v` wipes | | `~/.local/share/zoxide` | named volume `devbox-zoxide` | `down -v` wipes | | `~/.local/share/nvim` | named volume `devbox-nvim-data` | `down -v` wipes | | `~/.local/share/uv` | named volume `devbox-uv` | `down -v` wipes | | `~/.mempalace` | host bind-mount or `devbox-palace` (optional) | host / volume | | `~/.cache/chroma` | `devbox-chroma-cache` (optional) | `down -v` wipes | Anything not on a volume is on the writable layer and is lost on container recreate. ## MemPalace integration MemPalace is installed in the base image and pre-warmed with the ChromaDB ONNX embedding model so first-time semantic search is instant. The palace data lives at `~/.mempalace/palace` on the host (bind-mounted into the container). This means: - A pi running on the host and a pi running inside this container see the same palace. - SQLite's WAL mode handles concurrent reads + single writer cleanly, so simultaneous use is safe in practice. `mempalace-session` and `mempalace-docs` are on PATH for one-off session/docs mining; the 29 MCP tools (search, kg-query, drawer-add, diary-write, etc.) are wired into pi automatically by the pi-extensions mempalace bridge. ## Agent skills pi discovers skills under `~/.agents/skills/`. Two delivery paths feed that directory, and they compose: - **Image-baked skills (always present).** Skills shipped *inside* the image live under `/usr/local/share/pi-devbox/skills/` and are symlinked into `~/.agents/skills/` by `entrypoint-user.sh` on every start. They need no external mount, survive volume recreate (the source is an image path, not a home dir a named volume would shadow), and are created only when absent so a same-named skillset skill or user override is never clobbered. The bundled **`pi-devbox-environment`** skill is delivered this way — it teaches agents the container's persistence model, host/LAN SSH reachability, split-DNS mechanisms, the interactive-vs-tool-shell alias gotcha (`dssh`/`dscp`), tmux 0-indexing, uv-first Python, and pi-studio reachability, all as *mechanisms* (deployment-specific hostnames/domains/nameservers are discovered at runtime, never hardcoded). - **Vendored fallback skills.** The pi-toolkit global `AGENTS.md` tells every pi session to read `~/.agents/skills/pi-extensions/SKILL.md` at start (to fix fork/recall under-utilisation). That pointer would dangle in a container started *without* the private `skillset` repo, so the image also bakes fallback copies of **`pi-extensions`** and **`mempalace`**. They are symlinked only when absent, so a mounted skillset always overrides them. The `pi-extensions` skill is *layered*: a committed snapshot in `rootfs/` is the floor, and `Dockerfile.variant` copies the canonical, package-owned copy from the pinned `pi-extensions` clone (`/opt/pi-extensions/skill/`) over it at build, so a normal build ships the fresh copy and an old-ref/mirror build still ships the snapshot. `mempalace` is snapshot-only (its consumer skill has no public package home), and because pi-toolkit's `AGENTS.md` has no directive for it, the pi-devbox managed block adds a session-start *proactive-load* pointer for it (gated to pi-devbox containers, conditional on the MemPalace MCP tools) so a new container actually loads it. See `rootfs/usr/local/share/pi-devbox/skills/VENDORED.md`. - **Skillset repo (optional).** If a `skillset` repo is mounted (at `$HOME/skillset` or `/workspace/skillset`, or via `SKILLSET_CONTAINER_PATH`), `deploy-skills.sh` symlinks its skills in too. Image-baked skills are classified as foreign-links by its `--prune-stale` pass and left untouched. To make agents *proactively* load a baked skill at session start (rather than only on description match), the image appends a short, gated pointer to the global `AGENTS.md` at build time (see `pi-global-AGENTS.append.md`). The pointer fires only inside a pi-devbox container (it checks for `/usr/local/lib/pi-devbox/`). To add another image-baked skill: drop a `SKILL.md` under `rootfs/usr/local/share/pi-devbox/skills//`; the `COPY` in `Dockerfile.base` and the entrypoint symlink loop pick it up automatically. To refresh a vendored fallback, see `rootfs/usr/local/share/pi-devbox/skills/VENDORED.md`. ## SSH and ControlMaster The base image preconfigures `Host *` ssh defaults: ``` ControlMaster auto ControlPath /tmp/sshcm/%r@%h:%p ControlPersist 10m ``` The socket directory `/tmp/sshcm/` is created mode 700 on every container start (per-container, tmpfs-friendly). Multiple ssh calls to the same host within 10 minutes reuse the master TCP flow — important on residential ISPs with CGNAT per-destination flow caps (~4 flows on most European broadband; symptoms are `kex_exchange_identification: Connection closed by remote host` on the 5th+ concurrent ssh). User-level overrides in `~/.ssh/config` win because Debian's `/etc/ssh/ssh_config` includes `/etc/ssh/ssh_config.d/*.conf` before the `Host *` block. ### macOS-only keywords in a shared `~/.ssh/config` The same `~/.ssh/config` is read by macOS ssh *and* by the Linux OpenSSH inside the container (the sidecar `Include`s it). macOS-only keywords are **fatal** there, not ignored — a single `UseKeychain yes` in a `Host *` block takes down every ssh call in the container: ``` /home/developer/.ssh/config: line 2: Bad configuration option: usekeychain /home/developer/.ssh/config: terminating, 1 bad configuration options ``` That breaks `dssh`/`dscp`, `pi --ssh`, `scp`, and anything that shells out to ssh (including CI/deploy helpers), while the host keeps working perfectly — so it presents as a container regression rather than a host config error. Guard the keyword on the host, *before* it is used: ```diff Host * + IgnoreUnknown UseKeychain UseKeychain yes AddKeysToAgent yes ``` `IgnoreUnknown` is understood by both implementations: macOS still honours `UseKeychain`, Linux skips it. Also keep such a `Host *` block **below** any `Include` that must come first — OrbStack's own `Include ~/.orbstack/ssh/config` says so in a comment, and a `Host *` block above it silently violates that. ### Per-host `ControlPath` on a read-only `~/.ssh` `~/.ssh` is usually bind-mounted read-only, so a user `~/.ssh/config` that points `ControlPath` back under it (e.g. the CGNAT idiom `ControlPath ~/.ssh/cm/%r@%h:%p`) can't bind its master socket here — and a system default can never override a user's per-host value. Two layers handle this without editing the read-only config: - **`pi --ssh `** — the `ssh-controlmaster` extension detects an unwritable system `ControlPath` and falls back to its own writable `/tmp/pi-cm-.sock` master (its command-line `-o ControlPath` overrides the user's path); the remote-`pwd` probe uses `-o ControlPath=none` so it cannot fail on the read-only socket dir. - **`ssh -F ~/.ssh-local/config` / `dssh` / `dscp`** — `setup-lan-access.sh` redirects `ControlPath` into the writable `~/.ssh-local/cm` for every host (the sidecar is rendered on all host OSes). To name LAN peers that should jump via the host, add `ProxyJump host` overrides in the host-owned `~/.config/devbox-shell/ssh-lan.conf` (see [Naming LAN peers](#naming-lan-peers)) rather than the read-only `~/.ssh/config`. If the peer also rejects the host's key — the usual case, since host keys are normally passphrase-protected and the container has no Keychain or agent — see [Giving the container its own key for a peer](#giving-the-container-its-own-key-for-a-peer). ## tmux and 0-indexed sessions The image installs `/etc/tmux.conf` with: ``` set -g base-index 0 set -g pane-base-index 0 ``` This is the default tmux indexing. It's baked here because `pi-studio` (shipped in the `:latest-studio` variant) hard-codes its tmux send target to `:0.0`. If you override `base-index` to 1 in a personal `~/.tmux.conf`, pi-studio will fail with "can't find window: 0". ## AWS Bedrock auth If you use Bedrock as pi's LLM provider: 1. Configure SSO on the host: `aws configure sso` 2. Bind-mount `~/.aws:/home/developer/.aws:ro` 3. Set `AWS_PROFILE` and `AWS_REGION` in `.env` 4. Inside the container: `aws sso login` if needed; pi picks up the profile via the env vars. The pi-toolkit AWS env loader (in `~/.pi/agent/`) prepares Bedrock inference-profile model IDs (with `eu.` / `us.` prefixes) automatically. ## Build pipeline pi-devbox is built from this repo's CI in two phases: 1. **Base** (`Dockerfile.base`) — produces `joakimp/pi-devbox:base-` where `` is content-addressed over `Dockerfile.base`, `rootfs/`, and `entrypoint*.sh`. Rebuilt only when these change. 2. **Variant** (`Dockerfile.variant`) — `FROM ${BASE_IMAGE}` and adds the pi install (+ pi-studio when `INSTALL_STUDIO=true`). The `:latest` / `vX.Y.Z` and `:latest-studio` / `vX.Y.Z-studio` tags are produced from this layer. The studio variant builds via independent `smoke-studio` + `build-variant-studio` CI jobs that gate only the `-studio` tags. Tag naming: | Tag | Stage | |---|---| | `base-` | base image — internal building block | | `base-latest` | promoted alias of the most recent base | | `latest`, `vX.Y.Z` | variant: base + pi | | `latest-studio`, `vX.Y.Z-studio` | variant: base + pi + pi-studio | CI resolves `PI_VERSION` to a concrete version string before building to defeat a registry-buildcache hit on `npm install -g pi-coding-agent@latest` (the build-arg string would otherwise be byte-identical across releases and the layer would silently reuse the previous version's bytes). ### Building a fork / relocated build The canonical build clones its companions from `gitea.jordbo.se`. Every companion repo URL is an overridable build-arg (defaulting to the canonical origin), so a fork or a build on a host that can't reach that gitea can repoint each one at a mirror, another host, or a local `file://` path **without editing the Dockerfiles**: | Build-arg | Default | Dockerfile | |---|---|---| | `PI_TOOLKIT_REPO` | `https://gitea.jordbo.se/joakimp/pi-toolkit.git` | variant | | `PI_EXTENSIONS_REPO` | `https://gitea.jordbo.se/joakimp/pi-extensions.git` | variant | | `MEMPALACE_TOOLKIT_REPO` | `https://gitea.jordbo.se/joakimp/mempalace-toolkit.git` | base | | `PI_FORK_REPO` | `https://github.com/elpapi42/pi-fork.git` | variant | | `PI_OBSMEM_REPO` | `https://github.com/elpapi42/pi-observational-memory.git` | variant | | `PI_ATELIER_REPO` | `https://github.com/michaelmjhhhh/pi-atelier.git` | variant | | `PI_STUDIO_REPO` | `https://github.com/omaclaren/pi-studio.git` | variant | Each has a matching `*_REF` arg (branch name or commit SHA). Example — build the variant against forked toolkit/extensions and a pinned pi: ```bash # base first (mempalace-toolkit lives here) docker build -f Dockerfile.base -t myorg/pi-devbox:base-dev \ --build-arg MEMPALACE_TOOLKIT_REPO=https://github.com/myorg/mempalace-toolkit.git . # then the variant FROM that base docker build -f Dockerfile.variant -t myorg/pi-devbox:dev \ --build-arg BASE_IMAGE=myorg/pi-devbox:base-dev \ --build-arg PI_VERSION=0.79.7 \ --build-arg PI_TOOLKIT_REPO=https://github.com/myorg/pi-toolkit.git \ --build-arg PI_EXTENSIONS_REPO=https://github.com/myorg/pi-extensions.git . ``` Note: the gitea companions clone anonymously (no token needed); only the `resolve-versions` CI job calls the gitea *API* (which needs a token even for public repos). A plain `docker build` like the above skips that job entirely, so no credentials are required for a local/forked build. Provenance build-args (all optional; populate the OCI labels and `/etc/pi-devbox/build-manifest.json` — see below): `RELEASE_TAG`, `BUILD_DATE`, `SOURCE_REVISION`. CI sets these automatically; a manual build leaves them at harmless defaults. ### Build provenance (labels + manifest) Every published image is self-describing. Inspect the OCI labels without pulling the filesystem: ```bash docker inspect --format '{{json .Config.Labels}}' joakimp/pi-devbox:latest | jq . ``` `org.opencontainers.image.{version,revision,created}` plus `se.jordbo.pi-devbox.*-ref` record the intended pi version and companion refs. The on-disk `/etc/pi-devbox/build-manifest.json` records **ground truth** — the actual checked-out commit of each `/opt` clone and the live `pi --version` — so a tag is reconstructable after CI logs rotate: ```bash docker run --rm --entrypoint= joakimp/pi-devbox:latest cat /etc/pi-devbox/build-manifest.json ``` Inside a running container, `pi-devbox-version` wraps that manifest into a human-readable summary — no need to remember the file path or pipe it through `jq` yourself: ```console $ pi-devbox-version pi-devbox v1.5.0 built: 2026-07-13T17:53:16Z (source d68674d11e06) pi: 0.80.6 components: pi-toolkit: 9a8f6faeaa08 pi-extensions: 61c98e004e3d pi-fork: 4a09af4ef527 pi-observational-memory: 27a5195eaf90 mempalace-toolkit: 96699f2a1781 pi-studio: 2ef38ef31cea ``` It also flags **live drift** — if `pi --version` no longer matches what was baked at build time (e.g. something on a persisted volume shadowed the image's binary), the `pi:` line calls that out instead of silently trusting the manifest. `--json` dumps the raw manifest for scripting; `--quiet` gives a one-line `release_tag (source_revision)` form. It also prints once, automatically, at container start (from `entrypoint-user.sh`, before the rest of the setup output) — so you see which build you're in without asking. Exits 1 with a short notice on images built before this file existed, rather than failing silently. ## Troubleshooting ### Image grew unexpectedly `docker history joakimp/pi-devbox:latest` shows per-layer sizes. The biggest layers are typically the apt block (~600 MB), pi npm install (~330 MB), MemPalace + ChromaDB (~315 MB), AWS CLI (~270 MB), Node.js (~200 MB). ### pi can't reach LAN peers on macOS The LAN-access helper (`/usr/local/lib/pi-devbox/setup-lan-access.sh`) auto-runs on container start and writes `~/.ssh-local/config` with a ssh-jump-via-host configuration. Set `DEVBOX_LAN_ACCESS=jump` and `HOST_SSH_USER=` in `.env` if auto-detection fails. #### Naming LAN peers `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER` only set up the *jump* to the host. To make a **named** peer route through it — so `pi --ssh alpserv-2`, `dssh alpserv-2`, etc. resolve the ProxyJump — add a `ProxyJump host` override for it in the host-owned, bind-mounted `~/.config/devbox-shell/ssh-lan.conf` (**not** `~/.ssh/config`, which is mounted read-only): ``` Host pve pve-2 alpserv-2 lagret ProxyJump host ``` Any option can be set here, not just `ProxyJump`: the file is `Include`d *before* `~/.ssh/config` and ssh takes the **first** value it sees for each option, so whatever you put here wins while everything you omit is inherited from the matching block in your real `~/.ssh/config`. Peer names stay out of the published image (they are a fact about your LAN, not the image). Alternatively, set `DEVBOX_LAN_AUTOJUMP_PRIVATE=1` to ProxyJump *any* RFC1918 address through the host without naming peers (see `.env.example`). Once the file exists it is re-read on every connection, so *edits* take effect immediately — no container or session restart. **Creating it for the first time does need one restart**, because `setup-lan-access.sh` only emits the `Include ~/.config/devbox-shell/ssh-lan.conf` line when the file is already readable at container start (`if [ -r "$SSH_LAN_CONF" ]`). Until then ssh never looks at it — which reads exactly like "my override is being ignored". #### Giving the container its own key for a peer `ProxyJump` fixes *routing*; it does not fix *authentication*, and inheriting the host's `IdentityFile` usually fails inside the container: - Host keys are commonly passphrase-protected, and that passphrase is unlocked by the macOS Keychain or a running `ssh-agent`. The container has neither, so the key can never be decrypted — `Permission denied (publickey)` even though the identical `ssh peer` works in a host terminal. - `~/.ssh` is mounted read-only, so you can neither drop a container-usable key in there nor edit `~/.ssh/config` from inside. The answer is a **container-only keypair** in `~/.ssh-local/` — the named volume `devbox-ssh-local`, so it survives `docker compose up -d --force-recreate` — plus an `IdentityFile` override in the host-owned `ssh-lan.conf`. Note that nothing is baked into the *published image*: that volume is created on your machine at runtime, so no private key ever ships to Docker Hub, and a fresh pull elsewhere generates its own. (Every key below is a throwaway example.) **1. In the container** — generate a passphraseless key (there is no agent to unlock a protected one): ```bash ssh-keygen -t ed25519 -N '' -C "devbox-$(hostname)" \ -f ~/.ssh-local/mypeer_devbox_ed25519 cat ~/.ssh-local/mypeer_devbox_ed25519.pub # ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLE0000EXAMPLE0000EXAMPLE0000ex devbox-0d11ec7731c7 ``` **2. On the peer** — append that public key to `~/.ssh/authorized_keys` **of the account you will log in as** (the `User` from step 3), narrowly authorized rather than bare: ```bash mkdir -p ~/.ssh && chmod 700 ~/.ssh cat >> ~/.ssh/authorized_keys <<'KEY' from="192.168.1.0/24,192.168.4.0/24,10.8.0.7",restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLE0000EXAMPLE0000EXAMPLE0000ex devbox-mymachine KEY chmod 600 ~/.ssh/authorized_keys ``` Both lines are safe on a peer that is already set up: `mkdir -p` is a no-op when the directory exists, the `chmod`s only tighten, and appending never touches keys already listed. Use `>>`, never `>` — one stray truncation revokes every other key on that account. The options prefix must sit on the **same physical line** as the key, comma-separated with no spaces: a paste that wrapped is the likeliest reason a key that looks right is refused. `ssh-copy-id` cannot add that prefix, so append by hand (or let it copy the bare key and edit the line afterwards). If authentication still fails with no clear reason, suspect permissions — sshd's `StrictModes` silently ignores `authorized_keys` when the home directory, `~/.ssh` or the file itself is group- or world-writable, and says why only in the peer's own log (`journalctl -u ssh`, `/var/log/auth.log`). `restrict` disables pty, agent/X11 and port forwarding; append `port-forwarding` and `permitopen="127.0.0.1:"` after it if you need one specific tunnel. `from=` must list the **host's** addresses, not the container's: container egress is NAT'd through the host, so the peer sees the host's LAN address (confirm with `echo $SSH_CLIENT` on first login). List every network the host roams — e.g. both home WLAN subnets plus its VPN address — because a `from=` mismatch is indistinguishable from a wrong key in the error message. **3. On the host** — point the peer at that key in `~/.config/devbox-shell/ssh-lan.conf`: ``` Host mypeer mypeer.home.arpa HostName 192.168.1.142 User myuser IdentityFile ~/.ssh-local/mypeer_devbox_ed25519 IdentitiesOnly yes # ProxyJump host # only if the container cannot reach the peer directly ``` That path exists only inside containers, which is why it belongs here rather than in the shared `~/.ssh/config`. **4. First time only** — restart the container so the `Include` is emitted (see above), then verify with the master socket bypassed, so a warm connection cannot fake a pass: ```bash ssh -F ~/.ssh-local/config -o ControlPath=none mypeer 'echo $SSH_CLIENT' ``` Use one key per machine (`devbox-mbp`, `devbox-studio`, …) so a single `authorized_keys` line can be revoked without locking out the others. ### Smoke-testing a local build ```bash ./scripts/smoke-test.sh joakimp/pi-devbox:latest ``` `smoke-test.sh` is a **build-time** check (runs with `--entrypoint=""`), so it validates image contents and a fresh entrypoint deploy — it never sees a recreated container's persisted volumes. ### Post-recreate sanity check After `docker compose up -d --force-recreate`, run the **runtime** peer of `smoke-test.sh` from *inside* the container to confirm the new image is live, persisted volumes survived, and pi runtime wiring is intact: ```bash ./scripts/recreate-sanity-check.sh # auto-detects variant ./scripts/recreate-sanity-check.sh --expected-version 0.79.4 # assert pi version ``` If `cli_utils` is on your PATH, the `pi-devbox-sanity` wrapper runs the same check by short name and locates the repo automatically (override with `PI_DEVBOX_REPO=/path/to/pi-devbox`). Like `smoke-test.sh`, this script is maintainer tooling and is **not** shipped in the published image. ## Versioning and release pi-devbox follows semver-ish: - **Major** — architectural changes. `v1.0.0` is the first decoupled release (independent of opencode-devbox). - **Minor** — new variants, significant base additions. - **Patch** — pi version bumps, smaller fixes. The `pi --version` inside the image is asserted by smoke tests to match the version CI resolved (since v1.7.0, the pin below), so drift between what was intended and what actually got baked is caught at CI time rather than on a user's pull. ### Version pins: pi, pi-atelier, mempalace Three components are pinned to an exact version **in the repo** instead of being resolved to `latest` at build time: | Component | Pin | Where | |---|---|---| | pi | `0.84.1` | `ARG PI_VERSION` — `Dockerfile.variant` | | pi-atelier | `v0.8.0` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` | | mempalace | `3.6.0` | `ARG MEMPALACE_VERSION` — `Dockerfile.base` | The objective is **not** to freeze versions. Bumping is routine — usually one line plus a changelog note. The objective is that adopting a new upstream version is a deliberate, reviewable act, not a side effect of whatever happened to be published the morning CI ran. Each of these has already drawn blood: - **pi** — a minor release can move the private TUI/renderer internals that pi-atelier wraps, or the session `.jsonl` format `pi-session-repair` parses. - **pi-atelier** — 0.6.0/0.7.0 hang pi 0.84 **at startup**, burning CPU with no error (fixed in 0.7.1/0.7.2). Its `peerDependencies` still say `>=0.80.7`, so nothing in the npm metadata expresses the real floor. - **mempalace** — an unpinned install once swept in the broken `diary_write` MCP tool schema of 3.3.x/3.4.0, which is why that pin's comment requires a tool-schema review before every bump. CI enforces this rather than trusting it: - `resolve-versions` reads the pins **out of the Dockerfiles** — single source of truth, so a local `docker build` and a CI release ship the same versions — and fails the build if a pin is not concrete, not a semver tag, or not actually published on npm. - When npm has a newer pi than the pin, CI emits a `::warning::` naming it. That warning is the prompt to audit and bump; it never adopts the version. - `smoke-test.sh` asserts the image's `pi --version` equals the pin, and separately asserts the pairing rule **pi ≥ 0.84 ⇒ pi-atelier ≥ 0.7.1**, so a bad combination fails the build instead of publishing a TUI that never starts. To bump pi: read the upstream CHANGELOG for every intervening version (TUI/theme API, session format, extension loader, Node engine floor), re-check pi-atelier's CHANGELOG for the pi version it claims to track, then edit the one `ARG` line and record what you checked in `CHANGELOG.md`. #### If you previously hand-installed pi-atelier A hand-installed `pi install npm:pi-atelier` lands in `~/.pi/npm-global`, which is on the `devbox-pi-config` **volume** — so it outlives image upgrades and stays at whatever version you installed, unpinned and unaudited. Since the image now vendors an audited pi-atelier at `/opt/pi-atelier`, the entrypoint removes a lingering `npm:pi-atelier` entry from `packages[]` (after backing `settings.json` up to `settings.json.bak.atelier.`) and registers the pinned `/opt` copy instead. Nothing else in your settings is touched, and the npm-global copy itself is left on disk — only the registration changes. This matters more than it sounds: leaving a 0.6.x npm copy registered alongside pi 0.84 is precisely the combination that hangs at startup. To opt out of pi-atelier entirely, set `DEVBOX_ATELIER=0` in `.env`. The entrypoint then removes any pi-atelier entry from `packages[]` on start. That switch lives in the entrypoint — not in a pi command — deliberately: this component's failure mode is "pi will not start", which you cannot repair with `pi uninstall`. ## Acknowledgements pi-devbox was originally a thin re-brand of the `pi-only` variant of [opencode-devbox](https://gitea.jordbo.se/joakimp/opencode-devbox). It was decoupled at `v1.0.0` so it could evolve at its own pace, with self-contained docs and a focused, pi-centric image. Significant base infrastructure (the SSH ControlMaster setup, MemPalace integration, the entrypoint UID/GID dance) was adopted from there. The pi coding-agent itself is [@earendil-works/pi-coding-agent](https://www.npmjs.com/package/@earendil-works/pi-coding-agent). ## License MIT — see [`LICENSE`](LICENSE). This covers the repository's own contents (Dockerfiles, entrypoint scripts, `rootfs/` seeds, CI, docs). The published images bundle third-party software under their own licenses; see [`THIRD_PARTY.md`](THIRD_PARTY.md).