43cd6e22f2
Publish Docker Image / resolve-versions (push) Successful in 9s
Lint / actionlint (push) Successful in 15s
Lint / hadolint (push) Successful in 13s
Publish Docker Image / base-decide (push) Successful in 8s
Publish Docker Image / build-base (push) Successful in 41m22s
Publish Docker Image / smoke-studio (push) Successful in 5m16s
Publish Docker Image / smoke (push) Successful in 7m31s
Publish Docker Image / build-variant-studio (push) Successful in 18m31s
Publish Docker Image / build-variant (push) Successful in 27m18s
Publish Docker Image / promote-base-latest (push) Successful in 11s
Publish Docker Image / update-description (push) Successful in 12s
Two changes that belong together, because the first is what makes the second dangerous to get wrong. pi-atelier (TUI sidebar + status rail) is now vendored to /opt/pi-atelier at PI_ATELIER_REF=v0.8.0 and registered by entrypoint-user.sh — the pi-fork / pi-observational-memory / pi-studio pattern, deliberately NOT `pi install npm:pi-atelier`, which writes into ~/.pi/npm-global on the config volume where it shadows the image and pins nothing. Unlike its siblings it gets no `npm install`: atelier declares zero runtime deps (peerDeps only, satisfied by the baked pi) and has no build step, so pi loads its TypeScript straight from the checkout via package.json `pi.extensions`. pi is no longer resolved to npm `latest` at build time. The pin lives in Dockerfile.variant and CI reads it from there, so a local `docker build` and a CI release ship the same versions by construction. The pin is a CHECKPOINT, NOT A FREEZE: bumping stays a one-line change; what stops is *unreviewed* adoption of whatever shipped that morning, in the same build that then gets tagged and published. CI fails when a pin is not concrete or not actually published on npm, and warns — never adopts — when npm latest moves ahead, naming what to re-check. Why this pairing needed care: pi-atelier 0.6.0/0.7.0 wrap pi's PRIVATE TUI renderer, and under pi 0.84 that wrapper recurses — pi hangs at startup burning CPU with no error. Upstream fixed the recursion in 0.7.1 and restored the non-overlapping split in 0.7.2; 0.8.0 is additive on top. atelier's own peerDependencies still say >=0.80.7, which does not express that floor, so nothing in npm metadata could have warned us. The floor is therefore encoded as an executable rule — pi >= 0.84 => pi-atelier >= 0.7.1 — asserted in both smoke-test.sh (build time) and recreate-sanity-check.sh (after a real recreate), verified against a 4x4 version matrix. Existing volumes needed migration, not just vendoring: a hand-installed `npm:pi-atelier` entry is counted as already-registered by the entrypoint guard, so every existing volume would have kept its unpinned npm copy — and a 0.6.x copy next to pi 0.84 is exactly the startup hang. The entrypoint now drops that one exact string (settings.json.bak.atelier.<ts> backup, distinct prefix so it cannot clobber the template merge's backup in the same second) and lets the pinned /opt copy register. Tested against a real settings.json: only that entry removed, other packages and all keys intact, idempotent, and unparseable JSON leaves the file untouched. DEVBOX_ATELIER=0 opts out entirely — in the entrypoint rather than via `pi uninstall`, because this component's failure mode is "pi will not start", which cannot be repaired from inside pi. 0.84.1 was audited for this release, not merely adopted: theme/TUI changes are additive, the session format is unchanged (CURRENT_SESSION_VERSION = 3 in both 0.83.0 and 0.84.1 with an identical migrateV1ToV2/migrateV2ToV3 ladder, so existing transcripts are neither migrated nor at risk and pi-session-repair stays valid), and the Node engine floor is unmoved at >=22.19.0. CI resolves the atelier tag to its PEELED commit SHA — atelier uses annotated tags, so the unpeeled ref is a tag object, not a commit; pi-studio's lightweight tags never exposed that distinction. Also: docs for overriding the read-only ~/.ssh/config from the container — container-only keys in ~/.ssh-local, hardened authorized_keys, the fact that `from=` must allow the HOST's addresses because container egress is NAT'd through it, and the macOS-only-keyword trap (`UseKeychain` is fatal to Linux OpenSSH and takes out dssh/pi --ssh while the host keeps working). Corrects two claims in "Naming LAN peers": ssh-lan.conf is not ProxyJump-only, and first-time creation does need one restart because the Include is emitted only when the file already exists at start.
1045 lines
44 KiB
Markdown
1045 lines
44 KiB
Markdown
# 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=<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=<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
|
|
`<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](#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/<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 `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 <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` / `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 `<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:
|
|
|
|
```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=<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 `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** — authorize it narrowly rather than bare:
|
|
|
|
```
|
|
from="192.168.1.0/24,192.168.4.0/24,10.8.0.7",restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLE0000EXAMPLE0000EXAMPLE0000ex devbox-mymachine
|
|
```
|
|
|
|
`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:
|
|
|
|
```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.<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](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).
|