Files
pi-devbox/rootfs/usr/local/share/pi-devbox/skills/pi-devbox-environment/SKILL.md
T
Joakim Persson fbc1f86612
Lint / hadolint (push) Successful in 13s
Lint / actionlint (push) Successful in 16s
docs: a negative result is usually your own filter (skill + AGENTS.md)
Three false negatives in one session, all self-inflicted, all convincing
because the command "succeeded": a `| head -20` proved an SSH peer absent
that sits at line 454 of a ~500-line config; `ssh mac 'docker ps'` proved
the host had no Docker, when the non-interactive PATH simply lacks
/usr/local/bin; and `grep 'ssh '` proved no ControlMaster was running,
when those processes rename themselves to `ssh: <path> [mux]`. Same root
cause each time, so it goes in the skill rather than in a commit message:
a positive result carries its own evidence, absence has to be earned.

The skill (rootfs/, symlinked into ~/.agents/skills) is BAKED, so this is
an image change and is logged in CHANGELOG Unreleased accordingly. Its §3
also now records that a live ControlMaster socket makes later commands
authenticate not at all -- after editing a peer's authorized_keys, "it
still works" proves nothing; prove it with -o ControlPath=none, or the
breakage waits for a future session that has no memory of the edit.

AGENTS.md: corrected a stale CI claim while placing the pointer. It said a
tag push produces two runs including lint; lint.yml has since been scoped
to branches: ['**'], which excludes tag refs, and refs/tags/v1.8.4 duly
produced run 571 (publish) and nothing else. Kept the head_sha + workflow
path filter advice, which is cheap and guards against a future v*-triggered
workflow. Added a short section on verifying this repo from inside a
container, including that docker-compose.yml here is a TEMPLATE pinning
:latest while a real host runs its own per-machine file -- recreating from
the repo copy can silently move a host off :latest-studio.

Placement note: AGENTS.md is only auto-read when the cwd is this repo, so
the durable rule lives in the skill, which loads by description match in
any pi-devbox session.
2026-08-22 22:56:41 +02:00

17 KiB

name, description
name description
pi-devbox-environment Operate correctly inside a pi-devbox container. Load when running inside pi-devbox (detection: the directory `/usr/local/lib/pi-devbox/` exists, the shell prompt is prefixed `[devbox]`, or `~/.ssh-local/config` is present) and the task touches any of: reaching the Docker host or its LAN, SSH, DNS name resolution, what survives container recreate (persistence vs ephemerality), running Python or other REPLs, tmux, or the pi-studio browser UI. Covers the persistence model, the interactive-vs-tool-shell alias gotcha (dssh/dscp/cat=bat exist only in interactive bash), host + LAN SSH reachability and ControlMaster, split-horizon DNS mechanisms, the tmux 0-index constraint, uv-first Python, and pi-studio reachability. This skill teaches MECHANISMS only — concrete hostnames, usernames, internal domains, nameservers, and even the host OS vary per deployment and MUST be discovered at runtime, never assumed or hardcoded.

pi-devbox environment

You are (or may be) running inside pi-devbox: a Docker container that ships pi, MemPalace, and a curated tool stack, with the host source tree mounted at /workspace. This skill is about the container-shaped facts that change how you should act — things that are easy to get wrong because they differ from a normal workstation shell.

Golden rule: this environment is a template, not a fixed deployment. The host could be macOS, Windows, or Linux. There may or may not be LAN peers, a VPN, split-DNS, a skillset mount, or the -studio variant. Detect and verify the specifics live (commands below) — do not assume any particular hostname, domain, nameserver, or OS. Where this skill shows example values they are illustrative placeholders.

0. Am I in pi-devbox, and what's true here?

Cheap detection signals (any one is sufficient):

[ -d /usr/local/lib/pi-devbox ] && echo "pi-devbox image"
[ -r "$HOME/.ssh-local/config" ] && echo "LAN/host SSH sidecar present"
case "$PS1" in *'[devbox]'*) echo "interactive devbox shell";; esac

Then orient before acting:

cat /etc/os-release | head -2          # container distro (usually Debian)
ls -la /usr/local/lib/pi-devbox/       # which devbox helpers exist
sed -n '/^Host /,$p' ~/.ssh-local/config 2>/dev/null   # host/LAN reachability, if any
mount | grep -E ' /workspace | /home/\S+/\.ssh '       # what's bind-mounted

1. Persistence vs ephemerality — know before you write

The container has three storage tiers with very different lifetimes. Pick the right one or work is silently lost on the next recreate/update.

Tier Examples Survives down? Survives down -v? Survives image update / --force-recreate?
Host bind-mount /workspace, usually ~/.ssh (ro), often ~/.mempalace yes yes (lives on host) yes
Named volume ~/.pi, ~/.ssh-local, ~/.cache/bash, ~/.local/share/{uv,nvim,zoxide} yes no yes
Writable container layer anything else: sudo apt install …, rustup/ghc/R toolchains, files in /tmp, /opt edits yes no no

Practical consequences:

  • Durable work goes in /workspace (it's the host filesystem, UID-aligned — what you write appears with the user's normal ownership on the host).
  • Runtime-installed system packages and language toolchains are ephemeral. If a task needs them reproducibly, it belongs in the image (Dockerfile) or a project manifest, not an ad-hoc apt install. Tell the user when you install something that won't survive.
  • ~/.pi is a named volume, so things baked into the image under /home/<user>/... are shadowed by the volume on existing containers and only seen on a fresh volume. Image-owned content that must always be live belongs under an image path like /usr/local/... or /opt/... and is linked in by the entrypoint — not dropped into a home directory that a volume covers.

~/.agents/skills/ itself is in the ephemeral container layer, rebuilt by entrypoint-user.sh on every start from two sources — so where a skill really lives decides whether your edit survives:

readlink -f ~/.agents/skills/<name>     # always do this first
Resolves to Tier Edit here
/workspace/skillset/skills/<name>/ host bind-mount edit in place, commit in that repo
/usr/local/share/pi-devbox/skills/<name>/ image layer (root-owned, ephemeral) edit the canonical repo, then sudo cp the file over the image path to activate it for the running session

Only three skills are image-baked, and each has a different owner (the table in /usr/local/share/pi-devbox/skills/VENDORED.md is authoritative):

Baked skill Canonical source to edit
pi-devbox-environment pi-devbox repo → rootfs/usr/local/share/pi-devbox/skills/pi-devbox-environment/ (authored there; this file)
pi-extensions the pi-extensions package repo → skill/. Dockerfile.variant copies it over the vendored snapshot at build, so also refresh pi-devbox's rootfs/.../pi-extensions/ copy to keep the fallback floor from diverging
mempalace the private skillset repo → skills/mempalace/ (manual snapshot refresh per release)

Editing through the symlink into /usr/local/... is silently lost on the next recreate — and worse, it diverges from the canonical repo that every other consumer (host pi, opencode) reads.

Shadowing gotcha: image-baked links are created first and only when the name is absent, and the later deploy-skills.sh --bootstrap --prune-stale pass treats them as foreign links and leaves them alone. So for a name present in both the image and skillset — currently mempalace and pi-extensions — the image copy wins, and a skillset edit to that skill has no effect in the container. Verified 2026-07-29: the baked mempalace snapshot carries a Temporal grounding section (pi-devbox 904fe85) that the skillset copy at its snapshot point (8e8db64) lacks — containers load the richer baked text while skillset consumers get the older one. When you change one of those two, decide deliberately which copy is canonical and sync the other.

2. Interactive shell vs. your tool shell (a real footgun)

The conveniences below are defined in ~/.bash_aliases and only exist in an interactive login shell. Your bash tool runs non-interactively, so these are "command not found" there — you must spell out the underlying command.

Interactive alias Non-interactive equivalent to actually run
dssh <host> ssh -F "$HOME/.ssh-local/config" <host>
dscp … scp -F "$HOME/.ssh-local/config" …
cat file (→ bat) cat file works, but output differs; use command cat for raw
ll, la (→ eza/ls) ls -lh, ls -lha

If a command "works in my terminal but not when the agent runs it," this alias gap is the first thing to suspect.

A negative result is usually your own filter

When you are about to report that something is absent, unreachable, or not running, the filter you wrote is the prime suspect — not the thing. This environment produces false negatives cheaply, and they are convincing because the command "succeeded". Three real instances from one session, all wrong, all mine:

Claim I made Why it was false
"tor-ms22 is not in the SSH config" grep … | head -20 — the entry was at line 454. ~/.ssh/config here is ~500 lines.
"the Docker host has no docker" non-interactive SSH PATH lacks /usr/local/bin (§2, §3). It was at /usr/local/bin/docker.
"no ControlMaster is running" pattern ssh (trailing space) cannot match a master: those processes rename themselves to ssh: <controlpath> [mux].

Habits that would have caught all three:

# don't cap the output of a search whose answer you don't already know
grep -n -i -A6 'tor-ms22' ~/.ssh/config          # not | head -20

# on the host, resolve the binary instead of trusting PATH
ssh -F "$HOME/.ssh-local/config" mac 'command -v docker || ls /usr/local/bin/docker'

# match a process's ACTUAL argv, not the name you imagine
ps -eo pid,etime,args | grep -Ei 'mux|mosh|ssh'

A positive result needs no such scepticism — it carries its own evidence. Only absence has to be earned, so spend the extra command there.

dscp/scp with accented filenames on a macOS host. macOS stores filenames in Unicode NFD (decomposed — e.g. ä is a + combining U+0308), while the string you type or paste is usually NFC (precomposed ä, U+00E4). The bytes differ, so a precomposed remote path silently fails to match on the host — scp … "mac:'~/Desktop/Skärmavbild ….png'" returns No such file or directory even though the file plainly exists. Sidestep the encoding entirely: let the remote shell expand a wildcard, or list the directory first and copy the exact name it prints.

# glob dodges the NFC/NFD mismatch (the remote shell matches the real bytes):
scp -F "$HOME/.ssh-local/config" "mac:~/Desktop/Sk*rmavbild*.png" ./
# or read the exact filename first, then copy that:
ssh -F "$HOME/.ssh-local/config" mac 'ls -1 ~/Desktop/*.png'

3. Reaching the Docker host and its LAN over SSH

When the host is VM-backed (e.g. OrbStack / Docker Desktop on macOS) the entrypoint's setup-lan-access.sh writes a writable SSH sidecar at ~/.ssh-local/config. It always provides:

  • A Host * block redirecting ControlPath into the writable ~/.ssh-local/cm (because ~/.ssh is typically bind-mounted read-only, so a master socket can't be created under it), plus Include ~/.ssh/config.
  • Aliases host / mac → host.docker.internal (user comes from HOST_SSH_USER) — i.e. SSH back into the Docker host.
  • On VM-backed hosts only: an SSH-jump-via-host block so the container can reach the host's directly-attached LAN peers (ProxyJump host). On a native Linux host the LAN is usually reachable directly and this jump block is omitted — so don't assume a jump path exists; read the sidecar.

Use it (remember §2 — spell it out in tool bash):

ssh -F "$HOME/.ssh-local/config" mac 'hostname; whoami'      # reach the host
ssh -F "$HOME/.ssh-local/config" <lan-peer> '…'             # reach a LAN peer (if configured)

Two related mechanisms (don't reinvent them):

  • ControlMaster multiplexing is preconfigured (/tmp/sshcm/) to survive CGNAT per-destination flow caps on residential ISPs. If ~/.ssh/config pins a ControlPath under the read-only ~/.ssh, override with -o ControlPath=none (or use the sidecar, which already redirects it).

  • A live master socket MASKS auth and config changes on the far end. Once ~/.ssh-local/cm/<user>@<host>:22 exists, later commands ride it and authenticate not at all — so after editing remote authorized_keys, sshd_config, host keys, or firewall rules, "it still works" proves nothing. A corrupted authorized_keys then bites on the next cold connect, likely in a future session with no memory of the edit. Prove it immediately instead:

    ssh -F "$HOME/.ssh-local/config" -O check <host>        # 'Master running (pid=N)'
    ssh -F "$HOME/.ssh-local/config" -o ControlPath=none -o ControlMaster=no \
        -o BatchMode=yes <host> 'echo COLD AUTH OK'
    

    To attribute a socket rather than guess whose it is: ps -p <pid> -o pid,ppid,lstart,etime,args. A mosh the user started on the host bootstraps with the host's ~/.ssh/cm/ and is invisible from in here; only a mosh started inside the container shares ~/.ssh-local/cm/.

  • pi --ssh <host> rewires pi's own read/write/edit/bash tools to run on a remote host; it has its own writable-socket fallback. See the pi-extensions skill for that path.

4. DNS / name resolution — environment-specific, verify live

How a name resolves here is not universal and depends on the host's networking. The container's own resolver is just /etc/resolv.conf, but the host (which you reach via §3, and whose DNS the container may inherit) can use split-horizon DNS to send certain internal domains to specific nameservers while everything else goes to a default resolver/VPN gateway. The mechanism is OS-specific and may not be present at all:

  • macOS host: per-domain files in /etc/resolver/<domain>, each listing nameserver lines. Reading them (over ssh … mac) is a fine way to learn the real split-DNS map — for that one machine.
  • Linux host: typically systemd-resolved split DNS (per-link Domains= routing) or /etc/resolv.conf search/nameserver.
  • Windows host: the NRPT (Name Resolution Policy Table) plays the per-suffix role; WSL2 inherits host resolution via mirrored networking + DNS tunneling.

Operating rules:

  1. Never hardcode a domain→nameserver mapping or a specific nameserver IP — it is per-deployment and changes between users and even VPN states.
  2. Verify by reading the live config, e.g. cat /etc/resolv.conf in the container, or ssh … mac 'cat /etc/resolver/* 2>/dev/null' on a macOS host.
  3. Reachability needs both DNS and a route. A name resolving to an internal address is useless if packets to that subnet don't have a path (e.g. via the VPN or the §3 jump). Check both when something "resolves but won't connect."
  4. If you discover deployment-specific facts (a domain, a nameserver, a reachable peer), prefer recording them in MemPalace over baking them into code or this skill.

5. tmux is 0-indexed — don't change it

The image ships /etc/tmux.conf with base-index 0 / pane-base-index 0 because pi-studio hard-codes its tmux send target to <session>:0.0. If you (or a user ~/.tmux.conf) set base-index 1, pi-studio fails with "can't find window: 0". Leave the indexing alone in this environment.

6. Python and other languages: uv-first, toolchains are ephemeral

  • A system python3 exists, but prefer uv for REPLs and project envs — it's installed and its store (~/.local/share/uv) is a persisted volume.
    • Throwaway REPL: uv run --with ipython ipython
    • Project env: cd /workspace/proj && uv init && uv add <pkgs> && uv run … (the pyproject.toml + uv.lock travel with the repo — the durable choice).
  • Other language toolchains (Rust via rustup, R, GHC, Clojure, Go) are runtime opt-ins on the ephemeral layer unless baked into the image — they do not survive down -v or an image update. Flag this when installing.

7. pi-studio reachability (only in the -studio variant)

Present only if /opt/pi-studio exists / the studio_* tools are in your tool list. pi-studio binds to 127.0.0.1 inside the container with no host-bind flag, so a plain docker -p publish can't reach it. Two supported paths:

  • Host networking (network_mode: host): container loopback == host loopback; open the tokenized URL on the host. (Changes host.docker.internal semantics — weigh against §3 LAN jump.)
  • studio-expose bridge (STUDIO_EXPOSE=1 or run studio-expose &): a socat relay from the container's external interface to its loopback, so a published 127.0.0.1:PORT + ssh -L PORT:127.0.0.1:PORT host reaches it.

The real auth token comes from the /studio slash command (/studio --status to reprint), not from studio-expose. For Graphviz, use dot-watch → PNG (Studio renders Mermaid natively and previews PNG, but not SVG/DOT).

8. MemPalace is the shared brain

MemPalace data is usually a host bind-mount, so a pi on the host and a pi in this container share one palace (SQLite WAL: many readers, one writer). Use it to persist the deployment-specific facts this skill deliberately refuses to hardcode. Details are in the mempalace skill.

Checklist before acting in this environment

  • Writing durable output? → /workspace, not the ephemeral layer.
  • Using dssh/dscp/ll in the bash tool? → spell out the real command.
  • Assuming a hostname / domain / nameserver / host OS? → stop, detect it.
  • About to report something absent / unreachable / not running? → re-run without your own head/pattern/PATH assumptions first (§2).
  • Changed remote authorized_keys / sshd_config? → prove it with a cold connect; a live master socket hides breakage (§3).
  • "Resolves but won't connect"? → check route and DNS (§3 + §4).
  • apt/toolchain install? → tell the user it's ephemeral unless imaged.
  • Editing a skill? → readlink -f ~/.agents/skills/<name> first (§1).
  • Touching tmux indexing? → don't (§5).