joakimp ade58131d6
Lint / hadolint (push) Successful in 13s
Lint / actionlint (push) Successful in 23s
docs(readme): use mkdir -p instead of install -d for the peer's ~/.ssh
`install -d -m 700 ~/.ssh` was correct but wrong for the audience. This snippet
gets pasted onto an arbitrary peer — a NAS, a router, a BSD box — and `install`
is not in POSIX, so it is not guaranteed to be there. `mkdir -p` + `chmod` is
POSIX, present everywhere, and self-evidently idempotent to a reader deciding
whether it is safe to run on a machine that already has keys.

Behaviour was checked rather than assumed, on both coreutils and BSD/macOS
`install`: on an existing ~/.ssh it exits 0 and leaves authorized_keys intact
in content and mode, but it also silently chmods the directory (755 -> 700).
Desirable here, yet invisible in a doc — which is the second reason to prefer
the explicit two-step form, and why the surrounding text now states outright
that re-running is safe on an already-configured peer: mkdir -p is a no-op, the
chmods only tighten, and appending never touches keys already listed.

Verified the whole block end-to-end against a fresh HOME and one with a
pre-existing 755 ~/.ssh and an older key: 700/600 in both cases, older key
preserved, new line appended.
2026-08-08 01:24:51 +02:00
2026-05-14 19:57:17 +02:00

pi-devbox

A self-contained Docker image for running pi — 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

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
  • graphvizdot 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 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

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:

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

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 (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: 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:

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):

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:

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:

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:

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):
    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=<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:
    ssh -L 8765:127.0.0.1:8765 user@docker-host
    
  4. In your laptop browser — open http://127.0.0.1:8765/?token=<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:

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):

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:

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 <name>.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). 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

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:

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:

tldr --update

This fetches ~1500 command pages from the tldr-pages 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/<name>/; 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 Includes 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:

 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 <host> — the ssh-controlmaster extension detects an unwritable system ControlPath and falls back to its own writable /tmp/pi-cm-<pid>.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 / dscpsetup-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) 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.

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 <session>: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-<hash> where <hash> 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-<hash> 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:

# 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:

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:

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:

$ 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=<your-mac-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 Included 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):

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:

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 chmods 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:<port>" 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:

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

./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:

./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_VERSIONDockerfile.variant
pi-atelier v0.8.0 ARG PI_ATELIER_REFDockerfile.variant
mempalace 3.6.0 ARG MEMPALACE_VERSIONDockerfile.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.<timestamp>) 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. 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.

License

MIT — see 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.

S
Description
pi coding-agent container built on opencode-devbox base
Readme MIT 3.1 MiB
Languages
Shell 93.8%
Python 5.1%
Vim Script 1.1%