Compare commits
198 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16fddebd43 | |||
| e3b38cdb0b | |||
| ee6cb9e62a | |||
| 0d324f1855 | |||
| 6c13f43ac8 | |||
| 960aada769 | |||
| b9057fdc8c | |||
| cb6d9e5dd0 | |||
| ea896054df | |||
| 50153e65b7 | |||
| f25efa074d | |||
| c7d369f28d | |||
| b8d818ed99 | |||
| f5c53b8693 | |||
| 735565b9be | |||
| 9aaff26e3a | |||
| 852f900b53 | |||
| 1baba79c96 | |||
| 42bd29d654 | |||
| 3a44e81cad | |||
| 35964abd01 | |||
| 6353d59e63 | |||
| 8f0960e134 | |||
| 1c905480e3 | |||
| ff6fd1492a | |||
| edc7659add | |||
| cac5e00a31 | |||
| ecfd2fc2e5 | |||
| 15a3728ae9 | |||
| 361babd4fd | |||
| 70e675afee | |||
| 601fc98a49 | |||
| 7e0e66997d | |||
| 6bd8b79d3a | |||
| fabf1274aa | |||
| 5972a2c535 | |||
| aa0fbc5ec0 | |||
| 702dd71f4c | |||
| f561acc89a | |||
| 0d984b1414 | |||
| adcf56f829 | |||
| c8622ece9d | |||
| 05843ecfae | |||
| a2846a5f7e | |||
| 58c22afb04 | |||
| 30094782df | |||
| 9b5783f9dd | |||
| d9a7fe101b | |||
| 36e65fe657 | |||
| f0ebea2d98 | |||
| b615571913 | |||
| 495b7e3859 | |||
| 45850bc973 | |||
| 6891dc32b8 | |||
| 8a673ec143 | |||
| cdb6fc0950 | |||
| 14371e2da6 | |||
| aac4a1c323 | |||
| 34cf1e3810 | |||
| e8ddeaf89f | |||
| 49a6534093 | |||
| e070e0bcbf | |||
| dbb78798fb | |||
| f645e6654f | |||
| 657b1ad856 | |||
| ebd0de0be2 | |||
| 4f1aa0d0dd | |||
| 9e744d701f | |||
| 2b8c3a4db4 | |||
| cb7b8ad2ae | |||
| 93f986e90e | |||
| 26f223568d | |||
| 01abda3456 | |||
| b5810654f6 | |||
| 4f6f470518 | |||
| fbc1f86612 | |||
| 2ebf00d6d4 | |||
| c3b6d36778 | |||
| 3a509077c2 | |||
| a55f6369b3 | |||
| ffd54750b9 | |||
| d8b745c164 | |||
| ae13c2264e | |||
| a2f0a4a441 | |||
| 53b41cd76b | |||
| 29b62093f0 | |||
| cbd7cf5c67 | |||
| 7c00dd6001 | |||
| 7649d53f3b | |||
| ade58131d6 | |||
| ffd44ad9cf | |||
| 43cd6e22f2 | |||
| 62a2a79b1c | |||
| f20b2a7926 | |||
| 66a19aa394 | |||
| 572430237f | |||
| 1fd524e7fb | |||
| e86e5df327 | |||
| fa04d2083d | |||
| 209f2c2f67 | |||
| 4d4abd9a9f | |||
| d5c5da3f6c | |||
| 8248688d58 | |||
| e274510fd1 | |||
| 9ef7a92dce | |||
| 6dfbded9c8 | |||
| 45b6239777 | |||
| d00eef2acb | |||
| fb35c549b5 | |||
| 649fc44c5b | |||
| 89a8dc7fab | |||
| 6625d66f3a | |||
| 8caafc3f49 | |||
| 71b12a9ed4 | |||
| fb49828826 | |||
| 02be95ac1f | |||
| d68674d11e | |||
| 8c27894cf2 | |||
| 291ae5345e | |||
| 38d8832d34 | |||
| 32586f19e7 | |||
| aaf1be0bcb | |||
| 92212fa447 | |||
| 3d46c6615e | |||
| fa6e9dc9d6 | |||
| bd0627a557 | |||
| 67da05b99b | |||
| 4563b4d76d | |||
| f19c35da32 | |||
| 6002c6299d | |||
| d73bf2e9d3 | |||
| 3a59e15563 | |||
| d1db595f17 | |||
| 26384fe9f1 | |||
| b33e9dc592 | |||
| 3cdc2069db | |||
| cc53877328 | |||
| c42b237d30 | |||
| b7197e88b0 | |||
| 2985d9ade8 | |||
| bff810c1eb | |||
| 904fe85249 | |||
| cda488c565 | |||
| 9ab9a28458 | |||
| d175b31207 | |||
| 13e67599c4 | |||
| 7551947466 | |||
| a7d6a7d235 | |||
| d619a6e2ec | |||
| 2abfee141b | |||
| c346a106a3 | |||
| 8de0fad776 | |||
| ed49b8d97a | |||
| 9eff3f3c48 | |||
| a0abacaafb | |||
| da7d70825e | |||
| 41c2c2b716 | |||
| 5c08bfc8a8 | |||
| 1371584634 | |||
| d902b2d056 | |||
| c48abf41d1 | |||
| 777d53354f | |||
| 52fe09d79d | |||
| c9534c639f | |||
| 4ed6764323 | |||
| f8da7890df | |||
| b17dc1fa1f | |||
| 3eec9bc23c | |||
| 4744f05232 | |||
| 314c3767a8 | |||
| 05e88c5c75 | |||
| 7f67c36a1c | |||
| ab5ff8ec56 | |||
| 421558477d | |||
| b655faab9f | |||
| 3b0335f34e | |||
| f91dff6090 | |||
| 9ebb0643c7 | |||
| 7d8ee4cea1 | |||
| a78e59fb5b | |||
| cf5c60a342 | |||
| edd6be1737 | |||
| efd254f4e6 | |||
| 8b69b3625b | |||
| b55b44e7b6 | |||
| c1154f1fa6 | |||
| 36afd3c716 | |||
| 2ab03aaa6f | |||
| 2e86e5a3f3 | |||
| 45f4488764 | |||
| 3bfbafad9e | |||
| d9a538c405 | |||
| 08bb0c520e | |||
| e996b01542 | |||
| 03629cdac7 | |||
| 1d1283f942 | |||
| c139be326f | |||
| 1587a84579 |
@@ -0,0 +1,35 @@
|
||||
# Keep the Docker build context minimal and prevent stray files (notably
|
||||
# `.git`) from ever being pulled in by a future broad COPY. Both Dockerfiles
|
||||
# only COPY `rootfs/` and `entrypoint*.sh`, so everything below is safe to
|
||||
# exclude from the context.
|
||||
#
|
||||
# DO NOT add `rootfs/`, `entrypoint.sh`, `entrypoint-user.sh`, or the
|
||||
# Dockerfiles here — they are required to build the image.
|
||||
|
||||
# VCS / CI metadata
|
||||
.git
|
||||
.gitea
|
||||
.gitignore
|
||||
.dockerignore
|
||||
|
||||
# Lint / editor config
|
||||
.hadolint.yaml
|
||||
.editorconfig
|
||||
|
||||
# Docs & project meta
|
||||
README.md
|
||||
DOCKER_HUB.md
|
||||
CHANGELOG.md
|
||||
AGENTS.md
|
||||
IDEAS.md
|
||||
LICENSE
|
||||
THIRD_PARTY.md
|
||||
docs
|
||||
|
||||
# Local orchestration & examples (compose runs the image; not a build input)
|
||||
docker-compose.yml
|
||||
docker-compose.mempalace.yml
|
||||
.env.example
|
||||
|
||||
# Repo tooling / tests (run from a checkout, not baked into the image)
|
||||
scripts
|
||||
+151
@@ -9,7 +9,150 @@ WORKSPACE_PATH=~/projects
|
||||
# Path to SSH keys on host
|
||||
SSH_KEY_PATH=~/.ssh
|
||||
|
||||
# ── MemPalace memory (local by default) ───────────────────────────
|
||||
# By default the mempalace.ts extension spawns a LOCAL mempalace-mcp stdio
|
||||
# server (palace at ~/.mempalace). Uncomment the devbox-palace volume in
|
||||
# docker-compose.yml to persist it across container recreation — that one
|
||||
# volume now covers the mined conversation transcripts too, since the pi and
|
||||
# opencode feeders stage inside the palace root (<palace-root>/pi-stage), so
|
||||
# the staged files and the palace dedup keys pointing at them cannot be
|
||||
# separated.
|
||||
#
|
||||
# That palace root is resolved with mempalace's own precedence
|
||||
# ($MEMPALACE_PALACE_PATH -> $MEMPAL_PALACE_PATH -> ~/.mempalace/config.json ->
|
||||
# ~/.mempalace/palace), and the feeders derive their stage FROM it
|
||||
# (<palace-root>/pi-stage). Neither the image nor the entrypoint exports it, by
|
||||
# design: pinning the palace without carrying the stage along re-creates the
|
||||
# very split that a shared root removed. Override it only to move the palace off
|
||||
# the default -- e.g. onto a different mount -- and only to a path with the SAME
|
||||
# persistence as the palace itself. A stage that outlives its palace (or dies
|
||||
# first) makes a scoped `mempalace sync` prune conversation drawers, because
|
||||
# their dedup key is the staged path. Setting it to the default buys nothing.
|
||||
# Unlike WORKSPACE_PATH/SSH_KEY_PATH above, this is a path INSIDE the container.
|
||||
# MEMPALACE_PALACE_PATH=/home/developer/.mempalace/palace
|
||||
#
|
||||
# To instead share ONE MemPalace across containers/harnesses (pi + opencode
|
||||
# + native), set the URL below. When set, the extension connects over HTTP
|
||||
# and NO local mempalace-mcp is spawned; the devbox-palace volume is then
|
||||
# irrelevant. MEMPALACE_REMOTE_TOKEN, if set, is sent as a bearer token.
|
||||
#
|
||||
# Serve it with: mempalace serve --host 172.17.0.1 --port 8765
|
||||
#
|
||||
# NOT `mempalace-mcp --transport http --host 0.0.0.0`: `serve` is the turnkey
|
||||
# wrapper that mints/keeps a bearer token (0600, passed via env so it stays out
|
||||
# of `ps`) and can terminate TLS. Two binds to avoid:
|
||||
# 0.0.0.0 - exposes the palace to the whole LAN.
|
||||
# 127.0.0.1 - behind a tunnel this 403s every proxied request (the Host pin
|
||||
# is only enforced on loopback binds) AND silently starts with
|
||||
# no token at all, since auto-minting is gated on the bind being
|
||||
# non-loopback. Bind the docker0 gateway: reachable from the host
|
||||
# and its containers (so a newt/proxy container works), not from
|
||||
# the LAN. Set MEMPALACE_MCP_HTTP_TOKEN explicitly server-side.
|
||||
# MEMPALACE_REMOTE_URL=https://mempalace.example.com/mcp
|
||||
# MEMPALACE_REMOTE_TOKEN=
|
||||
|
||||
# ── MemPalace: automatic capture of pi sessions ───────────────────────
|
||||
# The mempalace.ts extension feeds this container's pi transcripts into the
|
||||
# palace by itself: on session_shutdown, and on a debounced agent_settled so a
|
||||
# crash loses at most one window rather than the whole session. The entrypoint
|
||||
# also runs a catch-up at container start, which is the only thing that can
|
||||
# recover transcripts after a hard kill (no handler runs on SIGKILL).
|
||||
# Nothing below is required for the local-palace case; the defaults work.
|
||||
#
|
||||
# MEMPALACE_FEED=0 # disable automatic capture entirely
|
||||
# MEMPALACE_FEED_DEBOUNCE_MS=600000 # min gap between mid-session feeds (10 min)
|
||||
# MEMPALACE_FEED_WING=wing_conversations
|
||||
#
|
||||
# REMOTE PALACE ONLY (MEMPALACE_REMOTE_URL set above): the palace is on another
|
||||
# host, and `mempalace_mine` resolves its source path in the SERVER process, so
|
||||
# the server cannot see this container's transcripts. The feeder therefore
|
||||
# rsyncs its staged exports into a per-device inbox on the palace host and asks
|
||||
# the server to mine its own local copy. Without MEMPALACE_PI_SSH_TARGET the
|
||||
# feeder is skipped (a remote palace with no inbox has nothing to mine).
|
||||
# MEMPALACE_PI_SSH_TARGET where to rsync to, as user@host:path
|
||||
# MEMPALACE_PI_REMOTE_PATH what that inbox is called ON THE SERVER — i.e. the
|
||||
# path the SERVER PROCESS can open. If the palace
|
||||
# server runs in Docker, that is the container path
|
||||
# (see docker-compose.mempalace.yml). If it runs
|
||||
# NATIVELY (systemd unit / uv tool / plain
|
||||
# `mempalace serve`), it sees host paths, so this
|
||||
# must equal the path half of
|
||||
# MEMPALACE_PI_SSH_TARGET. Getting this wrong is
|
||||
# quiet: rsync still succeeds and only the mine
|
||||
# fails with "source directory not found", so
|
||||
# transcripts ship and are filed nowhere. The feeder
|
||||
# warns in preflight when the two paths disagree.
|
||||
# MEMPALACE_PI_DEVICE inbox subdirectory for this machine (default: hostname)
|
||||
# MEMPALACE_PI_SSH_TARGET=user@palace-host:/srv/mempalace-feed
|
||||
# MEMPALACE_PI_REMOTE_PATH=/data/feed
|
||||
# MEMPALACE_PI_DEVICE=
|
||||
|
||||
# ── Mailbox notification: MUST BE NAMED, auto-detect CANNOT work here ──
|
||||
# The mempalace extension polls the logstream for fleet asks addressed to this
|
||||
# device and queues them into the next turn. That part needs no config. The
|
||||
# NOTIFICATION that tells the human it happened does, and unset means SILENT
|
||||
# outside the pi TUI.
|
||||
#
|
||||
# Why there is no working default: terminal identity lives in env vars set by
|
||||
# the emulator (KITTY_WINDOW_ID, TERM_PROGRAM) and `docker exec` does NOT
|
||||
# forward them — inside the container pi sees only TERM=xterm-256color no matter
|
||||
# what is rendering it. So "desktop" auto-detection always falls through to
|
||||
# OSC 777, which Kitty does not implement, and the notification silently does
|
||||
# nothing: the worst outcome for a feature whose only job is to break a silence.
|
||||
# Naming the protocol is what makes it fire.
|
||||
#
|
||||
# kitty OSC 99 desktop notification (correct for Kitty, incl. over SSH)
|
||||
# osc777 OSC 777 (tmux/iTerm2/foot and others)
|
||||
# desktop OSC 99 if KITTY_WINDOW_ID is visible, else OSC 777 — inside a
|
||||
# container that means effectively always OSC 777, so prefer naming
|
||||
# 0 / off suppress entirely (in-TUI notify still shows)
|
||||
# MEMPALACE_MAILBOX_NOTIFY=kitty
|
||||
#
|
||||
# Cadence, if the delivery ever feels late: the poll is coupled to session
|
||||
# activity (it runs when the agent settles), NOT to a wall clock.
|
||||
# MEMPALACE_MAILBOX_POLL_MS is therefore a FLOOR BETWEEN POLLS (default 300000),
|
||||
# not a promise of one every 5 minutes — an idle session polls zero times, and
|
||||
# session start does the first look.
|
||||
# MEMPALACE_MAILBOX_POLL_MS=300000
|
||||
# MEMPALACE_MAILBOX_RESURFACE_MS=3600000
|
||||
|
||||
# ── LAN access from the container (host-OS-agnostic) ─────────────────
|
||||
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
|
||||
# reach the host's directly-attached LAN peers by default. The entrypoint
|
||||
# then sets up the host as an SSH jump (use the `dssh` alias). Reach the host
|
||||
# with `dssh host`; for named LAN peers put `ProxyJump host` overrides in a
|
||||
# host-owned ~/.config/devbox-shell/ssh-lan.conf (bind-mounted in) rather than
|
||||
# editing ~/.ssh/config. On native Linux Docker the LAN is reachable directly
|
||||
# and this is a no-op.
|
||||
# See the opencode-devbox README for the full walkthrough.
|
||||
#
|
||||
# DEVBOX_LAN_ACCESS: auto (default) | jump | off
|
||||
# DEVBOX_LAN_ACCESS=auto
|
||||
# HOST_SSH_USER: your username on the host (required for the jump). On first
|
||||
# start the entrypoint prints the public key to authorize on the host.
|
||||
# HOST_SSH_USER=
|
||||
# DEVBOX_HOST_ALIAS: host hostname to reach (default host.docker.internal).
|
||||
# DEVBOX_HOST_ALIAS=host.docker.internal
|
||||
# DEVBOX_LAN_AUTOJUMP_PRIVATE: 1 = ProxyJump any private (RFC1918) IP through
|
||||
# the host, so bare `dssh user@<ip>` works on whatever LAN you're roaming on.
|
||||
# DEVBOX_LAN_AUTOJUMP_PRIVATE=0
|
||||
|
||||
# ── pi-atelier (TUI sidebar) ─────────────────────────────────────────
|
||||
# The image vendors pi-atelier at a pinned, audited tag and registers it on
|
||||
# container start. Set to 0 to opt out: the entrypoint then removes it from
|
||||
# pi's `packages[]` instead of registering it. This lives here rather than
|
||||
# being a `pi uninstall` because a broken TUI extension's failure mode is
|
||||
# "pi will not start", which you cannot fix from inside pi.
|
||||
# DEVBOX_ATELIER=1
|
||||
|
||||
# ── Git Configuration ────────────────────────────────────────────────
|
||||
# Set BOTH. If unset, every repo inside the container fails with
|
||||
# "Author identity unknown" on first commit, and an agent asked to commit
|
||||
# will guess an identity from git log — often the wrong one. The e-mail is
|
||||
# per-machine (work machines use the corporate address, personal machines the
|
||||
# private one), so it belongs in this per-machine .env, never in a skill or a
|
||||
# repo-local override. Consumed by entrypoint-user.sh -> ~/.gitconfig, which is
|
||||
# NOT persistent across container recreate — this file is the source of truth.
|
||||
GIT_USER_NAME=
|
||||
GIT_USER_EMAIL=
|
||||
|
||||
@@ -32,6 +175,14 @@ GIT_USER_EMAIL=
|
||||
# Detection is automatic if the skillset lives at WORKSPACE_PATH/skillset.
|
||||
# SKILLSET_CONTAINER_PATH=
|
||||
|
||||
# ── cli_utils (standalone commands from a mounted checkout) ──────────
|
||||
# If a cli_utils repo is mounted, the entrypoint symlinks its bin/ commands
|
||||
# into ~/.local/bin on every start, so they survive container recreate and
|
||||
# resolve in non-interactive shells too (docker exec, agent tool shells).
|
||||
# Detection is automatic at WORKSPACE_PATH/cli_utils (or one level below).
|
||||
# CLI_UTILS_CONTAINER_PATH=
|
||||
# CLI_UTILS_LINK=0 # disable the linking entirely
|
||||
|
||||
# ── Locale ───────────────────────────────────────────────────────────
|
||||
# LANG=sv_SE.UTF-8
|
||||
# LANGUAGE=sv_SE:sv
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,224 @@
|
||||
name: Lint
|
||||
|
||||
# Durable guard against CI-workflow bugs — most importantly the recurring
|
||||
# "bash-only syntax under the default `sh`/dash shell" footgun that broke
|
||||
# resolve-versions (ed49b8d) and promote-base-latest (b7197e8 → run 418).
|
||||
# actionlint runs shellcheck against each `run:` step using its *effective*
|
||||
# shell, so `set -o pipefail` under dash is flagged as SC3040 before any
|
||||
# expensive build runs. This is cheap (~10s) and independent of the build
|
||||
# pipeline, so it fires on every branch push/PR — not just on release tags,
|
||||
# which is where the build workflow (docker-publish.yml) is otherwise only
|
||||
# triggered.
|
||||
#
|
||||
# `branches: ['**']` (rather than a bare `push:`) deliberately EXCLUDES tag
|
||||
# pushes. A bare `push:` also fires on `refs/tags/v*`, which was duplicate work —
|
||||
# the tagged tree was already linted when the same commit was pushed to main
|
||||
# (v1.6.4: lint id=529 on refs/heads/main, then id=531 again on
|
||||
# refs/tags/v1.6.4, same sha e86e5df). The wasted compute is small (measured:
|
||||
# lint here runs 0.3-0.9 min, against a 77.6 min release build for v1.6.4 — so
|
||||
# runner contention is NOT a real argument in this repo, unlike opencode-devbox
|
||||
# where actionlint installs shellcheck and takes 6-15 min). The substantive
|
||||
# reason is discovery ambiguity: the runs listing is newest-first, so the
|
||||
# tag-ref lint run sorts ABOVE the publish run, and "first run matching
|
||||
# refs/tags/<tag>" picks lint — which goes green in under a minute while the
|
||||
# image is still building, making a release look finished before anything is
|
||||
# published. See AGENTS.md "Gitea API access" for the head_sha-filtered
|
||||
# discovery pattern.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- '**'
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: lint-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
|
||||
jobs:
|
||||
actionlint:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install shellcheck
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends shellcheck python3-yaml
|
||||
|
||||
- name: "Shellcheck + syntax-check repository scripts (severity: error)"
|
||||
# Gap being closed: everything else in this job shellchecks workflow
|
||||
# `run:` steps ONLY, via actionlint. The repo's own shell scripts —
|
||||
# entrypoint.sh, scripts/*.sh, and the extensionless tools under
|
||||
# rootfs/usr/local/bin/ — have never been shellchecked. That exact gap
|
||||
# (a sibling repo with no shell-script lint at all) is how a defect
|
||||
# shipped invisibly for two months: `echo "$json" | python3 <<'EOF'
|
||||
# ... json.load(sys.stdin)` cannot work — with no script argument
|
||||
# python reads its SCRIPT from stdin, so the heredoc IS stdin and the
|
||||
# json.load call hits EOF. shellcheck flags exactly this at severity
|
||||
# ERROR (SC2259, "This redirection overrides piped input"); nothing
|
||||
# ever ran it. Measured before adding this gate: `-S error` is 0
|
||||
# findings across every shell file in THIS repo today, so it is free
|
||||
# to add. `-S warning` is NOT free here (19x SC2088 tilde-in-quotes in
|
||||
# scripts/recreate-sanity-check.sh, plus assorted SC2016 — both
|
||||
# intentional), so warning-level would train people to ignore the job;
|
||||
# hence error-only, matching the SHELLCHECK_OPTS philosophy below.
|
||||
#
|
||||
# Discovery is *.sh UNION a shebang scan, because rootfs/usr/local/
|
||||
# bin/{pi-devbox-version,devbox-skill-reconcile,dot-watch,studio-expose}
|
||||
# are shell scripts with no extension. -print0/mapfile -d '' so a path
|
||||
# with a space cannot silently split, and the file count is asserted
|
||||
# non-zero — a green tick over an empty file set is not a check.
|
||||
#
|
||||
# The implementation moved to scripts/lint-shell.sh on 2026-09-08 so the
|
||||
# release gate in docker-publish.yml runs the SAME code rather than a
|
||||
# second copy that drifts. Edit the script, not a copy of it.
|
||||
run: bash scripts/lint-shell.sh
|
||||
|
||||
- name: Gitea shell guard (catches the actionlint blind spot)
|
||||
# actionlint models GitHub Actions, where the default run shell is
|
||||
# bash, so it does NOT flag bash syntax in a step that merely OMITS
|
||||
# `shell:` — which is exactly how ed49b8d and b7197e8 manifested on
|
||||
# Gitea (default sh/dash). This guard enforces that every run: step
|
||||
# resolves to bash under Gitea's real defaults. Run it BEFORE
|
||||
# actionlint so the more precise diagnostic surfaces first.
|
||||
run: bash scripts/check-workflow-shell.sh .gitea/workflows
|
||||
|
||||
- name: Install actionlint (pinned)
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
run: |
|
||||
curl -fsSL \
|
||||
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin actionlint
|
||||
actionlint --version
|
||||
|
||||
- name: Run actionlint
|
||||
# SHELLCHECK_OPTS excludes pure-style codes (quoting/style opinions)
|
||||
# so the guard stays focused on correctness bugs — crucially the
|
||||
# SC3xxx "not POSIX / wrong shell" family that catches the pipefail
|
||||
# footgun. Do NOT exclude SC3040 (set -o pipefail under sh) or any
|
||||
# other SC3xxx code.
|
||||
env:
|
||||
SHELLCHECK_OPTS: "-e SC2086 -e SC2016 -e SC2129 -e SC2001 -e SC2312"
|
||||
# Pass explicit paths: actionlint's no-arg mode auto-detects a
|
||||
# project by looking for `.github/workflows`, which doesn't exist in
|
||||
# this `.gitea/workflows` repo and hard-fails with exit 3
|
||||
# ("no project was found"). Globbing the workflow files is the
|
||||
# supported way to lint a non-GitHub layout.
|
||||
run: actionlint -color .gitea/workflows/*.yml
|
||||
|
||||
hadolint:
|
||||
# Lint the two Dockerfiles that ARE the project (the shell/actions linting
|
||||
# above never looked at them). Config — ignored rules + failure threshold
|
||||
# — lives in .hadolint.yaml, which hadolint reads automatically, so a local
|
||||
# `hadolint Dockerfile.base` reproduces CI exactly.
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Install hadolint (pinned)
|
||||
env:
|
||||
HADOLINT_VERSION: 2.15.1
|
||||
run: |
|
||||
curl -fsSL \
|
||||
"https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-Linux-x86_64" \
|
||||
-o /usr/local/bin/hadolint
|
||||
chmod +x /usr/local/bin/hadolint
|
||||
hadolint --version
|
||||
|
||||
- name: Run hadolint
|
||||
run: hadolint Dockerfile.base Dockerfile.variant
|
||||
|
||||
skill-floor:
|
||||
# Gate the VENDORED pi-extensions skill snapshot in rootfs/ against the
|
||||
# package repo it is a snapshot of. Its own job rather than a step in
|
||||
# `actionlint`, so "the floor is stale" is a distinct red name in the runs
|
||||
# list instead of being buried in a lint job that is about something else.
|
||||
#
|
||||
# The gap it closes, measured 2026-09-10: the floor sat at 34284 B, untouched
|
||||
# since fa04d20 (2026-07-30), while the package copy was 38973 B.
|
||||
# Dockerfile.variant copies the fresh package copy over the SERVED path but
|
||||
# never writes back to the floor, so nothing in the repo ever noticed. That
|
||||
# matters because the floor is a FALLBACK: the copy is guarded by
|
||||
# `if [ -f /opt/pi-extensions/skill/SKILL.md ]`, so a build whose clone
|
||||
# yields no skill/ ships the vendored snapshot and still goes green, with no
|
||||
# manifest flag or label saying which copy was served.
|
||||
#
|
||||
# Gating on another repo is normally a smell; it is proportionate here
|
||||
# because the check compares the skill DIRECTORY hash, so it can only fire
|
||||
# when that directory actually changed — which is exactly when the floor has
|
||||
# gone stale. pi-extensions commits that leave skill/ alone cannot turn this
|
||||
# red. No secret is needed either: the repo is anonymously clonable (verified
|
||||
# 2026-09-10 with `git ls-remote` and no credentials), so this cannot start
|
||||
# failing when a token expires.
|
||||
#
|
||||
# Exit codes are 0 in sync / 1 drift / 2 cannot-run, matching
|
||||
# scripts/lint-shell.sh: a gate that cannot run must not pass, so an
|
||||
# unreachable package repo is a red 2 rather than a green tick.
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Vendored pi-extensions skill floor matches the package
|
||||
run: bash scripts/check-skill-floor.sh
|
||||
|
||||
doc-drift:
|
||||
# Gate hand-maintained doc claims against the build files they describe.
|
||||
# Its own job for the same reason as skill-floor: "the docs lie" should be a
|
||||
# distinct red name, not a line buried in a job about workflow syntax.
|
||||
#
|
||||
# The gap it closes, measured 2026-09-10 while preparing v1.9.0 — five
|
||||
# claims had rotted, every one of them a fact written by hand in a file
|
||||
# nothing verified:
|
||||
# * README.md's "Version pins" table was wrong on ALL THREE rows (pi
|
||||
# 0.84.4 vs 0.85.1, pi-atelier v0.10.0 vs v0.10.1, mempalace 3.8.0 vs
|
||||
# 3.9.0) — and that table exists specifically to be the reviewable
|
||||
# record of what the repo freezes on purpose, so a wrong row destroys
|
||||
# the only thing it is for.
|
||||
# * README.md listed already-shipped typst PDF export under "Planned for
|
||||
# an upcoming minor release", marked "(shipped in Unreleased/base)".
|
||||
# * DOCKER_HUB.md claimed "Node.js v22" while v1.9.0 ships Node 24.
|
||||
#
|
||||
# DOCKER_HUB.md is why this is a gate and not a habit. It is PUBLISHED —
|
||||
# update-description POSTs it to Docker Hub as full_description on every tag
|
||||
# — and it had gone eight releases (v1.8.6 -> v1.9.0) untouched. Nothing
|
||||
# generates it and nothing checked it, so the only thing keeping it true was
|
||||
# someone remembering. It is also read from the TAG, so a fix pushed to main
|
||||
# after tagging never reaches the published page.
|
||||
#
|
||||
# Two classes of check. 1-7 are hermetic: each compares a doc string against
|
||||
# a value that exists in this repo — no network, no token, no built image.
|
||||
# 8-9 compare against what is PUBLISHED, because those claims have no
|
||||
# in-repo anchor and rotted for exactly that reason: 8 reads Docker Hub's
|
||||
# measured sizes; 9 reads the ref labels baked into the last released image
|
||||
# (anonymous registry API, no docker/crane) and `git ls-remote`s each
|
||||
# floating upstream, then requires every component the next build would
|
||||
# bake differently to be NAMED in the CHANGELOG above that release's
|
||||
# heading. Both SKIP loudly and counted when offline — a skip is neither OK
|
||||
# nor a failure. Claims that genuinely need a running container (the "N
|
||||
# mempalace_* tools" count, uncompressed sizes) are still left out — a gate
|
||||
# that cannot evaluate a claim honestly would have to guess, and a guessing
|
||||
# gate is worse than none. Assert those in scripts/smoke-test.sh instead.
|
||||
#
|
||||
# Exit codes 0 in sync / 1 drift / 2 cannot-run, matching lint-shell.sh and
|
||||
# check-skill-floor.sh. A renamed ARG makes the gate blind, so that is a red
|
||||
# 2, not a green tick.
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Doc claims match the build files
|
||||
run: bash scripts/check-doc-drift.sh
|
||||
@@ -0,0 +1,27 @@
|
||||
# hadolint configuration for pi-devbox.
|
||||
#
|
||||
# Both Dockerfiles are linted in CI (.gitea/workflows/lint.yml → `hadolint`
|
||||
# job). hadolint reads this file automatically, so a local
|
||||
# `hadolint Dockerfile.base` reproduces CI exactly.
|
||||
#
|
||||
# The ignores below are DELIBERATE project choices — they mirror the
|
||||
# philosophy of the shellcheck excludes already applied to `run:` steps
|
||||
# (SHELLCHECK_OPTS in lint.yml). Anything NOT listed here still fails the
|
||||
# build at `warning` and above, so new Dockerfile smells are caught going
|
||||
# forward.
|
||||
ignored:
|
||||
- DL3008 # "pin apt versions" — intentionally unpinned: the base tracks
|
||||
# Debian stable and runs `apt-get upgrade`, so pinning point
|
||||
# versions would rot and fight security updates.
|
||||
- DL3016 # "pin npm versions" — pi's version IS pinned, but via the
|
||||
# PI_VERSION build-arg (CI-resolved from npm), not the npm CLI.
|
||||
- DL4006 # "set -o pipefail before a pipe" — the piped RUNs are
|
||||
# download|extract steps with their own retries / `set -e`.
|
||||
# Switching the global SHELL to bash is a larger, base-affecting
|
||||
# change — tracked in IDEAS.md.
|
||||
- DL3003 # "use WORKDIR, not cd" — cosmetic in the few `cd` RUNs here.
|
||||
- SC2086 # "double-quote to prevent word-splitting" — the same code is
|
||||
# excluded for shell `run:` steps in lint.yml; splitting is
|
||||
# intentional in these contexts.
|
||||
|
||||
failure-threshold: warning
|
||||
@@ -1,64 +1,386 @@
|
||||
# AGENTS.md — pi-devbox
|
||||
|
||||
Container image that adds pi coding-agent on top of the opencode-devbox base image.
|
||||
Self-contained Docker image for the **pi coding-agent**. Decoupled from
|
||||
opencode-devbox at v1.0.0 (2026-06-09); previously pi-devbox was a thin
|
||||
re-brand of opencode-devbox's `pi-only` variant.
|
||||
|
||||
## Repository layout
|
||||
|
||||
- `Dockerfile` — single-stage build, `FROM opencode-devbox:base-latest`, installs pi + companion repos
|
||||
- `docker-compose.yml` — compose file for local use
|
||||
- `.env.example` — environment variable template
|
||||
- `scripts/smoke-test.sh` — sanity checks run by CI before pushing to Docker Hub
|
||||
- `.gitea/workflows/docker-publish.yml` — CI pipeline: smoke amd64 → multi-arch push → update Hub description
|
||||
- `Dockerfile.base` — multi-arch base layer with system packages,
|
||||
GitHub-binary tools (fzf, eza, zoxide, neovim, bat, gosu, gitleaks,
|
||||
git-lfs, uv, gitea-mcp, tealdeer), AWS CLI v2, mempalace + toolkit,
|
||||
Node.js, Python toolchain, locales, ssh ControlMaster defaults, and
|
||||
`/etc/tmux.conf` with 0-indexed sessions.
|
||||
- `Dockerfile.variant` — `FROM base-<hash>`, adds pi + companions
|
||||
(`pi-toolkit`, `pi-extensions`, `pi-fork`, `pi-observational-memory`)
|
||||
and, when `INSTALL_STUDIO=true`, vendors `pi-studio` to `/opt/pi-studio`
|
||||
(`-studio` variant). Also appends the pi-devbox managed block from
|
||||
`pi-global-AGENTS.append.md` onto pi-toolkit's `pi-global-AGENTS.md` (the
|
||||
single global instruction slot pi loads) so containers proactively load the
|
||||
baked `pi-devbox-environment` skill. Idempotent via a marker grep. After the
|
||||
pinned clones it also refreshes the vendored `pi-extensions` fallback skill
|
||||
by copying `/opt/pi-extensions/skill/` over the committed `rootfs/` snapshot
|
||||
(Option 1 over Option 2 — see `skills/VENDORED.md`).
|
||||
- `entrypoint.sh` — UID/GID alignment as root, then drops to `developer`.
|
||||
- `entrypoint-user.sh` — per-container start: prints the `pi-devbox-version`
|
||||
banner first (which build/commit is running, from the manifest below),
|
||||
then SSH ControlMaster socket dir, LAN-access setup, MemPalace init,
|
||||
pi-toolkit + pi-extensions deploy, mempalace-bridge symlink, fork/recall +
|
||||
pi-studio pi-install, optional `studio-expose` bridge (when
|
||||
`STUDIO_EXPOSE=1`), image-baked skills symlink-in, skillset deploy.
|
||||
- `rootfs/` — files baked into the image (bash aliases, inputrc,
|
||||
setup-lan-access.sh, `studio-expose` helper, `pi-devbox-version` — wraps
|
||||
`/etc/pi-devbox/build-manifest.json` into a human-readable summary + live
|
||||
drift check, see README “Build provenance”). Also
|
||||
`usr/local/share/pi-devbox/skills/<name>/SKILL.md` — image-baked agent
|
||||
skills (the repo-authored `pi-devbox-environment`, plus vendored fallback
|
||||
copies of `pi-extensions` and `mempalace` — see `skills/VENDORED.md`)
|
||||
symlinked into `~/.agents/skills/` by the entrypoint, available with or
|
||||
without a mounted skillset — plus
|
||||
`usr/local/share/pi-devbox/pi-global-AGENTS.append.md` (the global-AGENTS
|
||||
pointer concatenated in `Dockerfile.variant`).
|
||||
- `scripts/smoke-test.sh` — sanity checks run by CI before pushing to Hub.
|
||||
- `.gitea/workflows/docker-publish.yml` — two-phase CI (base-decide →
|
||||
build-base → smoke → build-variant → promote-base-latest →
|
||||
update-description). The `-studio` variant adds independent
|
||||
`smoke-studio` + `build-variant-studio` jobs that gate only the
|
||||
`-studio` tags (never the core `:latest` release).
|
||||
|
||||
## Versioning scheme
|
||||
|
||||
- Tags follow the pi npm version: `v{pi_version}[letter]`
|
||||
- Bump `PI_VERSION` build-arg default in `Dockerfile` when cutting a new release
|
||||
- Docker Hub: `joakimp/pi-devbox:vX.Y.Z` + `joakimp/pi-devbox:latest`
|
||||
- Tags follow semver. **v1.0.0** is the first decoupled release; future
|
||||
minor bumps add variants (`-studio`, `-studio-tex`) or significant base
|
||||
additions (e.g. v1.2.0 image-baked agent skills); patch bumps follow
|
||||
pi npm version updates and small fixes.
|
||||
- Docker Hub tags: `joakimp/pi-devbox:vX.Y.Z` + `joakimp/pi-devbox:latest`
|
||||
+ (since v1.1.0) `joakimp/pi-devbox:vX.Y.Z-studio` +
|
||||
`joakimp/pi-devbox:latest-studio`.
|
||||
Internal tags: `joakimp/pi-devbox:base-<hash>` (content-addressed) +
|
||||
`joakimp/pi-devbox:base-latest` (alias of most recent base).
|
||||
|
||||
## Release-day checklist
|
||||
|
||||
1. Bump `PI_VERSION` in `Dockerfile` (or leave as `latest` to pick up current)
|
||||
2. Update `CHANGELOG.md`: promote `Unreleased` → `vX.Y.Z — YYYY-MM-DD`
|
||||
3. Add fresh `## Unreleased` section
|
||||
4. Commit, tag `vX.Y.Z`, push tag → CI fires automatically
|
||||
1. Confirm `pi --version` resolves from npm to the expected version
|
||||
(`curl -sf 'https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest' | jq -r .version`).
|
||||
Check release notes at https://github.com/earendil-works/pi/releases for
|
||||
the upstream changelog to include in `CHANGELOG.md`.
|
||||
2. **Refresh the vendored mempalace skill snapshot if the skillset moved:**
|
||||
`scripts/vendor-mempalace-skill.sh --check` (reads a real skillset clone,
|
||||
writes nothing). Three exit codes, not two — a stale-but-truthful record is
|
||||
**not** a release blocker, so don't treat any non-zero exit as "must
|
||||
refresh" without reading which one it was:
|
||||
- **0** — the record is truthful. This includes stale-but-truthful
|
||||
(upstream has moved past the recorded ref, or the local clone has
|
||||
uncommitted changes) — a `NOTICE` is printed, but nothing is lying.
|
||||
**Skipping the refresh in this case is the legitimate, sanctioned
|
||||
outcome** — every enrolled host reads its own live skillset clone, so
|
||||
the baked copy is only a no-mount fallback. What is not legitimate is
|
||||
skipping it *silently*: the drift is visible here, in
|
||||
`pi-devbox-version`, and in the manifest, so decide rather than forget.
|
||||
- **1** — a confirmed problem: the vendored bytes provably do NOT match
|
||||
the file at the recorded ref (a lying record), or the recorded ref
|
||||
doesn't even resolve to that path in this clone. Refresh.
|
||||
- **2** — cannot determine (the recorded ref itself isn't resolvable in
|
||||
this clone — commonly a shallow checkout missing history). Fetch full
|
||||
history and re-check before deciding; don't refresh blind.
|
||||
Refresh with `scripts/vendor-mempalace-skill.sh`, which rewrites the file
|
||||
**and** the ARG together so they cannot drift apart, and refuses (exit 1)
|
||||
rather than silently rewinding provenance if the skillset clone's HEAD is
|
||||
behind the already-recorded ref (detached HEAD, older checkout) — pass
|
||||
`--force` only if that rewind is genuinely intended.
|
||||
Two consequences to accept deliberately on an actual refresh: the snapshot
|
||||
is hashed into `base_tag`, so it costs a base rebuild (~67 min); and if the
|
||||
section the phrase canary names has changed, re-pin it in
|
||||
`scripts/smoke-test.sh`.
|
||||
3. **Update the docs this release makes stale — BEFORE you tag.** Rename
|
||||
`CHANGELOG.md`'s `## Unreleased` to `## vX.Y.Z — YYYY-MM-DD` (em dash, as
|
||||
every prior release heading uses), then run the gate:
|
||||
|
||||
When drafting CHANGELOG entries, pull pi's release notes from the
|
||||
`CHANGELOG.md` shipped inside the npm tarball:
|
||||
```bash
|
||||
bash scripts/check-doc-drift.sh # 0 in sync / 1 drift / 2 cannot run
|
||||
```
|
||||
|
||||
```bash
|
||||
cd /tmp && npm pack @earendil-works/pi-coding-agent@<version>
|
||||
tar -xzf earendil-works-pi-coding-agent-<version>.tgz package/CHANGELOG.md
|
||||
head -40 package/CHANGELOG.md
|
||||
It compares README.md's version-pin table against the ARGs it names, and
|
||||
DOCKER_HUB.md's Node claim against `ARG NODE_VERSION`, plus Hub's
|
||||
25 000-char limit, unsubstituted `{{PLACEHOLDERS}}`, and stale `Unreleased`
|
||||
pointers in user-facing docs. With network it also checks DOCKER_HUB.md's
|
||||
size claims against Hub's measured sizes (check 8) and — check 9 — that
|
||||
**every component the next build would bake differently from the last
|
||||
published release is named in the CHANGELOG** above that release's heading:
|
||||
it reads the `se.jordbo.pi-devbox.*-ref` labels off the published image and
|
||||
`git ls-remote`s each floating `*_REF`. A red check 9 means an upstream
|
||||
(pi-toolkit, pi-extensions, mempalace-toolkit, pi-fork,
|
||||
pi-observational-memory, pi-studio) moved and no entry names the new SHA;
|
||||
the failure prints the compare URL; a `PI_VERSION` or `MEMPALACE_VERSION`
|
||||
bump is caught the same way via the `pi-version` / `mempalace-version`
|
||||
labels. Name the 7-char SHA (or version) where you describe
|
||||
the change — that is what the old "Dependency audit" tables recorded by
|
||||
hand, now required.
|
||||
|
||||
**Why before and not after:** `docker-publish.yml` runs `actions/checkout@v4`
|
||||
with no `ref:`, so every job reads `github.ref` — the **tag**. A doc fix
|
||||
pushed to `main` after tagging does not reach the release, and for
|
||||
`DOCKER_HUB.md` it does not reach the published Hub page either, because
|
||||
`update-description` POSTs that file as Docker Hub's `full_description` from
|
||||
the tag's tree. Getting it in afterwards means re-pointing the tag, which is
|
||||
its own hazard (v1.8.14 went `601fc98` → `361babd` and broke deploy
|
||||
verification until `git fetch --tags --force`).
|
||||
|
||||
The gate is deliberately narrow — it only checks claims verifiable from files
|
||||
in this repo. Still eyeball, because these are NOT gated:
|
||||
- counts and sizes (`~1.1 GB`, "N `mempalace_*` tools", "7 extensions") —
|
||||
they need a running image; assert them in `scripts/smoke-test.sh` instead
|
||||
- feature prose that quietly became false, e.g. a "Planned for an upcoming
|
||||
release" section describing something that already shipped
|
||||
- `Dockerfile.base`'s `# BASE_REBUILD_DATE:` marker. Ungated on purpose:
|
||||
`base_tag` hashes Dockerfile.base's content, comments included, so
|
||||
demanding it be current would force a ~60 min base rebuild on a release
|
||||
that touched no base files. **Fix it when the base is already rebuilding —
|
||||
then it is free.**
|
||||
|
||||
Measured cost of skipping this, 2026-09-10 (v1.9.0): five stale claims, one
|
||||
of them published. README's pin table was wrong on all three rows, and
|
||||
DOCKER_HUB.md — untouched for eight releases — still said Node v22 while the
|
||||
image shipped Node 24.
|
||||
4. Verify `docker compose up` works locally with the current `latest` image
|
||||
if you're upgrading users from a previous version. Then run the
|
||||
**post-recreate sanity check** inside the running container to confirm
|
||||
persisted volumes survived and the pi runtime wiring re-deployed (not just
|
||||
that the container booted):
|
||||
`docker compose exec devbox bash scripts/recreate-sanity-check.sh --expected-image-version X.Y.Z`
|
||||
(or just `pi-devbox-sanity --expected-image-version X.Y.Z` if
|
||||
`cli_utils/bin` is on PATH). This is the runtime peer of the build-time
|
||||
`smoke-test.sh` gate.
|
||||
**`X.Y.Z` here is the pi-devbox release tag** you are shipping (e.g.
|
||||
`1.8.9`), which is what the rest of this checklist means by `vX.Y.Z`.
|
||||
`--expected-image-version` is the flag that asserts it. There is also an
|
||||
`--expected-version`, and it means something else — the **pi coding agent**
|
||||
version (e.g. `0.84.3`, the `ARG PI_VERSION` pin). Handing the release tag
|
||||
to that one used to report *"pi version mismatch: expected 1.8.8, got
|
||||
0.84.3"*, i.e. a red on the final gate of the release accusing the wrong
|
||||
component; it now tells you to use `--expected-image-version` instead, and
|
||||
the reverse mix-up is caught too. Both flags are optional — with neither,
|
||||
the live pi version is asserted against the version recorded in the image's
|
||||
own build manifest (which catches a stale `pi` in the `~/.pi/npm-global`
|
||||
volume shadowing the baked one) and the image tag is reported
|
||||
informationally.
|
||||
5. Push tag: `git tag vX.Y.Z && git push origin vX.Y.Z`.
|
||||
6. Watch CI: smoke job builds amd64 only and asserts size + extensions +
|
||||
pi version + new-base-tooling presence. Variant build is multi-arch
|
||||
(amd64 + arm64) only after smoke passes. A tag push fires **only**
|
||||
`docker-publish.yml` — `lint.yml` is scoped to `branches: ['**']`, which
|
||||
excludes tag refs on purpose (the tagged tree was already linted when the
|
||||
commit hit `main`, and a fast lint run sorting above the slow publish run
|
||||
made releases look finished before anything shipped). Verified on v1.8.4:
|
||||
`refs/tags/v1.8.4` produced run 571 (publish) and nothing else. Still filter
|
||||
discovery on `head_sha` **and** the workflow `path` — see *Gitea API access*
|
||||
below — because that guard costs nothing and a future workflow added on `v*`
|
||||
would silently reintroduce the ambiguity.
|
||||
7. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus
|
||||
base-latest if the base was rebuilt this run).
|
||||
8. **Revoke any short-lived Gitea PAT** used during the release at
|
||||
`gitea.jordbo.se/user/settings/applications`. N/A if you used the
|
||||
`GITEA_ACCESS_TOKEN` env var instead (see *Gitea API access* below) —
|
||||
its lifecycle is managed host-side, nothing to revoke.
|
||||
|
||||
## Verifying this repo's reality from inside a container
|
||||
|
||||
Most work on this repo happens **inside** a pi-devbox container, inspecting a
|
||||
host or a peer over SSH. That setup manufactures convincing false negatives, so
|
||||
when you are about to report that something is **absent, unreachable, or not
|
||||
running**, suspect your own command first. Recurring instances:
|
||||
|
||||
- **`docker` is not on the host's non-interactive SSH `PATH`.** `ssh mac 'docker
|
||||
ps'` says *command not found* on a host that plainly runs Docker; use
|
||||
`/usr/local/bin/docker` (or `command -v docker` first). Every step in the
|
||||
*Release-day checklist* that inspects a running container hits this.
|
||||
- **Don't `| head -N` a search whose answer you don't already know.** The host's
|
||||
`~/.ssh/config` is ~500 lines; a `head -20` "proved" a peer absent that was
|
||||
defined at line 454.
|
||||
- **The deployment compose file is not this repo's.** `docker-compose.yml` here
|
||||
is a template pinning `:latest`; a real host runs its own per-machine file
|
||||
(find it with `docker inspect <container> --format '{{ index .Config.Labels
|
||||
"com.docker.compose.project.config_files" }}'`). Recreating from the repo copy
|
||||
can silently move a host off `:latest-studio` onto `:latest`.
|
||||
- **A live SSH ControlMaster hides remote auth changes** — after editing a
|
||||
peer's `authorized_keys`, prove access with `-o ControlPath=none -o
|
||||
ControlMaster=no`, or the breakage surfaces in a later session instead.
|
||||
|
||||
Depth and further mechanisms: the repo-authored `pi-devbox-environment` skill
|
||||
(`rootfs/usr/local/share/pi-devbox/skills/pi-devbox-environment/SKILL.md`) §2
|
||||
and §3 — that file is the one an agent actually loads mid-session, whereas this
|
||||
`AGENTS.md` is only auto-read when the cwd *is* this repo.
|
||||
|
||||
## Gitea API access (env token)
|
||||
|
||||
`GITEA_ACCESS_TOKEN` + `GITEA_HOST` are passed into the container from the
|
||||
host `.env` via `docker-compose.yml` (`${GITEA_ACCESS_TOKEN:-}` /
|
||||
`${GITEA_HOST:-}`), primarily to enable the `gitea-mcp` server. They are
|
||||
**not** baked into the image. When configured, they are also available for
|
||||
**any** direct Gitea API interaction from inside the container — inspecting
|
||||
CI runs, checking published tags, listing commits — e.g.
|
||||
`curl -H "Authorization: token $GITEA_ACCESS_TOKEN" "$GITEA_HOST/api/v1/repos/joakimp/pi-devbox/actions/runs?limit=20"`.
|
||||
Prefer this over a short-lived PAT file when the env token is present (the
|
||||
`ci-release-watcher` skill auto-detects it). Public-repo GET listings work
|
||||
unauthenticated too, so the token matters mainly for private repos or
|
||||
rate-limit headroom; its lifecycle is host-managed, so there is nothing to
|
||||
revoke after use. Never echo the token value (including into logs).
|
||||
|
||||
**Gotcha — a tag push fires EVERY workflow whose triggers match the tag ref.**
|
||||
`lint.yml` uses a bare `push:` trigger, so a release tag yields *both* a lint run
|
||||
and the publish run. The listing is newest-first and lint sorts **above** the
|
||||
publish run, so "take the first run whose `path` contains `refs/tags/<tag>`"
|
||||
picks the wrong one **reliably, not occasionally**. Real listing for v1.6.4:
|
||||
|
||||
```
|
||||
id=531 #104 lint.yml@refs/tags/v1.6.4 <- wrong; sorts first
|
||||
id=530 #103 docker-publish.yml@refs/tags/v1.6.4 <- the release build
|
||||
id=529 #102 lint.yml@refs/heads/main <- same commit, linted on push
|
||||
```
|
||||
|
||||
Pi's CHANGELOG has rich New Features / Added / Changed / Fixed sections
|
||||
per version. Don't try to derive notes from the npm registry metadata
|
||||
(`npm view`) — it doesn't include the changelog body.
|
||||
Lint goes green in minutes while the image is still building, so watching it
|
||||
makes a release look finished when nothing has been published yet.
|
||||
|
||||
## Key facts
|
||||
**Gotcha — the jobs endpoint takes the internal `id`, NOT the `run_number` the
|
||||
UI shows as `#104`.** The two diverge widely, and `GET
|
||||
.../actions/runs/<run_number>/jobs` does **not** error — it silently returns a
|
||||
*different* run's jobs. Always read `id` from the run listing:
|
||||
|
||||
- **Base image**: `joakimp/opencode-devbox:base-latest` — rebuilt whenever opencode-devbox cuts a new base
|
||||
- **pi binary**: baked at `/usr/bin/pi` (system npm prefix); `NPM_CONFIG_PREFIX=/home/developer/.pi/npm-global` at runtime so user-installed pi/packages land on the named volume
|
||||
- **Companion repos**: pi-toolkit and pi-extensions cloned to `/opt/` at build time; `entrypoint-user.sh` (inherited from base) deploys symlinks to `~/.pi/agent/` on container start
|
||||
- **MemPalace**: fully operational — inherited from base image; bridge extension deployed by entrypoint
|
||||
```bash
|
||||
# Which runs did this tag/commit trigger? Filter on head_sha; never trust
|
||||
# ordering or run numbering. limit=20, not 5 — with two runs per push the
|
||||
# publish run falls off a 5-item window fast.
|
||||
curl -sS -H "Authorization: token $GITEA_ACCESS_TOKEN" \
|
||||
"$GITEA_HOST/api/v1/repos/joakimp/pi-devbox/actions/runs?limit=20" \
|
||||
| jq --arg sha "$(git rev-list -n1 vX.Y.Z)" \
|
||||
'.workflow_runs[] | select(.head_sha==$sha) | {id, run_number, path, status, conclusion}'
|
||||
# pick the id whose .path starts with docker-publish.yml, then:
|
||||
curl -sS -H "Authorization: token $GITEA_ACCESS_TOKEN" \
|
||||
"$GITEA_HOST/api/v1/repos/joakimp/pi-devbox/actions/runs/<id>/jobs" \
|
||||
| jq '.jobs[] | {name, status, conclusion}'
|
||||
```
|
||||
|
||||
## Conventions
|
||||
**Watcher config for this repo** (`ci-release-watcher` skill, hub-only shape —
|
||||
pi-devbox has no downstream host to deploy to):
|
||||
|
||||
- Do NOT call `mempalace-toolkit/install.sh` in the Dockerfile — the base entrypoint handles it
|
||||
- `NPM_CONFIG_PREFIX=/usr` must be set per-RUN for any build-time `npm install -g` to keep baked binaries off the volume-shadowed path
|
||||
- The smoke test threshold is 2200 MB — update if the image legitimately grows past it
|
||||
- **PI_VERSION must be passed explicitly by CI as a concrete version** (derived from the git tag), not left as the `latest` default. The Dockerfile's bare `npm install -g @earendil-works/pi-coding-agent` (without `@${PI_VERSION}`) produces an identical layer-hash across builds; combined with registry buildcache (`cache-from`/`cache-to`) the layer gets reused even when `latest` would have resolved to a newer pi version. **All releases v0.74.0 → v0.75.5 silently shipped the same image bytes** because of this (verified via `docker manifest inspect` — identical digests across both arches and all four tags). Fixed in v0.75.5b: workflow now derives `PI_VERSION` from `${{ github.ref_name }}` and passes it as a build-arg; smoke-test asserts the resulting `pi --version` matches via `EXPECTED_PI_VERSION` env var. Same latent bug exists in opencode-devbox's `with-pi` variants but is masked there because `OPENCODE_VERSION` bumps invalidate downstream layers — will only manifest when cutting a `vN.N.Nb`-style opencode-version-unchanged release that only bumps pi.
|
||||
- `EXPECT_WORKFLOW=docker-publish.yml` — the skill's `preflight_run()` aborts at
|
||||
startup if the run id belongs to lint instead.
|
||||
- `EXPECTED_FRESH_TAGS='vX.Y.Z latest vX.Y.Z-studio latest-studio'`
|
||||
- `EXPECTED_EXISTS_TAGS='base-latest'` — existence only: it is content-addressed
|
||||
and legitimately keeps its old timestamp when the base is a cache hit.
|
||||
- `CRITICAL_JOBS='build-variant build-variant-studio'` — job names are matched
|
||||
**exactly** (`critical.issubset(succeeded)`), so the studio variant must be
|
||||
listed explicitly; the skill's default omits it. Leave `promote-base-latest`
|
||||
out: it legitimately skips on a base cache hit, which would misclassify a good
|
||||
run. `update-description` is the cosmetic post-publish job.
|
||||
|
||||
## Documentation drift sweep
|
||||
## Cache-hit footgun (must-know)
|
||||
|
||||
Before committing any non-trivial change, check that prose still matches code. Drift hotspots in this repo:
|
||||
`PI_VERSION` defaults to `latest` in `Dockerfile.variant` but **CI must
|
||||
resolve it to a concrete version string** before passing as a build-arg.
|
||||
Otherwise the build-arg string is byte-identical across releases →
|
||||
identical layer hash → registry buildcache silently reuses the old
|
||||
layer. `resolve-versions` job in the workflow handles this.
|
||||
|
||||
- `README.md` — quick-start examples, env-var table, base-image reference (must match `FROM` in `Dockerfile`).
|
||||
- `AGENTS.md` (this file) — `Key facts` block (pi binary path, `NPM_CONFIG_PREFIX`, base-image tag), smoke-test threshold number.
|
||||
- `CHANGELOG.md` — promote `Unreleased` only on tag, but record post-release fixes in a fresh `Unreleased` block.
|
||||
- `DOCKER_HUB.md` — hand-maintained slim Hub description; sync anything user-facing that changes (env vars, run command, base image).
|
||||
- `.env.example` — hand-updated, must match Dockerfile/entrypoint env vars.
|
||||
- `Dockerfile` `PI_VERSION` ARG default — if you intend to pin (rather than `latest`), bump it on release.
|
||||
Discovered in pi-devbox 2026-05-23 (every release v0.74.0..v0.75.5
|
||||
shipped the same image bytes); preventatively fixed for `PI_VERSION` +
|
||||
`PI_FORK_REF` + `PI_OBSMEM_REF`.
|
||||
|
||||
Quick triage: `git diff --name-only HEAD | xargs -I{} grep -l 'thing-you-changed' README.md AGENTS.md DOCKER_HUB.md CHANGELOG.md .env.example`.
|
||||
## Smoke-test gate
|
||||
|
||||
`scripts/smoke-test.sh` runs amd64-only against a freshly-built variant
|
||||
image. Verifies binaries, repo clones, runtime deployment (waits for
|
||||
keybindings + mempalace bridge + ≥4 extensions before sampling — fixes
|
||||
the parallel-build-load race documented in opencode-devbox c6f9d11
|
||||
2026-06-08), build-time leftovers (see below), and image size threshold
|
||||
(3800 MB in `SIZE_THRESHOLD_MB`; revisit after a few releases as actuals
|
||||
settle — this doc said 3500 until 2026-09-11, after the bar had already
|
||||
moved twice).
|
||||
|
||||
If smoke fails on size threshold but build is otherwise fine: bump
|
||||
`SIZE_THRESHOLD_MB` in scripts/smoke-test.sh in a follow-up commit and
|
||||
re-run. The threshold exists to catch *runaway* growth (an accidental
|
||||
texlive bake-in, a forgotten chrome dependency), not to block ordinary
|
||||
upstream bumps.
|
||||
|
||||
**The size gate is not a substitute for naming the residue.** It carries
|
||||
~225 MB of deliberate margin, so v1.9.1 shipped +131 MB of pure build
|
||||
residue — 110 MB of it npm's own download cache under `/root/.npm`, the
|
||||
rest foreign platform packages — and stayed green. Four named assertions
|
||||
now cover that ground: no foreign npm-11 platform packages beyond the
|
||||
host arch (`@esbuild/*`, `@mariozechner/clipboard-*`), no `/root/.npm` in
|
||||
the image, and — because the prune's real risk is *removing something
|
||||
needed*, not size — esbuild must compile TS and clipboard must load its
|
||||
native binding at **every** install site.
|
||||
|
||||
Two failure shapes to copy from those, both of which bit here:
|
||||
- `test ! -d /root/.npm` on mode-700 `/root` passes for a **permission**
|
||||
error, so the cache assertion refuses to run as non-root. Watch for
|
||||
this in any assertion about a path you may not be allowed to read.
|
||||
- `node -e 'require("esbuild")'` resolves by walking up from the CURRENT
|
||||
DIRECTORY, so it fails with `MODULE_NOT_FOUND` from `/workspace` on a
|
||||
perfectly healthy image (esbuild is nested inside the pi trees;
|
||||
`NODE_PATH` is unset). Always path-qualify: `require("<abs>/esbuild")`.
|
||||
A runbook shipped the bare form with "if this fails, revert the
|
||||
release" attached, and it duly went red for the wrong reason.
|
||||
|
||||
## Build pipeline notes
|
||||
|
||||
- **Two-phase**: base + variant. Base is rebuilt only when
|
||||
`Dockerfile.base`, `rootfs/`, or `entrypoint*.sh` change (CI computes
|
||||
a content hash and probes Hub for an existing `base-<hash>` tag).
|
||||
- **`base-latest` alias** is promoted from `base-<hash>` via `crane copy`
|
||||
(manifest copy, no rebuild) only when the base actually changed.
|
||||
- **`docker buildx build --push` retry**: 3 attempts with backoff for
|
||||
transient Hub blips. Deterministic failures fail all 3 and the job
|
||||
fails as expected.
|
||||
- **Registry buildcache disabled**: buildkit's cache-export hits HTTP 400
|
||||
on Hub CDN since ~2026-05-23. Image push works fine; we pay the full
|
||||
base build on Dockerfile.base change, but base tags are content-
|
||||
addressed so unchanged bases short-circuit at the probe step.
|
||||
|
||||
## Decoupling history (briefly)
|
||||
|
||||
Pre-v1.0.0 pi-devbox was `FROM joakimp/pi-devbox:base-pi-only`, where
|
||||
`base-pi-only` was a tag built by **opencode-devbox CI** (with
|
||||
`INSTALL_OPENCODE=false` in their variant Dockerfile) and pushed under
|
||||
the pi-devbox repo as an internal building-block tag. This setup
|
||||
required rebuilding opencode-devbox before pi-devbox could be tagged
|
||||
and meant pi-devbox docs needed cross-referencing into opencode-devbox.
|
||||
|
||||
v1.0.0 brings pi install logic into this repo, drops the cross-repo
|
||||
dependency, and the `base-pi-only*` tags from opencode-devbox become
|
||||
deprecated artifacts (to be removed in opencode-devbox v2.0.0).
|
||||
|
||||
## What we DON'T install (and why)
|
||||
|
||||
- **No texlive** (~600 MB–1 GB). PDF export from pandoc / pi-studio works
|
||||
out of the box via **`typst`** (~30 MB static binary), which the base ships
|
||||
as the pandoc PDF engine (`pandoc --pdf-engine=typst`) — small enough to live
|
||||
in base rather than a dedicated `:latest-studio-tex` variant. We don't bake in
|
||||
a full TeX Live: it's heavy and typst covers the common Markdown→PDF case.
|
||||
Users needing LaTeX-exact output can install the higher-fidelity fallback on
|
||||
demand: `sudo apt-get install texlive-xetex texlive-latex-recommended` (then
|
||||
`pandoc --pdf-engine=xelatex`).
|
||||
- **pi-studio** ships in the `:latest-studio` variant (since v1.1.0),
|
||||
vendored to `/opt/pi-studio` and registered at container start via
|
||||
`pi install /opt/pi-studio` (see Dockerfile.variant `INSTALL_STUDIO`).
|
||||
The default `:latest` image stays studio-free. Note: pi-studio binds
|
||||
`127.0.0.1` inside the container, so browser access needs host
|
||||
networking or the bundled `studio-expose` bridge (socat; auto-starts
|
||||
when `STUDIO_EXPOSE=1`) — see README "Using pi-studio".
|
||||
- **No Julia/R/GHCi/Clojure runtimes**. Use `uv run --with X` for
|
||||
Python REPLs; `apt install` other-language runtimes ad-hoc per
|
||||
container if needed.
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
- The host `~/.mempalace` bind-mount path is unchanged.
|
||||
- Volume names (`devbox-pi-config`, `devbox-ssh-local`,
|
||||
`devbox-shell-history`, `devbox-zoxide`, `devbox-nvim-data`,
|
||||
`devbox-uv`; optional `devbox-palace`, `devbox-chroma-cache`) are
|
||||
unchanged.
|
||||
- `~/.pi/agent/` layout inside the container is unchanged; existing
|
||||
named volumes work without recreation.
|
||||
- The `:latest` and `vX.Y.Z` Hub tags continue to point at a "base + pi"
|
||||
image. Same tag, same shape, just built differently.
|
||||
|
||||
+5646
-3
File diff suppressed because it is too large
Load Diff
+94
-25
@@ -1,15 +1,21 @@
|
||||
# pi-devbox
|
||||
|
||||
A Docker container with [pi coding-agent](https://github.com/earendil-works/pi) pre-installed, built on top of [opencode-devbox](https://hub.docker.com/r/joakimp/opencode-devbox)'s base image. Pi gets a fully-loaded development environment in one `docker run`.
|
||||
A self-contained Docker container for the [pi coding-agent](https://github.com/earendil-works/pi) — pi + companion repos + MemPalace + a curated set of dev tooling, ready to run.
|
||||
|
||||
> **Current `:latest` ships pi `{{PI_VERSION}}`** (resolved at build time; see [Versioning](#versioning)).
|
||||
|
||||
## Image variants
|
||||
|
||||
| Tag | Size (compressed) | What you get |
|
||||
|---|---|---|
|
||||
| `joakimp/pi-devbox:latest` | ~700 MB | Pi + companion repos, on top of the opencode-devbox base |
|
||||
| `joakimp/pi-devbox:vX.Y.Z` | same | Pinned pi version (tracks the [pi npm package version](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)) |
|
||||
| Tag | Architectures | Size (compressed) | What you get |
|
||||
|---|---|---|---|
|
||||
| `joakimp/pi-devbox:latest` | amd64, arm64 | ~1.23 GB | Self-contained: base + pi `{{PI_VERSION}}` + companions |
|
||||
| `joakimp/pi-devbox:vX.Y.Z` | amd64, arm64 | same | Pinned semver release |
|
||||
| `joakimp/pi-devbox:latest-studio` | amd64, arm64 | ~1.25 GB | `latest` + [pi-studio](https://github.com/omaclaren/pi-studio): browser prompt editor, KaTeX/Mermaid preview, tmux-backed literate REPLs |
|
||||
| `joakimp/pi-devbox:vX.Y.Z-studio` | amd64, arm64 | same | Pinned semver studio release |
|
||||
| `joakimp/pi-devbox:base-latest` | amd64, arm64 | ~1.17 GB | Base layer alias (internal building block; pull `:latest` instead) |
|
||||
| `joakimp/pi-devbox:base-<hash>` | amd64, arm64 | ~1.17 GB | Content-addressed base; immutable. Stable parent for variant rebuilds. |
|
||||
|
||||
Multi-arch: `linux/amd64`, `linux/arm64`.
|
||||
> **pi-studio (`-studio` tags):** launch with `/studio --no-browser --port 8765` inside a pi session. The server binds `127.0.0.1` **inside the container**, so reach it via host networking or a loopback bridge (and `ssh -L` for a remote host; mosh needs a parallel `ssh -L`). Full recipe: [README → Using pi-studio](https://gitea.jordbo.se/joakimp/pi-devbox#using-pi-studio--studio-variant).
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -38,30 +44,91 @@ Full setup guide — authentication for each provider (Anthropic, OpenAI, Gemini
|
||||
|
||||
## What's inside
|
||||
|
||||
Inherited from [opencode-devbox base](https://hub.docker.com/r/joakimp/opencode-devbox):
|
||||
### pi and companions
|
||||
|
||||
- **Debian trixie** (latest stable)
|
||||
- **Node.js** (LTS), **uv** (Python tooling), **rustup** (Rust on-demand)
|
||||
- **AWS CLI v2** + AWS Bedrock-ready config
|
||||
- **MemPalace** + MCP server — persistent agent memory across sessions, queryable via `mempalace_*` tools inside pi
|
||||
- **Gitea MCP** server
|
||||
- **Dev tools**: neovim (LazyVim defaults), tmux, bat, eza, fzf, zoxide, ripgrep, git-lfs, make
|
||||
- **Shell**: bash with history tuning, prefix-search bindings, fzf/zoxide integration
|
||||
|
||||
Added by pi-devbox:
|
||||
|
||||
- **pi** ([`@earendil-works/pi-coding-agent`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)) — baked at `/usr/bin/pi`, version pinned at build time via the `PI_VERSION` build-arg
|
||||
- **pi `{{PI_VERSION}}`** ([`@earendil-works/pi-coding-agent`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)) — installed at `/usr/bin/pi`, pinned to an audited version (not npm `latest`)
|
||||
- **pi-atelier** — TUI sidebar (ordered panels, split-pane, themes), vendored at `/opt/pi-atelier` and pinned to an audited tag; the exact tag is in the image labels (`se.jordbo.pi-devbox.pi-atelier-version`) and `/etc/pi-devbox/build-manifest.json`
|
||||
- **[pi-toolkit](https://gitea.jordbo.se/joakimp/pi-toolkit)** — keybindings (mosh/tmux-friendly Shift+Enter, Ctrl+J, Alt+J newline bindings), AWS env loader, settings template
|
||||
- **[pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions)** — 7 user-facing extensions: `ext-toggle` (manage extensions interactively), `mcp-loader` (load MCP servers via settings.json), `todo`, `ssh-controlmaster`, `notify`, `git-checkpoint`, `confirm-destructive`
|
||||
- **mempalace bridge** — MCP extension auto-symlinked from `/opt/mempalace-toolkit` so pi can read/write the same palace as opencode
|
||||
- **[pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions)** — 7 user-facing extensions: `ext-toggle`, `mcp-loader`, `todo`, `ssh-controlmaster`, `notify`, `git-checkpoint`, `confirm-destructive`
|
||||
- **`fork`** ([pi-fork](https://github.com/elpapi42/pi-fork)) and **`recall`** ([pi-observational-memory](https://github.com/elpapi42/pi-observational-memory)) tools
|
||||
- **mempalace bridge** — MCP extension auto-symlinked so pi reads/writes the host-mounted palace
|
||||
- **image-baked agent skills** — skills under `/usr/local/share/pi-devbox/skills/` (e.g. `pi-devbox-environment`, which teaches agents the container's persistence/networking/DNS/tmux/REPL specifics) are symlinked into `~/.agents/skills/` on start, available with or without a mounted skillset repo
|
||||
|
||||
The entrypoint deploys all of these on first container start. Re-running is idempotent and preserves user edits.
|
||||
The entrypoint deploys/registers all of these on first container start. Re-running is idempotent and preserves user edits.
|
||||
|
||||
### MemPalace (persistent agent memory)
|
||||
|
||||
- **MemPalace** + MCP server — semantic search over conversation history, knowledge graph, diary; queryable via 29 `mempalace_*` tools inside pi
|
||||
- ChromaDB ONNX embedding model pre-warmed at build time (`all-MiniLM-L6-v2`)
|
||||
- Bind-mount your host's `~/.mempalace` and the host-pi and container-pi share one brain
|
||||
|
||||
### Document and image tooling
|
||||
|
||||
- **pandoc** — universal Markdown↔HTML/Org/RST/etc. conversion. Useful well beyond pi: agent-driven doc exports, format conversion, etc.
|
||||
- **Typst** — markup-based typesetting, used as pandoc's `--pdf-engine`
|
||||
- **graphviz** (`dot`) — diagram rendering pipelines
|
||||
- **imagemagick** (`magick`) — image conversion / resizing
|
||||
|
||||
### Browser automation
|
||||
|
||||
- **agent-browser** — CLI for driving a real browser (open pages, click/fill/`eval`, snapshot the DOM, screenshots) so agents can verify front-end work instead of guessing
|
||||
- **Playwright** + a headless **Chromium** are pre-installed and pinned together; `AGENT_BROWSER_EXECUTABLE_PATH` is preset to the baked browser, so `agent-browser open <url>` works out of the box with no setup
|
||||
- **socat** — TCP bridge used to expose the pi-studio server outside the container's loopback
|
||||
|
||||
### Modern CLI tooling
|
||||
|
||||
- **Editor**: neovim (system-wide `termguicolors` default; bring your own config/plugins), tmux (configured for 0-indexed sessions)
|
||||
- **Search/nav**: ripgrep, fd, fzf, zoxide
|
||||
- **Display**: bat, eza, htop, tree
|
||||
- **Data**: jq, yq
|
||||
- **Help**: tldr (tealdeer — Rust port; run `tldr --update` once to populate cache)
|
||||
- **Git**: git-lfs, git-crypt, gitleaks (for pre-commit secret scanning)
|
||||
- **Build**: gcc, g++, make, patch
|
||||
- **Misc**: gosu, age, rsync, less
|
||||
|
||||
### Language toolchains
|
||||
|
||||
- **Python**: system Python 3 + **uv** (preferred) for fast Python package management. Run any Python REPL/notebook stack on demand without bloating the image:
|
||||
```bash
|
||||
uv run --with ipython ipython
|
||||
uv run --with jupyterlab jupyter lab --no-browser --port 8888
|
||||
uv run --with marimo marimo edit
|
||||
```
|
||||
- **Node.js** v24 LTS + npm (used by pi itself)
|
||||
- **Rust** — `rustup-init` is on PATH; install toolchains on demand
|
||||
- **Go** — opt-in via `--build-arg INSTALL_GO=true` if rebuilding from source
|
||||
|
||||
### Cloud + secrets
|
||||
|
||||
- **AWS CLI v2** — for SSO + Bedrock auth (pi's preferred LLM provider for the maintainer's setup)
|
||||
- **Gitea MCP** server — for Gitea API access from inside pi
|
||||
- **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). A read-only `~/.ssh` carrying a per-host `ControlPath` (common CGNAT configs) is handled too — redirected to a writable socket dir for both `pi --ssh` and `dssh`/`dscp`.
|
||||
- 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 (`dssh <peer>` alias; `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER`).
|
||||
|
||||
## Versioning
|
||||
|
||||
Tags follow the pi npm version: `v0.74.0`, `v0.75.0`, etc. `latest` always points at the most recent release. When pi cuts a new upstream version, this image is rebuilt and re-tagged to match.
|
||||
From v1.0.0 onward, pi-devbox uses **semver**:
|
||||
|
||||
For container-level rebuilds on the same pi version (security updates, base bumps, fixes) the tag gets a letter suffix: `v0.74.0b`, `v0.74.0c`, …
|
||||
- **Major** — architectural changes. v1.0.0 is the first decoupled release, where pi-devbox got its own self-contained build chain (previously it was a thin re-brand of opencode-devbox's `pi-only` variant).
|
||||
- **Minor** — new image variants, significant base additions.
|
||||
- **Patch** — pi version bumps, smaller fixes.
|
||||
|
||||
The pi binary version inside any given release is shown in this description (currently **`{{PI_VERSION}}`** for `:latest`) and asserted by smoke tests to match what's documented — version drift is caught at CI time, not on user pull.
|
||||
|
||||
> **Pre-v1.0.0 history.** Tags v0.74.0…v0.79.0 followed the pi npm version directly (`v{pi_version}[letter]`). Those images remain on Hub but are deprecated in favor of `:latest` / `:v1.X.Y`. The legacy `:base-pi-only*` tags were CI artifacts of the old opencode-devbox-based build pipeline; they will be removed in a future opencode-devbox v2.0.0.
|
||||
|
||||
### Build pipeline
|
||||
|
||||
pi-devbox is built in two phases:
|
||||
|
||||
1. **Base** (`Dockerfile.base`) → `base-<hash>` tag, content-addressed over `Dockerfile.base` + `rootfs/` + `entrypoint*.sh`. Rebuilt only when those change.
|
||||
2. **Variant** (`Dockerfile.variant`) → `:latest` and `:vX.Y.Z`. FROMs the base, adds the pi install + companions.
|
||||
|
||||
`base-latest` is an alias of the most recent base.
|
||||
|
||||
## Persistent state
|
||||
|
||||
@@ -74,6 +141,7 @@ User edits and pi-installed packages survive container recreation when you mount
|
||||
| `devbox-zoxide` | `/home/developer/.local/share/zoxide` | zoxide directory jump database |
|
||||
| `devbox-nvim-data` | `/home/developer/.local/share/nvim` | neovim plugin & Mason package state |
|
||||
| `devbox-uv` | `/home/developer/.local/share/uv` | uv Python installs and tool cache |
|
||||
| `devbox-ssh-local` | `/home/developer/.ssh-local` | LAN-jump key (one-time host authorization survives recreate) |
|
||||
|
||||
Optional volumes for MemPalace (commented out by default — uncomment in `docker-compose.yml` to persist conversation memory across restarts):
|
||||
|
||||
@@ -89,11 +157,12 @@ Optional volumes for MemPalace (commented out by default — uncomment in `docke
|
||||
## Source
|
||||
|
||||
- **This image**: https://gitea.jordbo.se/joakimp/pi-devbox
|
||||
- **Base image**: https://gitea.jordbo.se/joakimp/opencode-devbox (Hub: `joakimp/opencode-devbox`)
|
||||
- **pi**: https://github.com/earendil-works/pi
|
||||
- **pi-toolkit**: https://gitea.jordbo.se/joakimp/pi-toolkit
|
||||
- **pi-extensions**: https://gitea.jordbo.se/joakimp/pi-extensions
|
||||
- **MemPalace**: https://github.com/MemPalace/mempalace
|
||||
|
||||
## License
|
||||
|
||||
MIT (the image; pi and the bundled tools each carry their own licenses).
|
||||
MIT (the image; pi and the bundled tools each carry their own licenses). See
|
||||
`LICENSE` and `THIRD_PARTY.md` in the [source repo](https://gitea.jordbo.se/joakimp/pi-devbox).
|
||||
|
||||
-62
@@ -1,62 +0,0 @@
|
||||
# pi-devbox — pi coding-agent container
|
||||
#
|
||||
# Builds on top of the opencode-devbox base image, which provides:
|
||||
# Debian trixie, Node.js, AWS CLI, mempalace + MCP server, gitea-mcp,
|
||||
# dev tools (neovim, tmux, bat, eza, fzf, zoxide, ripgrep, uv, rustup,
|
||||
# git-crypt, gitleaks),
|
||||
# user setup (developer/gosu), entrypoints, chromadb prewarm.
|
||||
#
|
||||
# This image adds only pi itself and its companion repos.
|
||||
#
|
||||
# Build args:
|
||||
# BASE_IMAGE — base image to build from (default: base-latest)
|
||||
# PI_VERSION — pi npm version: "latest" or a pinned version e.g. "0.74.0"
|
||||
# PI_TOOLKIT_REF — git ref for pi-toolkit (default: main)
|
||||
# PI_EXTENSIONS_REF — git ref for pi-extensions (default: main)
|
||||
|
||||
ARG BASE_IMAGE=joakimp/opencode-devbox:base-latest
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
# PI_VERSION should be passed explicitly by CI as a concrete version
|
||||
# (e.g. PI_VERSION=0.75.5, derived from the git tag). The default `latest`
|
||||
# is for local dev convenience only — it has a known cache-hit footgun
|
||||
# when used in registry-cached CI builds. See .gitea/workflows/docker-
|
||||
# publish.yml § "Resolve PI_VERSION from tag" and AGENTS.md gotcha for
|
||||
# the full story (silent same-bytes-across-releases regression discovered
|
||||
# 2026-05-23 affecting all builds v0.74.0..v0.75.5).
|
||||
ARG PI_VERSION=latest
|
||||
ARG PI_TOOLKIT_REF=main
|
||||
ARG PI_EXTENSIONS_REF=main
|
||||
|
||||
# Install pi and clone companion repos.
|
||||
# NPM_CONFIG_PREFIX is overridden to /usr so the baked binary lands at the
|
||||
# system prefix — same pattern as opencode-devbox's variant Dockerfile.
|
||||
# At runtime, NPM_CONFIG_PREFIX is reset to /home/developer/.pi/npm-global
|
||||
# (inherited from base ENV) so user-installed packages land on the named
|
||||
# volume and survive container recreate.
|
||||
#
|
||||
# git clone is wrapped in a retry loop because gitea.jordbo.se occasionally
|
||||
# returns transient HTTP 500s on the first request after idle.
|
||||
RUN set -e && \
|
||||
git_clone_retry() { \
|
||||
url="$1"; ref="$2"; dest="$3"; \
|
||||
for i in 1 2 3 4 5; do \
|
||||
if git clone --depth 1 --branch "$ref" "$url" "$dest"; then return 0; fi; \
|
||||
rm -rf "$dest"; \
|
||||
echo "git clone $url failed (attempt $i/5), retrying in $((i*5))s..."; \
|
||||
sleep $((i*5)); \
|
||||
done; \
|
||||
return 1; \
|
||||
} && \
|
||||
if [ "${PI_VERSION}" = "latest" ]; then \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \
|
||||
else \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
|
||||
fi && \
|
||||
pi --version && \
|
||||
git_clone_retry https://gitea.jordbo.se/joakimp/pi-toolkit.git "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \
|
||||
git_clone_retry https://gitea.jordbo.se/joakimp/pi-extensions.git "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
|
||||
echo "pi-toolkit at $(cd /opt/pi-toolkit && git rev-parse --short HEAD)" && \
|
||||
echo "pi-extensions at $(cd /opt/pi-extensions && git rev-parse --short HEAD)"
|
||||
|
||||
# WORKDIR / ENTRYPOINT / CMD inherited from base.
|
||||
+942
@@ -0,0 +1,942 @@
|
||||
# pi-devbox — base image (variant-independent layers)
|
||||
#
|
||||
# This Dockerfile produces an image tagged base-<hash>, used as the parent
|
||||
# for all published variants of pi-devbox. It contains everything that does
|
||||
# not depend on variant-specific build-args (the pi install moves to
|
||||
# Dockerfile.variant).
|
||||
#
|
||||
# The base is rebuilt only when this file or anything it COPYs in changes
|
||||
# (rootfs/, entrypoint*.sh). Version bumps to PI_VERSION etc. do NOT
|
||||
# trigger a base rebuild.
|
||||
#
|
||||
# To force a base rebuild for fresh apt packages without other code
|
||||
# changes, bump the BASE_REBUILD_DATE comment below. The hash is
|
||||
# content-addressed over this file, so any byte change invalidates the
|
||||
# cache. Recommended cadence: once per release for security updates.
|
||||
#
|
||||
# BASE_REBUILD_DATE: 2026-09-22 (v1.9.4 — mempalace 3.9.0 -> 3.10.0 with ENV MEMPALACE_CONFIG_DIR pinning the layout, mempalace-toolkit 2167a1b explicit event_list order; previous marker 2026-09-19 / v1.9.3)
|
||||
#
|
||||
# ── Lineage note ─────────────────────────────────────────────────────
|
||||
# Adapted from opencode-devbox/Dockerfile.base (commit before v1.16.2).
|
||||
# pi-devbox was previously a thin re-brand of opencode-devbox's pi-only
|
||||
# variant; this file is the start of an independent build chain. The
|
||||
# opencode-devbox install logic (INSTALL_OPENCODE, INSTALL_OMOS) does
|
||||
# not appear here. The base is otherwise broadly equivalent so generic
|
||||
# upstream improvements (CVE updates, new dev tooling) can be cherry-
|
||||
# picked between repos.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
ARG DEBIAN_VERSION=trixie-slim
|
||||
FROM debian:${DEBIAN_VERSION} AS base
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
LABEL maintainer="joakimp"
|
||||
LABEL description="pi-devbox — base image (variant-independent)"
|
||||
LABEL org.opencontainers.image.source="https://gitea.jordbo.se/joakimp/pi-devbox"
|
||||
|
||||
# Avoid interactive prompts during build
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# ── Core system packages ─────────────────────────────────────────────
|
||||
# apt-get upgrade picks up any security/CVE fixes published between
|
||||
# debian:trixie-slim base-image rebuilds. Paired with the index update
|
||||
# and the install in the same layer so we don't bloat image history.
|
||||
#
|
||||
# Additions vs the upstream opencode-devbox base (2026-06-09):
|
||||
# pandoc — Markdown↔HTML/PDF/etc. conversion. Required by pi-studio
|
||||
# preview/export pipelines and broadly useful for any
|
||||
# agent-driven document workflow. ~200 MB. NOTE: pandoc is
|
||||
# only the front-end — PDF output needs a back-end engine.
|
||||
# We ship `typst` (installed further down) as the
|
||||
# lightweight default engine (`pandoc --pdf-engine=typst`)
|
||||
# instead of a ~600 MB TeX Live install.
|
||||
# xz-utils — `xz` decompressor. tar shells out to it for `.tar.xz`
|
||||
# assets (typst ships .tar.xz). ~0.5 MB. Also generally
|
||||
# useful for extracting xz-compressed archives.
|
||||
# graphviz — `dot` rendering for many diagram tools. ~10 MB.
|
||||
# See the bundled `dot-watch` helper for live .dot -> PNG
|
||||
# re-render (handy with pi-studio's image preview).
|
||||
# imagemagick — image conversion / resizing for thumbnails, etc. ~50 MB.
|
||||
# (yq is NOT apt-installed: Debian's `yq` is the unrelated Python tool;
|
||||
# mikefarah's Go yq is installed as a pinned binary further down.)
|
||||
# socat — TCP relay. Powers `studio-expose`, which bridges
|
||||
# pi-studio's container-loopback server to the container's
|
||||
# external interface so a published port can reach it.
|
||||
# ~1 MB; generally useful for any port-forwarding need.
|
||||
# nano — small, non-modal terminal editor for users who don't want
|
||||
# a vi-based editor. ~2.8 MB installed; its deps (libc6,
|
||||
# libncursesw6, libtinfo6) are already pulled in by nvim/less/
|
||||
# htop/tmux, so it adds no extra packages. Companion to nvim
|
||||
# and the `micro` binary installed further down. EDITOR stays
|
||||
# nvim; users opt in via `export EDITOR=nano`.
|
||||
# kitty-terminfo — terminfo entry for the kitty terminal (TERM=xterm-kitty).
|
||||
# ~77 KB, terminfo file only (no kitty binary). Without it,
|
||||
# ncurses apps fall back and Neovim can't reliably detect
|
||||
# true-colour from kitty over ssh; installing it makes
|
||||
# TERM=xterm-kitty understood. Pairs with the system-wide
|
||||
# Neovim termguicolors default (etc/xdg/nvim/sysinit.vim).
|
||||
# ncurses-term — broad terminfo bundle (wezterm, alacritty, foot, st, the
|
||||
# base `ghostty` entry, and many more) so SSHing in from a
|
||||
# modern emulator resolves its TERM instead of degrading to a
|
||||
# dumb fallback. xterm-kitty is NOT in it (hence kitty-terminfo
|
||||
# above); TERM=xterm-ghostty is compiled from an alias further
|
||||
# down (ncurses ships `ghostty`, not `xterm-ghostty`). iTerm2
|
||||
# defaults to xterm-256color (ncurses-base), so needs nothing.
|
||||
# iproute2 — `ss` (socket statistics) and `ip`. Measured 2026-08-30 on
|
||||
# v1.8.11: NEITHER was present, so the container could not
|
||||
# answer "what is listening in here" by any means, and
|
||||
# cli_utils' `portcheck` was a hard stub — it prints
|
||||
# "portcheck requires at least one of: ss, lsof, netstat" and
|
||||
# all three were absent. `ss` satisfies its preferred branch
|
||||
# (`ss -tlnp`), which is also the branch that reports the
|
||||
# owning PID, so nothing further is needed: net-tools is
|
||||
# deliberately NOT added (`netstat` is deprecated and only a
|
||||
# fallback branch) and neither is lsof (~500 KB for a third
|
||||
# path to the same answer). ~5.5 MB total: iproute2 itself is
|
||||
# 4.2 MB and pulls 6 libs under --no-install-recommends
|
||||
# (libbpf1, libmnl0, libtirpc-common, libtirpc3t64,
|
||||
# libxtables12, libcap2-bin — libpam-cap is a Recommends and
|
||||
# is correctly dropped). Verified end-to-end in a live
|
||||
# container: `ss` lands at /usr/bin/ss, `ip` at /usr/sbin/ip
|
||||
# (both already on the developer PATH), and `portcheck --all`
|
||||
# then correctly identifies the socat listener on 8765.
|
||||
# shellcheck — shell linter. Added 2026-09-09 to close a CAPABILITY gap, not
|
||||
# a style preference. `scripts/lint-shell.sh` is the release
|
||||
# GATE (the `lint-gate` job that `resolve-versions` depends
|
||||
# on), and it correctly refuses to pass when shellcheck is
|
||||
# missing — "a gate that cannot run must not pass". Measured on
|
||||
# v1.8.14: shellcheck was absent from this image by all three
|
||||
# routes (PATH, dpkg, filesystem), so `bash
|
||||
# scripts/lint-shell.sh` exited 2 in EVERY devbox container and
|
||||
# no developer could run the release gate locally at all. The
|
||||
# loop was therefore write-shell → push → wait for CI → discover,
|
||||
# which is the loop the gate was added to shorten: v1.8.14's
|
||||
# first attempt burned ~46 min on a tree whose lint had already
|
||||
# been red for 24 h. This is also what makes a client-side
|
||||
# pre-push hook possible (see hooks/pre-push); without the
|
||||
# binary that hook would refuse every push. ~39 MB installed
|
||||
# (Installed-Size 40112 KB, shellcheck 0.10.0-1) and measured
|
||||
# to pull ZERO additional packages under
|
||||
# --no-install-recommends: its deps (libc6, libffi8, libgmp10)
|
||||
# are already present. NOTE this file feeds the base-decide
|
||||
# hash (Dockerfile.base + rootfs/), so adding it forces one
|
||||
# full base rebuild.
|
||||
# bind9-dnsutils — `dig` and `nslookup`. Added 2026-09-10 to close a
|
||||
# DIAGNOSTIC gap measured during the gitea.egl.lan/FreeIPA
|
||||
# work: the container could resolve names but had NO way to
|
||||
# ask a SPECIFIC nameserver anything. `getent hosts` only
|
||||
# follows the resolver's default path, so the whole "gateway
|
||||
# 172.16.88.1 returns NXDOMAIN for the egl.lan zone while
|
||||
# 10.20.253.1 is authoritative for it" diagnosis had to be
|
||||
# hand-rolled in python3 — dig, host AND nslookup were all
|
||||
# absent. `dig @10.20.253.1 freeipa-4.egl.lan` is the
|
||||
# one-liner that replaces it, and split-horizon DNS is a
|
||||
# recurring class of bug on this fleet, not a one-off. NOTE
|
||||
# the package to name is bind9-dnsutils: plain `dnsutils` is
|
||||
# a transitional package in trixie. ~6.1 MB total (6210 KB
|
||||
# measured): bind9-dnsutils 721 KB + bind9-host 161 KB +
|
||||
# bind9-libs 3804 KB plus 7 small libs (libfstrm0,
|
||||
# libjson-c5, liblmdb0, libmaxminddb0, libprotobuf-c1,
|
||||
# liburcu8t64, libuv1t64) under --no-install-recommends.
|
||||
# ldap-utils — `ldapsearch`/`ldapmodify`. Added 2026-09-10. This fleet
|
||||
# authenticates against FreeIPA (EGL.LAN), and every LDAP
|
||||
# probe during the Gitea auth work had to be run by SSHing to
|
||||
# an already-enrolled host because the container had no LDAP
|
||||
# client at all. 1244 KB and pulls NOTHING extra under
|
||||
# --no-install-recommends — its deps (libldap, libsasl2) are
|
||||
# already present. CAVEAT: this gives SIMPLE binds only,
|
||||
# which is what Gitea itself uses and what most probes need.
|
||||
# GSSAPI binds (`ldapsearch -Y GSSAPI`) additionally require
|
||||
# krb5-user + libsasl2-modules-gssapi-mit, deliberately NOT
|
||||
# added here — that is a Kerberos-client decision with
|
||||
# /etc/krb5.conf implications, not just a tool.
|
||||
# xxd — hex dump. 198 KB, no extra deps. Convenience, and honestly
|
||||
# marginal: `od -c` from coreutils is always present and does
|
||||
# the same job. Earned its place because verifying that
|
||||
# git-crypt actually encrypted a staged blob (the \0GITCRYPT\0
|
||||
# magic) is a recurring check in myconfigs and xxd is the
|
||||
# muscle-memory command for it.
|
||||
# NOT added — netcat-openbsd (133 KB): measured redundant on
|
||||
# 2026-09-10, because socat is already baked above AND bash's
|
||||
# /dev/tcp does reachability checks with zero packages
|
||||
# (verified against gitea.egl.lan:3000). Recorded here so the
|
||||
# omission reads as a decision rather than an oversight.
|
||||
# python3-yaml — PyYAML. Added 2026-09-10 for precisely the same reason as
|
||||
# shellcheck above: a gate this repo ALREADY OWNS could not be
|
||||
# run locally by anyone. scripts/check-workflow-shell.sh — the
|
||||
# guard that catches the "bash-only syntax under Gitea's default
|
||||
# sh/dash shell" footgun that broke resolve-versions (ed49b8d)
|
||||
# and promote-base-latest (b7197e8) — hard-exits with "ERROR:
|
||||
# python3 yaml module missing" without it. lint.yml installs it
|
||||
# explicitly in CI (`shellcheck python3-yaml`), which is itself
|
||||
# the evidence that the image lacked it. Measured 2026-09-10
|
||||
# while wiring the skill-floor job: the guard could not be run
|
||||
# before pushing — the same write → push → wait-for-CI loop that
|
||||
# shellcheck was baked to shorten. 552 KB, and pulls ZERO extra
|
||||
# packages under --no-install-recommends.
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y --no-install-recommends && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
wget \
|
||||
git \
|
||||
openssh-client \
|
||||
gnupg \
|
||||
jq \
|
||||
ripgrep \
|
||||
fd-find \
|
||||
tree \
|
||||
less \
|
||||
htop \
|
||||
tmux \
|
||||
make \
|
||||
patch \
|
||||
diffutils \
|
||||
shellcheck \
|
||||
git-crypt \
|
||||
age \
|
||||
file \
|
||||
sudo \
|
||||
locales \
|
||||
procps \
|
||||
unzip \
|
||||
gcc \
|
||||
g++ \
|
||||
rsync \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
pandoc \
|
||||
xz-utils \
|
||||
graphviz \
|
||||
imagemagick \
|
||||
socat \
|
||||
nano \
|
||||
kitty-terminfo \
|
||||
ncurses-term \
|
||||
iproute2 \
|
||||
bind9-dnsutils \
|
||||
ldap-utils \
|
||||
xxd \
|
||||
python3-yaml \
|
||||
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── tmux defaults: 0-indexed windows and panes ───────────────────────
|
||||
# pi-studio (omaclaren/pi-studio) hard-codes its tmux send target to
|
||||
# `<session>:0.0`. Containers that ship tmux with default options are
|
||||
# already 0-indexed; this file makes the assumption explicit so future
|
||||
# /etc/tmux.conf consumers can read it. Users can override per-user
|
||||
# in ~/.tmux.conf if they want 1-indexing — pi-studio will then fail
|
||||
# to find its REPL session.
|
||||
RUN printf '%s\n' \
|
||||
'# pi-devbox baked default — see Dockerfile.base.' \
|
||||
'# pi-studio targets tmux session :0.0; do not change these here.' \
|
||||
'set -g base-index 0' \
|
||||
'set -g pane-base-index 0' \
|
||||
> /etc/tmux.conf
|
||||
|
||||
# ── SSH client defaults: ControlMaster on a writable socket path ──────
|
||||
# Why this exists: the devbox typically mounts ~/.ssh from the host as
|
||||
# read-only (security: keys are readable, but agents can't tamper with
|
||||
# config / known_hosts / authorized_keys / plant a malicious ProxyCommand).
|
||||
# OpenSSH's default ControlPath is ~/.ssh/cm/... which is unwritable on
|
||||
# such mounts, so any attempt to use ControlMaster fails. Symptoms:
|
||||
# unix_listener: cannot bind to path /home/.../.ssh/cm/...: Read-only file system
|
||||
# kex_exchange_identification: Connection closed by remote host
|
||||
# The latter manifests downstream of CGNAT per-destination flow caps
|
||||
# (~4 concurrent flows on most European residential ISPs) which silently
|
||||
# drop further SYNs once exceeded — making fresh ssh attempts fail with
|
||||
# banner-exchange timeouts that look like a remote problem.
|
||||
#
|
||||
# Fix: set a system-wide default ControlPath in /tmp (per-container,
|
||||
# tmpfs-friendly, always writable) so multiplexing Just Works without
|
||||
# touching the read-only ~/.ssh mount. Per-host overrides in user's
|
||||
# ~/.ssh/config still win — Debian's default /etc/ssh/ssh_config has
|
||||
# `Include /etc/ssh/ssh_config.d/*.conf` *before* the `Host *` block,
|
||||
# so user config can override these defaults if desired.
|
||||
#
|
||||
# CAVEAT (and why it is handled elsewhere): a user per-host override that
|
||||
# points ControlPath BACK under the read-only ~/.ssh (e.g. the common CGNAT
|
||||
# idiom `ControlPath ~/.ssh/cm/%r@%h:%p`) re-introduces the unwritable-socket
|
||||
# failure — a system drop-in here can never override a user's per-host value.
|
||||
# For `pi --ssh`, the ssh-controlmaster extension handles this by detecting an
|
||||
# unwritable system ControlPath and falling back to its own /tmp master; for
|
||||
# `ssh -F ~/.ssh-local/config` (dssh/dscp), setup-lan-access.sh redirects
|
||||
# ControlPath into the writable ~/.ssh-local. See CHANGELOG "Unreleased".
|
||||
#
|
||||
# ControlPersist=10m means the master socket sticks around 10 min after
|
||||
# the last session closes, so consecutive ssh calls in a workflow reuse
|
||||
# the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm
|
||||
# (mode 700) on each container start.
|
||||
RUN mkdir -p /etc/ssh/ssh_config.d && \
|
||||
printf '%s\n' \
|
||||
'# Devbox-baked default. See Dockerfile.base "SSH client defaults".' \
|
||||
'# Override per-host in ~/.ssh/config if the master socket location' \
|
||||
'# needs to differ.' \
|
||||
'Host *' \
|
||||
' ControlMaster auto' \
|
||||
' ControlPath /tmp/sshcm/%r@%h:%p' \
|
||||
' ControlPersist 10m' \
|
||||
' ServerAliveInterval 30' \
|
||||
' ServerAliveCountMax 6' \
|
||||
> /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf && \
|
||||
chmod 644 /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf
|
||||
|
||||
# ── Go-compiled tools (install from GitHub to avoid CVEs in Debian's old Go builds)
|
||||
#
|
||||
# Version policy: default is `latest` — resolved at build time by
|
||||
# following the /releases/latest redirect and reading the tag from the
|
||||
# Location header. Every base rebuild picks up the newest upstream
|
||||
# release. Explicit pins still work via build-args (e.g.
|
||||
# --build-arg GOSU_VERSION=1.19).
|
||||
|
||||
# gosu — privilege de-escalation
|
||||
ARG GOSU_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${GOSU_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing gosu ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/download/${V}/gosu-${ARCH}" -o /usr/local/bin/gosu && \
|
||||
chmod +x /usr/local/bin/gosu && \
|
||||
gosu --version
|
||||
|
||||
# fzf — fuzzy finder
|
||||
ARG FZF_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${FZF_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing fzf ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/download/v${V}/fzf-${V}-linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin fzf && \
|
||||
fzf --version
|
||||
|
||||
# git-lfs
|
||||
ARG GIT_LFS_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${GIT_LFS_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing git-lfs ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/download/v${V}/git-lfs-linux-${ARCH}-v${V}.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/git-lfs-${V}/git-lfs /usr/local/bin/git-lfs && \
|
||||
rm -rf /tmp/git-lfs-${V} && \
|
||||
git lfs install --system && \
|
||||
git-lfs --version
|
||||
|
||||
# gitleaks
|
||||
ARG GITLEAKS_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x64" ;; arm64) echo "arm64" ;; *) echo "x64" ;; esac) && \
|
||||
V="${GITLEAKS_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing gitleaks ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/download/v${V}/gitleaks_${V}_linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin gitleaks && \
|
||||
chmod +x /usr/local/bin/gitleaks && \
|
||||
gitleaks version
|
||||
|
||||
# neovim
|
||||
ARG NVIM_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${NVIM_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing neovim ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/download/v${V}/nvim-linux-${ARCH}.tar.gz" | tar -xz -C /opt && \
|
||||
ln -s /opt/nvim-linux-${ARCH}/bin/nvim /usr/local/bin/nvim && \
|
||||
nvim --version | head -1
|
||||
|
||||
# micro — modern, non-modal terminal editor. Ships alongside nvim so users
|
||||
# who aren't comfortable with vi-style modal editing have a friendly option:
|
||||
# desktop-style keybindings (Ctrl+S save, Ctrl+Q quit, Ctrl+C/V/X, Ctrl+Z
|
||||
# undo), mouse support, and syntax highlighting out of the box. A single
|
||||
# static Go binary (~12 MB) installed from GitHub releases, exactly like
|
||||
# bat/eza/zoxide below. EDITOR stays nvim (see below); users opt in with
|
||||
# `export EDITOR=micro` or `git config --global core.editor micro`.
|
||||
#
|
||||
# NOTE: upstream moved zyedidia/micro -> micro-editor/micro. The old org URL
|
||||
# still 302s, but its /releases/latest redirect lands on ANOTHER /latest URL
|
||||
# (the org rename), so the tag-parsing idiom below would resolve "latest"
|
||||
# instead of a version. Use the canonical micro-editor/micro URL.
|
||||
# Arch asset naming differs from the others: amd64 -> linux64, arm64 ->
|
||||
# linux-arm64. The tarball extracts to micro-<version>/micro.
|
||||
ARG MICRO_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "linux64" ;; arm64) echo "linux-arm64" ;; *) echo "linux64" ;; esac) && \
|
||||
V="${MICRO_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/micro-editor/micro/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing micro ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/micro-editor/micro/releases/download/v${V}/micro-${V}-${ARCH}.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/micro-${V}/micro /usr/local/bin/micro && \
|
||||
rm -rf /tmp/micro-${V} && \
|
||||
micro --version
|
||||
|
||||
# bat
|
||||
ARG BAT_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${BAT_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing bat ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/download/v${V}/bat-v${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/bat-v${V}-${ARCH}-unknown-linux-musl/bat /usr/local/bin/bat && \
|
||||
rm -rf /tmp/bat-v${V}-* && \
|
||||
bat --version
|
||||
|
||||
# eza
|
||||
ARG EZA_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${EZA_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing eza ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/download/v${V}/eza_${ARCH}-unknown-linux-gnu.tar.gz" | tar -xz -C /usr/local/bin && \
|
||||
eza --version | head -1
|
||||
|
||||
# zoxide
|
||||
ARG ZOXIDE_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${ZOXIDE_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing zoxide ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/download/v${V}/zoxide-${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /usr/local/bin zoxide && \
|
||||
zoxide --version
|
||||
|
||||
# uv — fast Python package manager. Note: uv tags don't prefix with "v".
|
||||
ARG UV_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${UV_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing uv ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/download/${V}/uv-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/uv-${ARCH}-unknown-linux-musl/uv /usr/local/bin/uv && \
|
||||
install /tmp/uv-${ARCH}-unknown-linux-musl/uvx /usr/local/bin/uvx && \
|
||||
rm -rf /tmp/uv-* && \
|
||||
uv --version
|
||||
|
||||
# ── MemPalace — local-first AI memory system ─────────────────────────
|
||||
# Provides semantic search over conversation history via 29 MCP tools.
|
||||
# Always installed in the base. Set INSTALL_MEMPALACE=false at base-build
|
||||
# time to shave ~300 MB.
|
||||
#
|
||||
# Stall protection (fixed 2026-06-13; self-heal added 2026-06-25):
|
||||
# mempalace-mcp is launched by the `mempalace.ts` pi extension from
|
||||
# mempalace-toolkit (cloned below). That extension applies a per-REQUEST
|
||||
# timeout in its JSON-RPC client and kills the child on stall, so a virtiofs
|
||||
# cold-open of chroma.sqlite3 / HNSW load can no longer hang the pi TUI
|
||||
# uninterruptibly. A stall-kill is no longer a permanent latch either: the
|
||||
# next tool call respawns the server with capped exponential backoff (the
|
||||
# budget resets on any successful response). Tunables:
|
||||
# MEMPALACE_MCP_TIMEOUT_MS (default 60000; the feed's `mempalace_mine` carries
|
||||
# its own longer MEMPALACE_FEED_MINE_TIMEOUT_MS, default 300000, since toolkit
|
||||
# 817b3a8 — before that the 60 s deadline cut every honest mine off),
|
||||
# MEMPALACE_MCP_INIT_TIMEOUT_MS
|
||||
# (default 300000 — generous so a genuine first cold-open isn't killed),
|
||||
# MEMPALACE_MCP_MAX_RESPAWNS (default 2; 0 disables self-heal),
|
||||
# MEMPALACE_MCP_RESPAWN_BACKOFF_MS (default 1000); timeouts of 0 disable.
|
||||
# Defaults live in the extension, so no ENV is needed here. A standalone
|
||||
# stdio-watchdog shim is NOT needed — the extension already owns
|
||||
# request/response correlation. See CHANGELOG.md "Unreleased > Fixed".
|
||||
ARG INSTALL_MEMPALACE=true
|
||||
# Pin to a known-good version. Bump deliberately, not implicitly: an
|
||||
# unpinned install silently swept in mempalace 3.3.x/3.4.0 with a broken
|
||||
# diary_write schema. Pinning makes mempalace upgrades a reviewable diff
|
||||
# rather than a surprise.
|
||||
#
|
||||
# 3.5.0 (2026-06) shipped the upstream fix for the top-level-anyOf diary_write
|
||||
# schema (issue #1728 / PR #1717, merged 2026-06-14): the advertised schema
|
||||
# is now `"required": ["agent_name"]` with entry/content enforced at dispatch,
|
||||
# which Anthropic's tools API accepts — so the old mcp_server.py perl
|
||||
# workaround that used to live below is gone.
|
||||
#
|
||||
# 3.6.0 (2026-07-17, PyPI latest) is additive/reliability only — secure
|
||||
# `mempalace serve` remote mode, optional Milvus backend, atomic KG
|
||||
# supersede(), conversation chronology, mining exclusions, plus recovery and
|
||||
# locking fixes. Reviewed for MCP tool-schema changes before bumping (that
|
||||
# being the exact regression class this pin exists to catch): there are NONE,
|
||||
# and nothing touches diary_write. Two fixes matter for how this image uses
|
||||
# mempalace: read-only mode now covers checkpoint + delete_by_source in
|
||||
# _MUTATING_TOOLS (#1930), and agent attribution is preserved in
|
||||
# mempalace_checkpoint (#2023/#2034).
|
||||
#
|
||||
# Keep in lockstep with opencode-devbox when bumping.
|
||||
#
|
||||
# 3.7.1 (from 3.6.0) is safe for anyone with an EXISTING LOCAL palace: verified
|
||||
# against the 3.7.1 source, not the changelog. Legacy drawers lack the new
|
||||
# `chunk_total` marker and both decision sites trust them ("trust the match as
|
||||
# before"), NORMALIZE_VERSION is 2 in both, chromadb stays <2 (no index-format
|
||||
# migration), there is no auto-migration ("We do NOT auto-migrate"), and the one
|
||||
# new palace file (logstream.sqlite3) is created lazily on first logstream use.
|
||||
# Two behaviour changes to know: MEMPALACE_MCP_ALLOW_PEER_WRITER no longer works
|
||||
# on local/chroma palaces, and writer-lock setup failures now fail CLOSED
|
||||
# (refuse the write) rather than fail open. Neither affects the container's
|
||||
# normal MCP-server-plus-CLI-feeder pattern, which already serialised on the
|
||||
# same lock under 3.6.0.
|
||||
#
|
||||
# 3.8.0 (2026-08-23, PyPI, released hours after this project's own v1.8.5 tag
|
||||
# the same day) is additive/reliability only — reviewed for MCP tool-schema
|
||||
# changes before bumping, as always: there are NONE. Two PRs matter:
|
||||
# - PR #2320/#2322: `sync --apply` no longer deletes a drawer solely because
|
||||
# its source_file was unreachable AT THAT MOMENT — it now asks for
|
||||
# corroboration first. This fixes losing a whole mined project to one
|
||||
# `sync --apply` while its volume happened to be unmounted.
|
||||
# IMPORTANT — do not over-read this fix: it addresses TRANSIENT
|
||||
# unreachability, not the standing landmine (documented in the operator's
|
||||
# global AGENTS.md) against running `mempalace_sync` / `mempalace_delete_by_source`
|
||||
# beyond dry-run on the SHARED central palace. On that palace most
|
||||
# source_file paths are PERMANENTLY absent from whichever host runs the
|
||||
# sync — a different machine's paths simply do not exist here, ever, not
|
||||
# merely "right now". That is a different failure shape than #2320/#2322
|
||||
# fixes. The landmine still stands; this bump does not relax it.
|
||||
# - PR #2307: long-running Chroma servers no longer invalidate their own
|
||||
# HNSW cache on their own writes (server-side perf fix). This does NOT
|
||||
# make `mempalace_reconnect` unnecessary — that tool exists for EXTERNAL
|
||||
# writes bypassing the in-process client (e.g. direct sqlite backfills,
|
||||
# CLI commands against a running server), a different scenario #2307
|
||||
# does not touch.
|
||||
#
|
||||
# CI-side audit (added after v1.8.6, closing that release's "Still open" item):
|
||||
# resolve-versions now treats this pin exactly as it treats PI_VERSION — it
|
||||
# reads the ARG from THIS file, refuses a non-concrete value, verifies the
|
||||
# version is published on PyPI, refuses a YANKED release (an exact pin installs
|
||||
# one silently under PEP 592), and WARNS — never silently adopts — when PyPI has
|
||||
# a newer release. smoke-test.sh then asserts the installed core equals that
|
||||
# audited pin, which catches a stale cached base layer that no manifest-internal
|
||||
# check can see. So a bump here is now gated end to end; what remains manual is
|
||||
# the JUDGEMENT above (MCP schema review, server/client sequencing), which is
|
||||
# the part that should stay manual.
|
||||
#
|
||||
# Deployment sequencing note for whoever ships this bump: synlig (the shared
|
||||
# central palace host) serves mempalace SERVER-SIDE as a `uv tool` install run
|
||||
# by the systemd unit `mempalace-serve.service` (`python -m mempalace.mcp_server
|
||||
# --transport http`), NOT via docker-compose.mempalace.yml — that compose file
|
||||
# exists in this repo but is not what runs there. (Measured 2026-09-22 over
|
||||
# ssh: `uv tool list` -> mempalace v3.9.0, python 3.12.13, chromadb 1.5.9;
|
||||
# `docker ps` matched no palace container. This comment previously said the
|
||||
# compose stack served 3.8.0, which was stale on both counts.) Bumping this ARG
|
||||
# changes only the CLIENT version baked into pi-devbox images: it introduces
|
||||
# client/server skew until synlig's tool is upgraded (`uv tool upgrade
|
||||
# mempalace` + restart the unit). Not something to code around here — just
|
||||
# sequence the upgrade. And note which side OWNS what: MCP tool semantics
|
||||
# (event_list ordering, kg_timeline pagination, search result fields) come
|
||||
# from the SERVER the extension talks to over MEMPALACE_REMOTE_URL, so they
|
||||
# change when synlig upgrades; only the local CLI (`mempalace init` at first
|
||||
# run, the mempalace-pi-session feeder) and the on-disk layout under
|
||||
# ~/.mempalace change when THIS pin does.
|
||||
#
|
||||
# v1.8.13: 3.8.0 -> 3.9.0. Audited: no Breaking/Removed changelog headings.
|
||||
# Adopted mainly for #2281 (`mempalace_mine` accepts a single conversation
|
||||
# file again) — though note that does NOT unblock this image's own feeder,
|
||||
# which was measured to mine DIRECTORIES, not files, so it was never hitting
|
||||
# that bug. Four behaviour changes ride along and are skew-relevant while
|
||||
# synlig stays on 3.8.0: hub-forward escaping, an HTTP lock split, similarity
|
||||
# score semantics, and parsed-output compatibility. 3.9.0-only features
|
||||
# (release awareness, `task create`/`task launch` MCP tools) are SERVER-side,
|
||||
# so they stay dark until synlig is redeployed — a client bump alone cannot
|
||||
# light them up.
|
||||
#
|
||||
# v1.9.4: 3.9.0 -> 3.10.0 (PyPI 2026-09-15). Deferred at v1.9.3 for two
|
||||
# "Upgrade notes" items; both re-measured against the 3.10.0 wheel, one needed
|
||||
# an adaptation:
|
||||
# - "New installs keep config and palace under ~/.config/mempalace". The
|
||||
# resolution order is $MEMPALACE_CONFIG_DIR, then ~/.mempalace IF it holds
|
||||
# config.json / people_map.json / palace/chroma.sqlite3, then XDG. An
|
||||
# EMPTY ~/.mempalace does not count — and an empty ~/.mempalace is exactly
|
||||
# what a freshly mounted devbox-palace volume (or entrypoint.sh's mkdir on
|
||||
# a volume-less container) looks like at first boot. Measured with a fresh
|
||||
# $HOME: `mempalace init` wrote to ~/.config/mempalace, outside the
|
||||
# persisted path, and entrypoint-user.sh's first-run test
|
||||
# `[ ! -d ~/.mempalace/palace ]` would stay true on every start. With
|
||||
# MEMPALACE_CONFIG_DIR set, everything landed in ~/.mempalace. Hence the
|
||||
# ENV MEMPALACE_CONFIG_DIR below (in the non-root-user section, where
|
||||
# ${USER_NAME} is in scope): first in the resolution order, so the
|
||||
# heuristic never runs and the image's layout contract no longer depends
|
||||
# on it. Existing volumes were safe either way (config.json is a legacy
|
||||
# marker); the ENV is for first boots. palace_path still defaults to
|
||||
# <config_dir>/palace and MEMPALACE_PALACE_PATH is still honoured (config.py
|
||||
# :927), so scripts/smoke-test.sh's stage-path test keeps its meaning.
|
||||
# - "MCP event listing returns the newest events first when no cursor is
|
||||
# given". SERVER-side (see above), so it lands when synlig upgrades, not
|
||||
# here. mempalace-toolkit 2167a1b made every cursor-less event_list call
|
||||
# in the pi extension say `order: "desc"` explicitly, so the mailbox reads
|
||||
# the same window against either server version.
|
||||
# Also in the notes, neither reaching this image: `mempalace rules` dropped
|
||||
# `--agent` (no caller in pi-devbox, mempalace-toolkit, skillset or myconfigs);
|
||||
# `get_collection()` refuses unknown collection names (library callers only).
|
||||
# MCP tool-schema review, as always: no tool removed or renamed; additive
|
||||
# fields on search results (filed_at / content_date provenance), `limit` /
|
||||
# `offset` on kg_timeline, `last_modified` on drawers. Skew while synlig stays
|
||||
# on 3.9.0 is narrower than it looks: the pi extension speaks HTTP to the hub
|
||||
# (no local mempalace-mcp is spawned), and the feeder in remote mode stages
|
||||
# locally in python, rsyncs, and calls the hub's own `mempalace_mine` tool.
|
||||
# Measured 2026-09-22 with a PATH shim in front of `mempalace`: a 47-session
|
||||
# `mempalace-pi-session --dry-run` made ZERO local CLI calls (the shim's
|
||||
# positive control logged one). So 3.10.0's new CLI write-routing policy never
|
||||
# runs against the hub from this image; the client pin touches first-run
|
||||
# `mempalace init` and the on-disk layout, nothing else in remote mode.
|
||||
ARG MEMPALACE_VERSION=3.10.0
|
||||
# Recorded as a label HERE, not in Dockerfile.variant, for three reasons: the
|
||||
# value lives next to the ARG that defines it (a second copy in the variant
|
||||
# would be one more pin able to drift, which is the class check-doc-drift.sh
|
||||
# exists to catch); labels are inherited by every image built FROM this one, so
|
||||
# both variants carry it with no build-arg to plumb through four call sites;
|
||||
# and inheritance means the label states the pin of the base the variant
|
||||
# ACTUALLY built on — which is the question when base-decide cache-hits an
|
||||
# older base. Like every se.jordbo.pi-devbox.* label this records INTENT; the
|
||||
# ground truth is /etc/pi-devbox/build-manifest.json's mempalace_version, read
|
||||
# from the installed binary, and scripts/smoke-test.sh asserts the two agree.
|
||||
# check-doc-drift.sh check 9 reads this off the last published image so that a
|
||||
# pin bump must be named in the CHANGELOG — until this label ships, that
|
||||
# component reports SKIP (label absent on the published release), not OK.
|
||||
LABEL se.jordbo.pi-devbox.mempalace-version="${MEMPALACE_VERSION}"
|
||||
ENV UV_TOOL_DIR=/opt/uv-tools
|
||||
ENV UV_TOOL_BIN_DIR=/usr/local/bin
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
mkdir -p /opt/uv-tools && \
|
||||
uv tool install --no-cache "mempalace==${MEMPALACE_VERSION}" && \
|
||||
/opt/uv-tools/mempalace/bin/python -c "import mempalace; print('mempalace', mempalace.__version__ if hasattr(mempalace, '__version__') else 'installed')" ; \
|
||||
fi
|
||||
|
||||
# (The mempalace diary_write top-level-anyOf workaround that patched
|
||||
# mcp_server.py here was removed in v1.2.2 — fixed upstream in mempalace
|
||||
# 3.5.0 via issue #1728 / PR #1717 (merged 2026-06-14). See CHANGELOG.md.)
|
||||
|
||||
# ── mempalace-toolkit — bash wrappers for session/docs mining ────────
|
||||
ARG INSTALL_MEMPALACE_TOOLKIT=true
|
||||
ARG MEMPALACE_TOOLKIT_REF=main
|
||||
# MEMPALACE_TOOLKIT_REPO defaults to the canonical gitea origin but is
|
||||
# overridable so a relocated/forked build can clone from a mirror or a
|
||||
# different host without editing this Dockerfile (mirrors the
|
||||
# PI_FORK_REPO / PI_OBSMEM_REPO / PI_STUDIO_REPO pattern in the variant).
|
||||
ARG MEMPALACE_TOOLKIT_REPO=https://gitea.jordbo.se/joakimp/mempalace-toolkit.git
|
||||
# MEMPALACE_TOOLKIT_REF accepts EITHER a branch name OR a commit SHA. CI
|
||||
# resolves it to a SHA (resolve-versions job) and folds that SHA into the
|
||||
# base-decide hash so the base rebuilds when the toolkit moves. `git clone
|
||||
# --branch <40-char-SHA>` fails ("Remote branch not found") — the same
|
||||
# footgun fixed in Dockerfile.variant (v1.0.0-rerun, run 374) — so use
|
||||
# `git fetch <ref> + checkout FETCH_HEAD`, which works for name and SHA.
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \
|
||||
rm -rf /opt/mempalace-toolkit && mkdir -p /opt/mempalace-toolkit && \
|
||||
git -C /opt/mempalace-toolkit init -q && \
|
||||
git -C /opt/mempalace-toolkit remote add origin "${MEMPALACE_TOOLKIT_REPO}" && \
|
||||
ok=0; for i in 1 2 3 4 5; do \
|
||||
if git -C /opt/mempalace-toolkit fetch --depth 1 origin "${MEMPALACE_TOOLKIT_REF}" && \
|
||||
git -C /opt/mempalace-toolkit checkout -q FETCH_HEAD; then ok=1; break; fi; \
|
||||
echo "git fetch mempalace-toolkit@${MEMPALACE_TOOLKIT_REF} failed (attempt $i/5), retrying in $((i*5))s..."; \
|
||||
sleep $((i*5)); \
|
||||
done; \
|
||||
[ "$ok" = "1" ] && \
|
||||
ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \
|
||||
ln -sf /opt/mempalace-toolkit/bin/mempalace-docs /usr/local/bin/mempalace-docs && \
|
||||
ln -sf /opt/mempalace-toolkit/bin/mempalace-pi-session /usr/local/bin/mempalace-pi-session && \
|
||||
ln -sf /opt/mempalace-toolkit/bin/mempalace-census /usr/local/bin/mempalace-census && \
|
||||
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs \
|
||||
/opt/mempalace-toolkit/bin/mempalace-pi-session \
|
||||
/opt/mempalace-toolkit/bin/mempalace-census && \
|
||||
mempalace-session --help >/dev/null && \
|
||||
mempalace-docs --help >/dev/null && \
|
||||
mempalace-pi-session --help >/dev/null && \
|
||||
mempalace-census --help >/dev/null && \
|
||||
echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \
|
||||
fi
|
||||
|
||||
# rustup — Rust toolchain manager (init binary only; toolchains installed at runtime)
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://static.rust-lang.org/rustup/dist/${ARCH}-unknown-linux-gnu/rustup-init" -o /usr/local/bin/rustup-init && \
|
||||
chmod +x /usr/local/bin/rustup-init
|
||||
|
||||
# gitea-mcp — MCP server for Gitea API
|
||||
ARG GITEA_MCP_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${GITEA_MCP_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing gitea-mcp ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/download/v${V}/gitea-mcp_Linux_${ARCH}.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin/ gitea-mcp && \
|
||||
chmod +x /usr/local/bin/gitea-mcp && \
|
||||
gitea-mcp --version
|
||||
|
||||
# Locales
|
||||
RUN sed -i -E '/(en_US|en_GB|sv_SE|da_DK|nb_NO|fi_FI|de_DE|fr_FR|es_ES|it_IT|pt_BR|nl_NL|pl_PL|ja_JP|ko_KR|zh_CN)\.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||
ENV LANG=en_US.UTF-8
|
||||
ENV LANGUAGE=en_US:en
|
||||
ENV LC_ALL=en_US.UTF-8
|
||||
ENV EDITOR=nvim
|
||||
# Advertise 24-bit colour so colour-aware tools (Neovim's own auto-detect, bat,
|
||||
# delta, ...) use true colour instead of a 256-colour fallback. Safe for the
|
||||
# modern terminals this devbox targets; override by exporting `COLORTERM=`
|
||||
# (empty) from a terminal that lacks true-colour support.
|
||||
ENV COLORTERM=truecolor
|
||||
ENV PATH="/home/developer/.local/bin:/home/developer/.cargo/bin:${PATH}"
|
||||
|
||||
# ── Node.js (required for pi + MCP servers + tldr) ──
|
||||
# 24 (LTS "Krypton"), raised from 22 on 2026-09-10 because the image was BELOW a
|
||||
# DECLARED requirement, not merely behind the newest release: `agent-browser`
|
||||
# publishes engines.node ">=24.0.0", so every build on 22 installed it with an npm
|
||||
# EBADENGINE warning and then ran it outside its supported range — measured on
|
||||
# v1.8.14, which shipped node 22.23.2 with agent-browser 0.37.1. The other two npm
|
||||
# consumers are satisfied either way: pi declares ">=22.19.0" and playwright
|
||||
# ">=20". Verified before bumping, because a missing NodeSource suite would break
|
||||
# the build for every arch at once: deb.nodesource.com/setup_24.x returns HTTP 200
|
||||
# and the node_24.x suite advertises `Architectures: amd64 arm64 armhf x86_64`, so
|
||||
# both the arm64 fleet and the amd64 CI runners resolve.
|
||||
ARG NODE_VERSION=24
|
||||
RUN curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
|
||||
apt-get install -y --no-install-recommends nodejs && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── agent-browser — headless browser automation for the agent ────────
|
||||
# Gives the agent a real browser it can drive (open/click/fill/eval/
|
||||
# screenshot) so front-end work involving live DOM or WebGL can be VERIFIED
|
||||
# rather than guessed at. The `agent-browser` skill (shipped from the
|
||||
# skillset repo, not this image) documents the CLI; without this block that
|
||||
# skill is a no-op because the binary isn't present. Verified end-to-end
|
||||
# 2026-07-13: drives the baked Chromium headless (open + screenshot + eval
|
||||
# into a WebGL SPA) — doctor's launch test passes in ~0.5s.
|
||||
#
|
||||
# TWO pieces, because agent-browser is a standalone Rust CLI that ships NO
|
||||
# browser of its own — it only drives one you provide:
|
||||
# 1. the CLI itself (npm; ~70 MB of prebuilt native binaries), and
|
||||
# 2. a Chromium, which we fetch via Playwright.
|
||||
#
|
||||
# Why Playwright fetches the browser (and NOT `agent-browser install`):
|
||||
# agent-browser's own installer drops Chrome under ~/.agent-browser/browsers
|
||||
# — inside /home/${USER_NAME}, which is a NAMED VOLUME at runtime, so a
|
||||
# build-time download would be SHADOWED (invisible) once the volume mounts.
|
||||
# Playwright honours PLAYWRIGHT_BROWSERS_PATH, so we place the browser under
|
||||
# /usr/local/share (never shadowed) and hand agent-browser a STABLE symlink
|
||||
# via AGENT_BROWSER_EXECUTABLE_PATH — the symlink insulates the ENV from
|
||||
# Playwright's per-version, per-ARCH browser directory (`chrome-linux` on arm64,
|
||||
# `chrome-linux64` on amd64 — Chrome-for-Testing), so we `find` the `chrome`
|
||||
# binary rather than hardcode the path; the headless-shell binary is named
|
||||
# `chrome-headless-shell`, so `-name chrome` skips it.
|
||||
#
|
||||
# `playwright install --with-deps chromium` also apt-installs Chromium's
|
||||
# runtime libs; verified to resolve correctly on Debian trixie (exit 0 — the
|
||||
# t64 library renames are handled by Playwright's dep list). Build runs as
|
||||
# root, so the apt step works. NPM_CONFIG_PREFIX=/usr keeps both CLIs on /usr
|
||||
# so they survive the ~/.pi/npm-global volume mount (same trick the variant
|
||||
# uses for pi). After fetching, we DROP Playwright's `chromium_headless_shell-*`
|
||||
# build — agent-browser drives the full chrome (verified, incl. headless), so the
|
||||
# headless shell is dead weight — and clean the apt/npm caches, trimming the
|
||||
# layer to ~625 MB (Chromium) from ~960 MB. Still the bulk of the base's size,
|
||||
# and the one real tradeoff of shipping this to every variant.
|
||||
ARG AGENT_BROWSER_VERSION=latest
|
||||
ARG PLAYWRIGHT_VERSION=latest
|
||||
ENV PLAYWRIGHT_BROWSERS_PATH=/usr/local/share/ms-playwright
|
||||
RUN NPM_CONFIG_PREFIX=/usr npm install -g \
|
||||
"agent-browser@${AGENT_BROWSER_VERSION}" \
|
||||
"playwright@${PLAYWRIGHT_VERSION}" && \
|
||||
playwright install --with-deps chromium && \
|
||||
CHROME="$(find "${PLAYWRIGHT_BROWSERS_PATH}" -type f -name chrome -path '*/chromium-*/*' | head -n1)" && \
|
||||
[ -n "$CHROME" ] && ln -sf "$CHROME" /usr/local/bin/agent-chrome && \
|
||||
agent-browser --version && \
|
||||
test -x "$(readlink -f /usr/local/bin/agent-chrome)" && \
|
||||
rm -rf "${PLAYWRIGHT_BROWSERS_PATH}"/chromium_headless_shell-* && \
|
||||
npm cache clean --force && \
|
||||
rm -rf /var/lib/apt/lists/* /root/.npm /tmp/*
|
||||
ENV AGENT_BROWSER_EXECUTABLE_PATH=/usr/local/bin/agent-chrome
|
||||
|
||||
# ── tldr (tealdeer) — community-maintained command examples ──────────
|
||||
# Tealdeer is a Rust port of the tldr-pages client; ~5 MB static binary,
|
||||
# ~135 MB smaller than the Node tldr global. Same `tldr` command, same UX.
|
||||
ARG TEALDEER_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${TEALDEER_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tealdeer-rs/tealdeer/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing tealdeer ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tealdeer-rs/tealdeer/releases/download/v${V}/tealdeer-linux-${ARCH}-musl" -o /usr/local/bin/tldr && \
|
||||
chmod +x /usr/local/bin/tldr && \
|
||||
tldr --version
|
||||
|
||||
# ── typst — lightweight PDF engine for pandoc (Markdown→PDF) ─────────
|
||||
# pandoc (apt-installed above) is only a front-end; rendering PDF needs a
|
||||
# back-end engine. Rather than a ~600 MB TeX Live install, we ship typst:
|
||||
# a single ~30 MB static Rust binary with no LaTeX dependency. pi-studio's
|
||||
# PDF export (studio_export_pdf) and pandoc invocations use it via
|
||||
# `pandoc --pdf-engine=typst`. A fuller TeX Live remains the higher-
|
||||
# fidelity fallback for anyone who needs LaTeX-exact output (not shipped
|
||||
# here — install on demand or in a future variant).
|
||||
#
|
||||
# Follows the `latest` GitHub-release convention (like tealdeer/uv/bat).
|
||||
# typst ships a `.tar.xz` asset (hence xz-utils in the apt layer above)
|
||||
# that extracts to typst-<arch>-unknown-linux-musl/typst. Pin a specific
|
||||
# tag with --build-arg TYPST_VERSION=vX.Y.Z.
|
||||
#
|
||||
# We also patch pandoc's bundled typst template
|
||||
# (/usr/share/pandoc/data/templates/template.typst): its conf() defaults the
|
||||
# document font to an empty tuple (`font: ()`), so a naked
|
||||
# `pandoc --pdf-engine=typst` fails with "font fallback list must not be empty"
|
||||
# unless the caller passes `-V mainfont=...`. We default it to Libertinus Serif
|
||||
# (typst's own bundled default font) so PDF export works out-of-the-box.
|
||||
ARG TYPST_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${TYPST_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/typst/typst/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing typst ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/typst/typst/releases/download/v${V}/typst-${ARCH}-unknown-linux-musl.tar.xz" | tar -xJ -C /tmp && \
|
||||
install /tmp/typst-${ARCH}-unknown-linux-musl/typst /usr/local/bin/typst && \
|
||||
rm -rf /tmp/typst-${ARCH}-unknown-linux-musl && \
|
||||
typst --version && \
|
||||
sed -i 's/^ font: (),$/ font: ("Libertinus Serif",),/' /usr/share/pandoc/data/templates/template.typst && \
|
||||
grep -q 'font: ("Libertinus Serif",),' /usr/share/pandoc/data/templates/template.typst
|
||||
|
||||
# ── yq (mikefarah) — YAML processor, jq's companion for YAML ─────────
|
||||
# Installed as the mikefarah Go binary — NOT Debian's `yq` apt package, which
|
||||
# is the unrelated Python kislyuk/yq (a jq wrapper with different syntax and
|
||||
# version line, e.g. 3.x). The cloud-init repo's deploy.sh/provision.sh
|
||||
# require mikefarah yq v4 (the unrelated Debian python yq is v3.x). Follows
|
||||
# the repo's `latest` convention (like tealdeer/uv/etc.); the smoke test pins
|
||||
# the contract to major v4, so a future yq v5 fails CI instead of silently
|
||||
# breaking provision.sh. Pin a specific tag with --build-arg YQ_VERSION=vX.Y.Z.
|
||||
ARG YQ_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${YQ_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/mikefarah/yq/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing mikefarah yq ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/mikefarah/yq/releases/download/${V}/yq_linux_${ARCH}" -o /usr/local/bin/yq && \
|
||||
chmod +x /usr/local/bin/yq && \
|
||||
yq --version
|
||||
|
||||
# ── AWS CLI v2 (for SSO/Bedrock authentication) ─────────────────────
|
||||
RUN ARCH=$(case "${TARGETARCH}" in \
|
||||
amd64) echo "x86_64" ;; \
|
||||
arm64) echo "aarch64" ;; \
|
||||
*) echo "x86_64" ;; \
|
||||
esac) && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscli.zip && \
|
||||
unzip -q /tmp/awscli.zip -d /tmp && \
|
||||
/tmp/aws/install && \
|
||||
rm -rf /tmp/aws /tmp/awscli.zip && \
|
||||
aws --version
|
||||
|
||||
# ── Non-root user ────────────────────────────────────────────────────
|
||||
ARG USER_NAME=developer
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
|
||||
RUN groupadd --gid ${USER_GID} ${USER_NAME} && \
|
||||
useradd --uid ${USER_UID} --gid ${USER_GID} -m -s /bin/bash ${USER_NAME} && \
|
||||
echo "${USER_NAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/${USER_NAME}
|
||||
|
||||
# Standard directories
|
||||
RUN mkdir -p /workspace \
|
||||
/home/${USER_NAME}/.pi/agent/extensions \
|
||||
/home/${USER_NAME}/.agents/skills \
|
||||
/home/${USER_NAME}/.cache/bash \
|
||||
/home/${USER_NAME}/.ssh && \
|
||||
chown -R ${USER_NAME}:${USER_NAME} /workspace /home/${USER_NAME}
|
||||
|
||||
# ── Pre-warm chromadb embedding model ──────────────────────────────
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
gosu ${USER_NAME} /opt/uv-tools/mempalace/bin/python -c "\
|
||||
from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2; \
|
||||
ef = ONNXMiniLM_L6_V2(); \
|
||||
_ = ef(['warmup']); \
|
||||
print('chromadb embedding model warmed: all-MiniLM-L6-v2')" && \
|
||||
ls -lh /home/${USER_NAME}/.cache/chroma/onnx_models/all-MiniLM-L6-v2/ ; \
|
||||
fi
|
||||
|
||||
# ── User-writable npm global prefix on the devbox-pi-config volume ──
|
||||
# Build-time installs use NPM_CONFIG_PREFIX=/usr (see Dockerfile.variant).
|
||||
# Runtime npm/pi installs use this prefix → land on the named volume.
|
||||
ENV NPM_CONFIG_PREFIX=/home/${USER_NAME}/.pi/npm-global
|
||||
ENV PATH="/home/${USER_NAME}/.pi/npm-global/bin:${PATH}"
|
||||
|
||||
# ── MemPalace config/palace root: pin it, do not let a heuristic pick it ──
|
||||
# mempalace >= 3.10.0 resolves its config dir as $MEMPALACE_CONFIG_DIR, then
|
||||
# ~/.mempalace ONLY if it already holds a config/palace, else ~/.config/mempalace
|
||||
# (XDG). An empty ~/.mempalace — a fresh devbox-palace volume, or entrypoint.sh's
|
||||
# mkdir on a volume-less container — fails that test, so a first boot would put
|
||||
# the palace outside the persisted path and re-run first-run init forever. This
|
||||
# ENV is first in the order, so the layout is what entrypoint.sh (mkdir),
|
||||
# entrypoint-user.sh (first-run test), the feeder's <palace-root>/pi-stage and
|
||||
# scripts/recreate-sanity-check.sh all already assume. Rationale and the
|
||||
# measurement live with ARG MEMPALACE_VERSION above; keep the two in step.
|
||||
ENV MEMPALACE_CONFIG_DIR=/home/${USER_NAME}/.mempalace
|
||||
|
||||
# ── Shell defaults (bash history, aliases, readline) ─────────────────
|
||||
RUN mkdir -p /etc/skel-devbox
|
||||
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
|
||||
COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
|
||||
COPY rootfs/home/developer/.gitignore_global /etc/skel-devbox/.gitignore_global
|
||||
|
||||
# ── Editor defaults: system-wide Neovim true-colour ──────────────────
|
||||
# /etc/xdg/nvim/sysinit.vim is Neovim's system vimrc: it loads for every user
|
||||
# (before any personal ~/.config/nvim) and can still be overridden per-user.
|
||||
# Enables termguicolors so the default theme renders in 24-bit colour instead
|
||||
# of a muddy 256-colour fallback. Pairs with kitty-terminfo (installed above).
|
||||
COPY rootfs/etc/xdg/nvim/sysinit.vim /etc/xdg/nvim/sysinit.vim
|
||||
|
||||
# ── Terminal support: xterm-ghostty terminfo alias ──────────────────
|
||||
# ncurses-term (installed above) covers wezterm/alacritty/foot/st and the base
|
||||
# `ghostty` entry, but Ghostty connects with TERM=xterm-ghostty, for which no
|
||||
# distro packages an entry. Ship a thin alias (use=ghostty) and compile it into
|
||||
# the system terminfo db with `tic -x`, so it inherits the maintained ghostty
|
||||
# capability set. The `infocmp` check fails the build if the entry didn't land.
|
||||
COPY rootfs/usr/local/share/terminfo-src/ghostty.terminfo /usr/local/share/terminfo-src/ghostty.terminfo
|
||||
RUN tic -x -o /usr/share/terminfo /usr/local/share/terminfo-src/ghostty.terminfo && \
|
||||
infocmp -x xterm-ghostty >/dev/null
|
||||
|
||||
# ── Entrypoint ────────────────────────────────────────────────────────
|
||||
COPY rootfs/usr/local/lib/pi-devbox/ /usr/local/lib/pi-devbox/
|
||||
# Image-baked skills + the global-AGENTS append snippet. Under /usr/local so a
|
||||
# named volume over a home dir can't shadow them; linked into ~/.agents/skills
|
||||
# by entrypoint-user.sh, and the snippet is concatenated onto the global
|
||||
# AGENTS.md in Dockerfile.variant (after pi-toolkit, which owns that file).
|
||||
COPY rootfs/usr/local/share/pi-devbox/ /usr/local/share/pi-devbox/
|
||||
COPY rootfs/usr/local/bin/studio-expose /usr/local/bin/studio-expose
|
||||
COPY rootfs/usr/local/bin/dot-watch /usr/local/bin/dot-watch
|
||||
COPY rootfs/usr/local/bin/pi-devbox-version /usr/local/bin/pi-devbox-version
|
||||
COPY rootfs/usr/local/bin/devbox-skill-reconcile /usr/local/bin/devbox-skill-reconcile
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
|
||||
/usr/local/bin/studio-expose \
|
||||
/usr/local/bin/dot-watch \
|
||||
/usr/local/bin/pi-devbox-version \
|
||||
/usr/local/bin/devbox-skill-reconcile \
|
||||
/usr/local/lib/pi-devbox/*.sh 2>/dev/null || true
|
||||
|
||||
# Start as root — entrypoint adjusts UID/GID then drops to developer
|
||||
WORKDIR /workspace
|
||||
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
CMD ["bash", "-l"]
|
||||
@@ -0,0 +1,670 @@
|
||||
# pi-devbox — variant image
|
||||
#
|
||||
# FROMs a base-<hash> image produced by Dockerfile.base and adds only
|
||||
# the variant-specific tools — currently just the pi install. Kept as a
|
||||
# separate file (rather than collapsed into Dockerfile.base) so future
|
||||
# variants (e.g. studio, studio-tex) can FROM the variant or extend
|
||||
# this Dockerfile with additional build args without rebuilding the
|
||||
# base on every pi version bump.
|
||||
#
|
||||
# Pass `--build-arg BASE_IMAGE=<repo>:base-<hash>` to select the base.
|
||||
# CI computes the base hash from Dockerfile.base + rootfs/ +
|
||||
# entrypoint*.sh and feeds it in.
|
||||
#
|
||||
# IMPORTANT: the base image sets NPM_CONFIG_PREFIX to
|
||||
# /home/developer/.pi/npm-global so runtime `pi install npm:...` and
|
||||
# `npm install -g` by the developer user lands on the named volume.
|
||||
# At BUILD time we want the baked binaries on /usr so they survive the
|
||||
# volume mount. Each `npm install -g` below therefore prefixes the
|
||||
# command with `NPM_CONFIG_PREFIX=/usr`.
|
||||
|
||||
ARG BASE_IMAGE
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG USER_NAME=developer
|
||||
|
||||
# ── pi coding-agent + companions ─────────────────────────────────────
|
||||
# pi-toolkit and pi-extensions are cloned into /opt/. entrypoint-user.sh
|
||||
# runs each repo's install.sh on container start so symlinks land under
|
||||
# ~/.pi/agent/ on the named volume.
|
||||
#
|
||||
# ── pi version pin: an AUDITED CHECKPOINT, not a freeze ──────────────
|
||||
# PI_VERSION is pinned to a version whose upstream CHANGELOG has been read
|
||||
# against this image's integration surface: the theme/TUI API that pi-atelier
|
||||
# couples to, the session `.jsonl` format that `pi-session-repair` parses, the
|
||||
# extension/package loader, and the Node engine floor. CI reads THIS LINE as
|
||||
# the single source of truth (see the `resolve-versions` job) and no longer
|
||||
# follows npm `latest` — following it meant every release silently adopted
|
||||
# whatever pi shipped that morning, unaudited, in the very build that then got
|
||||
# tagged and published.
|
||||
#
|
||||
# BUMPING IS ROUTINE AND EXPECTED — the pin exists to force a look, not to
|
||||
# hold a version forever:
|
||||
# 1. Read the upstream CHANGELOG for every version between old and new.
|
||||
# 2. Re-check the companions that couple to pi's private TUI/renderer
|
||||
# internals — pi-atelier above all (see PI_ATELIER_REF below for the
|
||||
# 0.6.0-under-pi-0.84 startup-hang precedent).
|
||||
# 3. Bump this line, record the audit in CHANGELOG.md, then tag.
|
||||
# CI fails the build if this pin is not a published npm version, and warns —
|
||||
# without adopting it — when npm `latest` has moved ahead. That warning is the
|
||||
# prompt to do step 1; it is not something to silence.
|
||||
#
|
||||
# A concrete version here ALSO defeats the registry-buildcache cache-hit
|
||||
# footgun that `latest` carried: a byte-identical build-arg string produced an
|
||||
# identical layer hash, so the cache reused the layer from whatever pi was
|
||||
# current when it was first populated (shipped the same bytes for pi-devbox
|
||||
# v0.74.0..v0.75.5; discovered + fixed in v0.75.5b, 2026-05-23). The `latest`
|
||||
# branch below is kept only for a deliberate local `docker build` override.
|
||||
#
|
||||
# AUDITED AT 0.84.4 (2026-08-31, was 0.84.3): NO "Breaking Changes" and no
|
||||
# "Removed" heading in the 0.84.4 section (grepped, 0 matches) — unlike 0.84.3,
|
||||
# whose heading is described in the paragraph below and stays audited. Adopted
|
||||
# for three fixes that land on machinery this fleet actually runs:
|
||||
# - #6879 large tool results crossing the auto-compaction threshold were sent
|
||||
# to the provider BEFORE compacting; pi now compacts between tool execution
|
||||
# and the next assistant response in the same run. This is the shape of
|
||||
# nearly every session here (multi-hundred-KB logstream/palace tool output).
|
||||
# - #8345 a resumed session corrupted its next appended entry when the JSONL
|
||||
# lacked a trailing newline. That file is the memory feeder's own input.
|
||||
# Measured on tor-ms22 before the bump: 49/49 transcripts end in a newline,
|
||||
# 0 lines fail json.loads — the bug had not bitten this corpus.
|
||||
# - #8537 extension messages sent with `triggerTurn: false` WHILE THE AGENT IS
|
||||
# RUNNING were inserted between a tool call and its result, so
|
||||
# order-validating providers rejected the replayed history. The mempalace
|
||||
# mailbox is outside that precondition — it delivers at `agent_settled`
|
||||
# (idle) with `{deliverAs:"steer"}` and deliberately no `triggerTurn` — and
|
||||
# 0.84.4 leaves the documented steer semantics unchanged, so RFC 003 §7.11
|
||||
# still holds. Recorded because the fix is what would make a future mid-run
|
||||
# delivery safe, which is the only reason we would ever change that call.
|
||||
# One doc consequence, fixed in this same release: pi's own docs/compaction.md
|
||||
# gained exactly one paragraph — the autoCompact threshold is now ALSO checked
|
||||
# mid-run, after a tool batch's results are appended. See
|
||||
# docs/observational-memory.md §3, which had said compaction is only checked
|
||||
# when pi goes idle.
|
||||
#
|
||||
# AUDITED AT 0.84.3 (2026-08-25, was 0.84.2): upstream's notes carry a
|
||||
# "Breaking Changes" heading — `GoogleThinkingLevel` renamed to
|
||||
# `GoogleApiThinkingLevel`. INERT FOR THIS IMAGE: all four vendored companions
|
||||
# (/opt/pi-fork, /opt/pi-observational-memory, /opt/pi-atelier, /opt/pi-studio)
|
||||
# were grepped for that symbol and reference it ZERO times, so nothing here
|
||||
# couples to the renamed type. Recorded because the heading will look alarming
|
||||
# to the next reader doing step 1 above — the audit is done, don't redo it.
|
||||
# Adopted for two fixes that land squarely on this repo's own vendored-skill
|
||||
# wiring (see devbox-skill-reconcile, v1.8.5): nested Markdown skills inside
|
||||
# `.agents/skills/<group>/` directories were not discovered, and root Markdown
|
||||
# files such as README.md / AGENTS.md inside a skill dir were reported as
|
||||
# broken skills unless they declared valid skill frontmatter.
|
||||
#
|
||||
# v1.8.13: 0.84.4 -> 0.85.1. SKIP 0.85.0 deliberately — it accidentally
|
||||
# published internal experimental code and extra subpaths, breaking SDK
|
||||
# imports (upstream #9132); 0.85.1 exists specifically to undo that, with the
|
||||
# supported SDK and stdio RPC API unchanged. Audited: no Breaking/Removed
|
||||
# changelog headings in either release, engine floor unchanged (>=22.19.0,
|
||||
# container runs 22.23.2), runtime deps 20 -> 19. User-visible changes are the
|
||||
# streaming indicator moving into the editor border and faster fullscreen
|
||||
# transcript search; no deprecation language anywhere.
|
||||
#
|
||||
# Verified EMPIRICALLY rather than from the changelog, because a pi bump has
|
||||
# hung the TUI before (pi-atelier < 0.7.1 + pi >= 0.84): 0.85.1 was
|
||||
# side-installed and driven under a pty against all four companion extensions,
|
||||
# with atelier v0.10.0 AND v0.10.1 — five combinations, each rendering alive
|
||||
# with a CPU delta of 0.00-0.01s over a 5s window, where the known hang
|
||||
# signature is ~5s of sustained CPU. Two-sided check: the atelier sidebar
|
||||
# painted ACTIVITY+WORKSPACE identically to the 0.84.4 control, so the test
|
||||
# could distinguish "loaded" from "silently absent".
|
||||
#
|
||||
# v1.9.4: HELD at 0.85.1 while 0.86.1 and 0.87.0 exist upstream. 0.87.0's
|
||||
# changelog: "Removed the inherited `shouldStopAfterTurn` agent option. Use
|
||||
# `finishTurn` and return `{ action: "end" }` instead" (no issue number on
|
||||
# that line); 0.86.0 moved provider stream inputs to `TranscriptContext`, with
|
||||
# system prompts read from `context.messages`. pi-observational-memory 3.1.4
|
||||
# (the `master` ref baked below) still uses both the old option and
|
||||
# `AgentContext.systemPrompt` in its observer, reflector and dropper workers —
|
||||
# measured 2026-09-22 in src/agents/*/agent.ts, both the baked 3.1.3 tree and
|
||||
# upstream master 3.1.4: `shouldStopAfterTurn` 1 per worker (3), `finishTurn`
|
||||
# 0, `systemPrompt` 1 per worker. Its peerDependencies are `*`,
|
||||
# so nothing at install time would refuse; it would break at runtime (turn
|
||||
# caps ignored, workers losing their specialised prompts). Upstream tracks it
|
||||
# as pi-observational-memory #82 with fix PR #83 (opened 2026-09-21, mergeable,
|
||||
# not merged at this writing). Bump pi and pi-obsmem TOGETHER once #83 has
|
||||
# shipped in a release. The other extensions were checked against the 0.86.0
|
||||
# and 0.87.0 breaking lists and are clean: ssh-controlmaster's `user_bash`
|
||||
# handler already returns `undefined | { operations }` (0.86.0 fail-closed
|
||||
# contract) and its registerTool calls spread the built-in tools so they
|
||||
# carry parameter schemas (#9300). 0.86.1 as an intermediate is untested and
|
||||
# not worth the pty matrix for a stop that #83 will make moot.
|
||||
ARG PI_VERSION=0.85.1
|
||||
ARG PI_TOOLKIT_REF=main
|
||||
ARG PI_EXTENSIONS_REF=main
|
||||
# Repo URLs default to the canonical gitea origin but are overridable so a
|
||||
# relocated/forked build can clone from a mirror or a different host
|
||||
# without editing this Dockerfile — same pattern as PI_FORK_REPO /
|
||||
# PI_OBSMEM_REPO / PI_STUDIO_REPO below.
|
||||
ARG PI_TOOLKIT_REPO=https://gitea.jordbo.se/joakimp/pi-toolkit.git
|
||||
ARG PI_EXTENSIONS_REPO=https://gitea.jordbo.se/joakimp/pi-extensions.git
|
||||
# pi-fork (fork tool) + pi-observational-memory (recall tool) live on GitHub
|
||||
# under elpapi42. CI resolves these to commit SHAs to defeat the same
|
||||
# cache-hit footgun that affects PI_VERSION.
|
||||
ARG PI_FORK_REPO=https://github.com/elpapi42/pi-fork.git
|
||||
ARG PI_FORK_REF=master
|
||||
ARG PI_OBSMEM_REPO=https://github.com/elpapi42/pi-observational-memory.git
|
||||
ARG PI_OBSMEM_REF=master
|
||||
# pi-atelier (TUI sidebar: ordered panels, split-pane, themes) is PINNED TO A
|
||||
# TAG, which CI resolves to that tag's commit SHA — same treatment as
|
||||
# pi-studio, for reproducibility plus cache-busting.
|
||||
#
|
||||
# This floor is hard-earned. pi-atelier 0.6.0/0.7.0 wrapped pi's PRIVATE TUI
|
||||
# renderer, and under pi 0.84 that wrapper recursed: pi hung at startup with
|
||||
# sustained CPU. Upstream fixed the recursion in 0.7.1 and restored the
|
||||
# non-overlapping split in 0.7.2 — "avoiding the recursive render path that
|
||||
# caused startup hangs and sustained CPU usage". Its own peerDependencies
|
||||
# still say `>=0.80.7`, which does NOT encode that floor, so nothing would
|
||||
# have warned us: NEVER pair pi-atelier < 0.7.1 with pi >= 0.84. Bump this
|
||||
# pin and PI_VERSION together, checking atelier's CHANGELOG for the pi
|
||||
# version it claims to track.
|
||||
#
|
||||
# AUDITED AT v0.10.0 (2026-08-31, was v0.8.2 — two minor releases): no
|
||||
# BREAKING notice in either release, and both are UI-only (Sidebar calm during
|
||||
# an active Turn, composer frame + Status Rail, fullscreen-copy-safe Sidebar,
|
||||
# Windows path normalisation, Workspace Pulse deferred until pi trusts the
|
||||
# project). The one coupling that matters runs the OPPOSITE way to the floor
|
||||
# above: v0.9.0 renders the Sidebar as a separate split-layout child and
|
||||
# therefore "raises the minimum supported Pi version to 0.84.0", which its
|
||||
# peerDependencies do encode this time (`>=0.84.0`, up from `>=0.80.7`).
|
||||
# Satisfied with room to spare by PI_VERSION 0.84.4 above — and note that both
|
||||
# executable floors (scripts/smoke-test.sh, scripts/recreate-sanity-check.sh)
|
||||
# compare with `sort -V`, so 0.10.0 >= 0.7.1 is evaluated correctly rather than
|
||||
# as the string comparison that would read 0.10.0 as older than 0.7.1.
|
||||
# Pairs deliberately with pi 0.84.4's own fullscreen selection-copy controls:
|
||||
# atelier keeps Sidebar content out of the transcript selection, pi adds
|
||||
# `fullscreenCopyOnSelect` + Ctrl+X for the selection itself.
|
||||
#
|
||||
# No `npm install` step, unlike pi-fork/pi-observational-memory/pi-studio:
|
||||
# pi-atelier declares ZERO runtime dependencies (only peerDeps, satisfied by
|
||||
# the baked pi) and has no build step — pi loads its TypeScript directly from
|
||||
# the /opt checkout. Adding an install here would be a no-op that only costs
|
||||
# build time.
|
||||
ARG PI_ATELIER_REPO=https://github.com/michaelmjhhhh/pi-atelier.git
|
||||
# v1.8.13: v0.10.0 -> v0.10.1. Refactor-only upstream (formatters, tests,
|
||||
# panel identity); peerDependencies declare pi >=0.84.0, so it spans both the
|
||||
# old and new pin. Included because it was already exercised: the pty matrix
|
||||
# for PI_VERSION above ran atelier v0.10.1 against pi 0.85.1 and painted the
|
||||
# sidebar identically to v0.10.0.
|
||||
#
|
||||
# v1.9.4: v0.10.1 -> v0.10.3 (both v0.10.2 and v0.10.3 released 2026-09-22).
|
||||
# Fixes only per the release notes: sidebar text/borders preserved beside
|
||||
# inline images (#53), transcript images hidden while capturing overlays are
|
||||
# open, Workspace Pulse skips redundant HEAD/diff when nothing tracked changed
|
||||
# (#61), sidebar height from row counts (#59), git/usage scans suspended while
|
||||
# disabled, Display Revert + Undo ordering. Checked before bumping: package.json
|
||||
# at v0.10.3 still declares zero runtime dependencies and no build script (so
|
||||
# the no-`npm install` reasoning above holds) and peerDependencies are still
|
||||
# pi >=0.84.0, so it still spans the pinned 0.85.1. 13 commits v0.10.1..v0.10.3,
|
||||
# all under src/ tests/ docs/ scripts/ plus metadata; no entry-point move.
|
||||
ARG PI_ATELIER_REF=v0.10.3
|
||||
# Human-readable tag PI_ATELIER_REF was resolved from; recorded as a label.
|
||||
ARG PI_ATELIER_VERSION=v0.10.3
|
||||
|
||||
RUN set -e && \
|
||||
# git_fetch_ref: clone-equivalent helper that accepts EITHER a branch name
|
||||
# OR a commit SHA as $ref. Uses `git fetch <ref> + checkout FETCH_HEAD`
|
||||
# which (a) works with both name and SHA forms uniformly, and (b) defeats
|
||||
# the registry-buildcache footgun when CI passes a resolved SHA. The
|
||||
# earlier helper `git_clone_retry` (using `git clone --branch`) only
|
||||
# worked with branch names — a SHA-resolved build-arg made `git clone
|
||||
# --branch <40-char-SHA>` fail with "Remote branch not found". Surfaced
|
||||
# in pi-devbox v1.0.0-rerun (run 374) 2026-06-10 and fixed by switching
|
||||
# all four clones to git_fetch_ref. Both Gitea and GitHub allow fetching
|
||||
# arbitrary commits by default (uploadpack.allowReachableSHA1InWant).
|
||||
git_fetch_ref() { \
|
||||
url="$1"; ref="$2"; dest="$3"; \
|
||||
rm -rf "$dest"; mkdir -p "$dest"; \
|
||||
git -C "$dest" init -q && git -C "$dest" remote add origin "$url" && \
|
||||
for i in 1 2 3 4 5; do \
|
||||
if git -C "$dest" fetch --depth 1 origin "$ref" && git -C "$dest" checkout -q FETCH_HEAD; then return 0; fi; \
|
||||
echo "git fetch $url@$ref failed (attempt $i/5), retrying in $((i*5))s..."; \
|
||||
sleep $((i*5)); \
|
||||
done; \
|
||||
return 1; \
|
||||
} && \
|
||||
# prune_foreign_natives: npm 11 (shipped with Node 24) installs EVERY optional
|
||||
# platform package of a native dependency, not just the one matching the host.
|
||||
# TWO families are affected in this image, and BOTH have been measured — add a
|
||||
# family here only after measuring it, never by widening the pattern on a hunch:
|
||||
#
|
||||
# @esbuild/<platform> 26 dirs, 284 MB (found first, v1.9.1)
|
||||
# @mariozechner/clipboard-<triple> 11 dirs, 12 MB per site, 10 MB foreign
|
||||
#
|
||||
# esbuild declares those with os/cpu constraints, but npm 11 ignores them and
|
||||
# ALSO ignores --os/--cpu and an npmrc carrying os=/cpu= (all three measured).
|
||||
# So prune explicitly, keeping only the host platform, computed from
|
||||
# `node -p process.arch` so one line stays correct on amd64 and arm64.
|
||||
# Measured on pi-fork's tree: npm 10.9.8 -> 165 MB, npm 11.19.0 -> 449 MB,
|
||||
# and the 165 MB figure reproduces what v1.9.0's predecessor actually shipped.
|
||||
#
|
||||
# WHY THE CLIPBOARD FAMILY WAS ADDED (2026-09-11): v1.9.1 pruned @esbuild only
|
||||
# and still shipped +131 MB compressed over v1.8.14. That residual was
|
||||
# attributed by listing the PUBLISHED arm64 layer tarballs straight from the
|
||||
# registry (there is no docker CLI inside the container, so `docker history`
|
||||
# was not available): +110 MB /root/.npm/_cacache (purged below) and +21 MB of
|
||||
# clipboard platform packages across the two install sites — 131 MB total, so
|
||||
# the delta is now fully accounted for with no unexplained remainder.
|
||||
#
|
||||
# Keeping linux-$arch-{gnu,musl} is deliberate: clipboard's napi-rs loader
|
||||
# tries ./<name>.node then the platform package, per platform in try/catch, and
|
||||
# chooses gnu vs musl at runtime from its own isMusl() probe — so both host-arch
|
||||
# branches must survive. The musl package is a 420-byte stub, i.e. free. The
|
||||
# bare wrapper `@mariozechner/clipboard` has no hyphen suffix and therefore
|
||||
# cannot match the regex below. Verified on arm64 against a copy of the real
|
||||
# tree before this was written: after pruning to those two,
|
||||
# require('@mariozechner/clipboard') still loads and exports all 18 functions.
|
||||
# esbuild likewise still compiles TS via transformSync at both install sites.
|
||||
# This removes dead weight, not function.
|
||||
#
|
||||
# MUST run in the SAME layer as the npm installs above: deleting in a later RUN
|
||||
# leaves the bytes in this layer and shrinks the image by nothing.
|
||||
# NOTE the single backslash in -printf '%f\n': Docker passes '\\n' through
|
||||
# verbatim, so v1.9.1's doubled version printed a mangled "li ux-arm64"
|
||||
# (find emitted a literal backslash, then `tr` translated the n out of the
|
||||
# name). Confirmed from the published image's own recorded created_by.
|
||||
prune_foreign_natives() { \
|
||||
arch="$(node -p process.arch)"; \
|
||||
find /usr/lib/node_modules /opt -type d -regex '.*/@esbuild/[^/]+' \
|
||||
! -name "linux-$arch" -prune -exec rm -rf {} + ; \
|
||||
find /usr/lib/node_modules /opt -type d -regex '.*/@mariozechner/clipboard-[^/]+' \
|
||||
! -name "clipboard-linux-$arch-gnu" ! -name "clipboard-linux-$arch-musl" \
|
||||
-prune -exec rm -rf {} + ; \
|
||||
echo "native platform dirs kept: $(find /usr/lib/node_modules /opt -type d \( -regex '.*/@esbuild/[^/]+' -o -regex '.*/@mariozechner/clipboard-[^/]+' \) -printf '%f\n' 2>/dev/null | sort | uniq -c | tr '\n' ' ')"; \
|
||||
} && \
|
||||
# purge_build_caches: the build's own download caches are NOT free — they land
|
||||
# in whichever layer created them. Measured on the published v1.9.1 arm64
|
||||
# variant layer: root/.npm/_cacache was 145.2 MB of a 401.9 MB layer (35.2 MB
|
||||
# in v1.8.14), the single biggest item in the +131 MB residual, because npm 11
|
||||
# caches every platform tarball it fetched — including the ones just pruned.
|
||||
# Nothing at runtime reads it: the build runs as root, the container runs as
|
||||
# `developer` with its own cache under $HOME (and $HOME/.pi is a volume).
|
||||
# DELIBERATELY NOT purged here: /tmp/node-compile-cache (1.3 MB, written by
|
||||
# `pi --version` below). The manifest RUN at the end of this file calls
|
||||
# `pi --version` again, so deleting it here only relocates those bytes into
|
||||
# that layer instead of removing them from the image — measured, not assumed:
|
||||
# today the manifest layer is 128 kB precisely because it finds the cache warm.
|
||||
purge_build_caches() { \
|
||||
npm cache clean --force >/dev/null 2>&1 || true; \
|
||||
rm -rf /root/.npm; \
|
||||
} && \
|
||||
if [ "${PI_VERSION}" = "latest" ]; then \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \
|
||||
else \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
|
||||
fi && \
|
||||
pi --version && \
|
||||
git_fetch_ref "${PI_TOOLKIT_REPO}" "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \
|
||||
git_fetch_ref "${PI_EXTENSIONS_REPO}" "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
|
||||
git_fetch_ref "${PI_FORK_REPO}" "${PI_FORK_REF}" /opt/pi-fork && \
|
||||
git_fetch_ref "${PI_OBSMEM_REPO}" "${PI_OBSMEM_REF}" /opt/pi-observational-memory && \
|
||||
git_fetch_ref "${PI_ATELIER_REPO}" "${PI_ATELIER_REF}" /opt/pi-atelier && \
|
||||
(cd /opt/pi-fork && npm install --omit=dev --no-audit --no-fund) && \
|
||||
(cd /opt/pi-observational-memory && npm install --omit=dev --no-audit --no-fund) && \
|
||||
prune_foreign_natives && \
|
||||
purge_build_caches && \
|
||||
echo "pi-toolkit at $(cd /opt/pi-toolkit && git rev-parse --short HEAD)" && \
|
||||
echo "pi-extensions at $(cd /opt/pi-extensions && git rev-parse --short HEAD)" && \
|
||||
echo "pi-fork at $(cd /opt/pi-fork && git rev-parse --short HEAD)" && \
|
||||
echo "pi-observational-memory at $(cd /opt/pi-observational-memory && git rev-parse --short HEAD)" && \
|
||||
echo "pi-atelier at $(cd /opt/pi-atelier && git rev-parse --short HEAD) (${PI_ATELIER_VERSION})"
|
||||
|
||||
# ── Image-baked skill refresh: pi-extensions (Option 1 over Option 2) ──
|
||||
# rootfs ships a VENDORED snapshot of the pi-extensions skill at
|
||||
# /usr/local/share/pi-devbox/skills/pi-extensions/ (the "floor" — guarantees the
|
||||
# skill is always in the image). The pi-extensions PACKAGE repo now co-locates
|
||||
# the canonical skill under skill/, so here — after the pinned clone — we copy
|
||||
# that over the snapshot. Result: a normal build ships the fresh, package-owned
|
||||
# copy (pinned + recorded in the manifest via PI_EXTENSIONS_REF); a build whose
|
||||
# ref predates the skill, or a fork pointing at a mirror without it, still ships
|
||||
# the committed snapshot. The skill calls ./evaluate-extension-usage.py, so it
|
||||
# is copied alongside. Idempotent and cache-safe (depends only on the clone).
|
||||
RUN if [ -f /opt/pi-extensions/skill/SKILL.md ]; then \
|
||||
cp /opt/pi-extensions/skill/SKILL.md \
|
||||
/usr/local/share/pi-devbox/skills/pi-extensions/SKILL.md && \
|
||||
if [ -f /opt/pi-extensions/skill/evaluate-extension-usage.py ]; then \
|
||||
cp /opt/pi-extensions/skill/evaluate-extension-usage.py \
|
||||
/usr/local/share/pi-devbox/skills/pi-extensions/evaluate-extension-usage.py ; \
|
||||
fi && \
|
||||
echo "refreshed pi-extensions skill from package @ $(cd /opt/pi-extensions && git rev-parse --short HEAD)" ; \
|
||||
else \
|
||||
echo "pi-extensions package has no skill/ at this ref — keeping vendored snapshot" ; \
|
||||
fi
|
||||
|
||||
# ── pi-devbox awareness: append our pointer to the global AGENTS.md ──
|
||||
# pi loads a SINGLE global instruction file (~/.pi/agent/AGENTS.md), which
|
||||
# pi-toolkit's install.sh re-symlinks to /opt/pi-toolkit/pi-global-AGENTS.md on
|
||||
# every container start. There is no second global slot, and that file is
|
||||
# root-owned (not writable by the runtime user), so we compose at BUILD time:
|
||||
# append the pi-devbox managed block to pi-toolkit's file here, after the clone.
|
||||
# Idempotent via a marker grep so a rebuilt layer never double-appends. This
|
||||
# makes every container proactively aware of the pi-devbox-environment skill;
|
||||
# the snippet itself is gated (only fires when /usr/local/lib/pi-devbox exists).
|
||||
RUN if [ -f /opt/pi-toolkit/pi-global-AGENTS.md ] && \
|
||||
! grep -q 'pi-devbox:managed-block' /opt/pi-toolkit/pi-global-AGENTS.md; then \
|
||||
printf '\n' >> /opt/pi-toolkit/pi-global-AGENTS.md && \
|
||||
cat /usr/local/share/pi-devbox/pi-global-AGENTS.append.md >> /opt/pi-toolkit/pi-global-AGENTS.md && \
|
||||
echo "appended pi-devbox block to pi-global-AGENTS.md" ; \
|
||||
else \
|
||||
echo "pi-devbox block already present or pi-global-AGENTS.md missing (skipped)" ; \
|
||||
fi
|
||||
|
||||
# ── Optional: pi-studio (:latest-studio variant) ─────────────────────
|
||||
# pi-studio (omaclaren/pi-studio) is a pi-package + theme providing a
|
||||
# two-pane browser workspace: prompt/response editor, KaTeX/Mermaid live
|
||||
# preview, and tmux-backed literate REPLs. Off by default; the studio
|
||||
# variant sets INSTALL_STUDIO=true.
|
||||
#
|
||||
# Vendored to /opt/pi-studio and registered at container start by
|
||||
# entrypoint-user.sh via `pi install /opt/pi-studio` — the SAME pattern
|
||||
# as pi-fork / pi-observational-memory above. We deliberately do NOT run
|
||||
# `pi install <git-url>` at build time: that writes into ~/.pi/agent,
|
||||
# which is a named volume, so a build-time install collides with / is
|
||||
# shadowed by the volume on first run. Vendoring to /opt (an image layer)
|
||||
# + a runtime local-path install keeps it on the image and idempotent.
|
||||
#
|
||||
# No build step is needed: pi-studio ships its browser bundle prebuilt in
|
||||
# git (client/studio-client.js) and pi loads index.ts directly; its
|
||||
# package.json scripts are only test/typecheck. So we just fetch + install
|
||||
# the 3 prod deps (@earendil-works/pi-ai, @sinclair/typebox, ws).
|
||||
#
|
||||
# PI_STUDIO_REF is CI-resolved to a commit SHA to defeat the registry-
|
||||
# buildcache cache-hit footgun (see the PI_VERSION note above).
|
||||
ARG INSTALL_STUDIO=false
|
||||
ARG PI_STUDIO_REPO=https://github.com/omaclaren/pi-studio.git
|
||||
ARG PI_STUDIO_REF=main
|
||||
# PI_STUDIO_VERSION is the human-readable tag (e.g. v0.9.36) that PI_STUDIO_REF
|
||||
# was resolved from; recorded as a label below for at-a-glance identification.
|
||||
# Only meaningful for the studio variant (default `none` otherwise).
|
||||
#
|
||||
# v1.8.13 — READ THIS BEFORE REASONING ABOUT WHICH pi-studio SHIPS. Neither
|
||||
# default below survives a CI build. `resolve-versions` in
|
||||
# .gitea/workflows/docker-publish.yml passes BOTH as build-args (studio_ref and
|
||||
# studio_tag), and it deliberately selects the newest STABLE semver tag: its
|
||||
# filter is `^v?[0-9]+\.[0-9]+\.[0-9]+$`, which excludes pre-releases. So a
|
||||
# PUBLISHED v1.8.13 studio image contains pi-studio v0.9.59 (commit 9eed84f,
|
||||
# = refs/tags/v0.9.59^{}), NOT the v0.9.60-rc.0 that `main` currently points at
|
||||
# (658536f). The `main` default here only applies to a local `docker build`
|
||||
# that passes no studio args.
|
||||
#
|
||||
# That upstream-tag-over-main choice is intentional and documented at the
|
||||
# resolve step: pi-studio keeps tagging every version but stopped publishing
|
||||
# GitHub Releases at v0.5.55 and pushes freely to main, so pinning main risked
|
||||
# baking half-finished commits that land after a tag.
|
||||
#
|
||||
# Corrected here on 2026-09-06 after reading the run-639 resolve-versions
|
||||
# output: the v1.8.13 audit had recorded "RC adopted deliberately" and set this
|
||||
# ARG to v0.9.60-rc.0, which was measured at the wrong layer — a Dockerfile
|
||||
# default cannot answer "what will CI publish?" when CI overrides it. Left at
|
||||
# `none` rather than pinned to a tag, because a hardcoded pre-release here goes
|
||||
# stale the moment main moves and would re-tell the same lie to the next reader.
|
||||
# Consequence worth keeping: the RC's opt-in Studio network binding is NOT in
|
||||
# any published v1.8.13 image, so it needs no audit for this release.
|
||||
ARG PI_STUDIO_VERSION=none
|
||||
RUN if [ "${INSTALL_STUDIO}" = "true" ]; then \
|
||||
set -e; \
|
||||
# Same prune + cache purge as the main install RUN — see the comments there.
|
||||
# They have to be redefined because shell functions do not survive across
|
||||
# layers, and they have to run in THIS layer because pi-studio's npm install
|
||||
# happens here: deleting in a later RUN would leave the bytes in this layer
|
||||
# and shrink nothing. pi-studio pulls its own pi-coding-agent copy, so it is
|
||||
# a third ~274 MB site on top of the two in the non-studio variant — and its
|
||||
# npm install refills /root/.npm, which the main RUN emptied in ITS layer.
|
||||
prune_foreign_natives() { \
|
||||
arch="$(node -p process.arch)"; \
|
||||
find /usr/lib/node_modules /opt -type d -regex '.*/@esbuild/[^/]+' \
|
||||
! -name "linux-$arch" -prune -exec rm -rf {} + ; \
|
||||
find /usr/lib/node_modules /opt -type d -regex '.*/@mariozechner/clipboard-[^/]+' \
|
||||
! -name "clipboard-linux-$arch-gnu" ! -name "clipboard-linux-$arch-musl" \
|
||||
-prune -exec rm -rf {} + ; \
|
||||
echo "native platform dirs kept: $(find /usr/lib/node_modules /opt -type d \( -regex '.*/@esbuild/[^/]+' -o -regex '.*/@mariozechner/clipboard-[^/]+' \) -printf '%f\n' 2>/dev/null | sort | uniq -c | tr '\n' ' ')"; \
|
||||
}; \
|
||||
purge_build_caches() { \
|
||||
npm cache clean --force >/dev/null 2>&1 || true; \
|
||||
rm -rf /root/.npm; \
|
||||
}; \
|
||||
rm -rf /opt/pi-studio && mkdir -p /opt/pi-studio && \
|
||||
git -C /opt/pi-studio init -q && \
|
||||
git -C /opt/pi-studio remote add origin "${PI_STUDIO_REPO}" && \
|
||||
ok=0; for i in 1 2 3 4 5; do \
|
||||
if git -C /opt/pi-studio fetch --depth 1 origin "${PI_STUDIO_REF}" && \
|
||||
git -C /opt/pi-studio checkout -q FETCH_HEAD; then ok=1; break; fi; \
|
||||
echo "git fetch pi-studio@${PI_STUDIO_REF} failed (attempt $i/5), retrying in $((i*5))s..."; \
|
||||
sleep $((i*5)); \
|
||||
done; \
|
||||
[ "$ok" = "1" ] && \
|
||||
(cd /opt/pi-studio && npm install --omit=dev --no-audit --no-fund) && \
|
||||
prune_foreign_natives && \
|
||||
purge_build_caches && \
|
||||
echo "pi-studio at $(cd /opt/pi-studio && git rev-parse --short HEAD)"; \
|
||||
fi
|
||||
|
||||
# STUDIO_PORT: advisory default consumed by docker-compose port publishing
|
||||
# and the recommended `/studio --no-browser --port "$STUDIO_PORT"` launch.
|
||||
# Harmless in the non-studio variant. NOTE: pi-studio hard-binds the server
|
||||
# to 127.0.0.1 inside the container (index.ts: .listen(port,"127.0.0.1")),
|
||||
# so reaching it from a browser needs a loopback bridge or host networking —
|
||||
# see the "Using pi-studio" section in README.md.
|
||||
ENV STUDIO_PORT=8765
|
||||
|
||||
# ── Optional: Go toolchain ───────────────────────────────────────────
|
||||
# Off by default; opt in for users who run Go tools inside the devbox.
|
||||
ARG INSTALL_GO=false
|
||||
ARG GO_VERSION=latest
|
||||
RUN if [ "${INSTALL_GO}" = "true" ]; then \
|
||||
GOARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${GO_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/?mode=json" | \
|
||||
awk -F'"' '/"version":/ { sub(/^go/,"",$4); print $4; exit }'); \
|
||||
fi && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing Go ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/go${V}.linux-${GOARCH}.tar.gz" | tar -C /usr/local -xz && \
|
||||
ln -s /usr/local/go/bin/go /usr/local/bin/go && \
|
||||
ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt; \
|
||||
fi
|
||||
|
||||
# ── Build provenance: OCI labels + on-disk build manifest ────────────
|
||||
# Records exactly which pi version and companion-repo commits were baked
|
||||
# into THIS image, so a published tag is self-describing and reproducible
|
||||
# after the fact (CI logs rotate; a released image must not depend on
|
||||
# them). Previously the resolved SHAs only ever reached the CI build log.
|
||||
#
|
||||
# These ARGs are declared LAST, immediately before the layer that uses
|
||||
# them, so a changing BUILD_DATE / RELEASE_TAG / SOURCE_REVISION never
|
||||
# invalidates the expensive pi-install / clone layers above.
|
||||
ARG RELEASE_TAG=dev
|
||||
ARG BUILD_DATE=
|
||||
ARG SOURCE_REVISION=
|
||||
# MEMPALACE_TOOLKIT_REF is consumed in Dockerfile.base; re-declared here
|
||||
# only so its intended ref lands in the label set alongside the others.
|
||||
ARG MEMPALACE_TOOLKIT_REF=main
|
||||
# ── Vendored skill provenance ─────────────────────────────────────────
|
||||
# The vendored mempalace SKILL.md is the ONLY baked artefact with no /opt
|
||||
# clone behind it: its upstream (the skillset repo) is PRIVATE, so the
|
||||
# image cannot clone it and CI cannot resolve its HEAD (see VENDORED.md).
|
||||
# Consequence through v1.8.7: the snapshot was ANONYMOUS — nothing in the
|
||||
# image or the repo recorded which skillset commit it was taken from, so
|
||||
# the only staleness check available was a hand-maintained phrase canary in
|
||||
# scripts/smoke-test.sh, which by construction can only detect "older than
|
||||
# what I remembered to pin", never "older than skillset main".
|
||||
#
|
||||
# Recording the ref costs nothing and makes the question answerable. It is
|
||||
# deliberately a plain ARG DEFAULT rather than a CI-resolved output:
|
||||
# * the value is a fact about the committed snapshot, so it belongs in
|
||||
# the tree next to it — not in a workflow that a local `docker build`
|
||||
# never runs (same reasoning as MEMPALACE_VERSION living in
|
||||
# Dockerfile.base rather than being duplicated in docker-publish.yml);
|
||||
# * CI therefore needs NO new build-arg at any of its four
|
||||
# Dockerfile.variant call sites (smoke, smoke-studio, build-variant,
|
||||
# build-variant-studio) — a plumbing change that is easy to
|
||||
# under-apply to only two of them;
|
||||
# * and it needs no credential for a private repo.
|
||||
# Bump it with scripts/vendor-mempalace-skill.sh, which refreshes the file
|
||||
# and rewrites this line together, so the pair cannot drift apart by hand.
|
||||
# This ARG lives in Dockerfile.variant ON PURPOSE: Dockerfile.base and
|
||||
# rootfs/ are both hashed into base_tag, so recording provenance here costs
|
||||
# no ~67-minute base rebuild. (scripts/check-base-hash.sh scans only
|
||||
# Dockerfile.base, so no folding into the base hash is required — nor would
|
||||
# it be correct, since this ARG changes nothing about the base's contents.)
|
||||
ARG SKILLSET_SNAPSHOT_REF=e9e45f7acdde490c3b5d24ce5f508bff8785c2c7
|
||||
|
||||
# Dockerfile.base sets description="pi-devbox — base image (variant-independent)"
|
||||
# and every variant INHERITS it, so both published images used to advertise
|
||||
# themselves on Docker Hub as the base image. A LABEL cannot branch on
|
||||
# INSTALL_STUDIO, so the description arrives as a build-arg: CI passes the
|
||||
# variant-specific string (see docker-publish.yml), and the default below keeps
|
||||
# a plain `docker build -f Dockerfile.variant` honest rather than misleading.
|
||||
ARG IMAGE_TITLE="pi-devbox"
|
||||
ARG IMAGE_DESCRIPTION="pi-devbox — development container for the pi coding agent"
|
||||
|
||||
LABEL org.opencontainers.image.version="${RELEASE_TAG}" \
|
||||
org.opencontainers.image.revision="${SOURCE_REVISION}" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
org.opencontainers.image.title="${IMAGE_TITLE}" \
|
||||
org.opencontainers.image.description="${IMAGE_DESCRIPTION}" \
|
||||
description="${IMAGE_DESCRIPTION}" \
|
||||
se.jordbo.pi-devbox.pi-version="${PI_VERSION}" \
|
||||
se.jordbo.pi-devbox.pi-toolkit-ref="${PI_TOOLKIT_REF}" \
|
||||
se.jordbo.pi-devbox.pi-extensions-ref="${PI_EXTENSIONS_REF}" \
|
||||
se.jordbo.pi-devbox.pi-fork-ref="${PI_FORK_REF}" \
|
||||
se.jordbo.pi-devbox.pi-obsmem-ref="${PI_OBSMEM_REF}" \
|
||||
se.jordbo.pi-devbox.pi-atelier-ref="${PI_ATELIER_REF}" \
|
||||
se.jordbo.pi-devbox.pi-atelier-version="${PI_ATELIER_VERSION}" \
|
||||
se.jordbo.pi-devbox.mempalace-toolkit-ref="${MEMPALACE_TOOLKIT_REF}" \
|
||||
se.jordbo.pi-devbox.pi-studio-ref="${PI_STUDIO_REF}" \
|
||||
se.jordbo.pi-devbox.pi-studio-version="${PI_STUDIO_VERSION}" \
|
||||
se.jordbo.pi-devbox.skillset-snapshot-ref="${SKILLSET_SNAPSHOT_REF}"
|
||||
|
||||
# The manifest is written from GROUND TRUTH — the actual checked-out HEAD
|
||||
# of each /opt clone and the live `pi --version` — not merely the intended
|
||||
# build-args. That way it also exposes a clone that silently resolved to
|
||||
# something other than the requested ref. pi-studio is present only in the
|
||||
# studio variant (JSON null otherwise).
|
||||
RUN set -e; \
|
||||
mkdir -p /etc/pi-devbox; \
|
||||
rev() { git -C "$1" rev-parse HEAD 2>/dev/null || echo "unknown"; }; \
|
||||
PI_V="$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')"; \
|
||||
# mempalace CORE (the PyPI package behind the MCP tools) is installed in
|
||||
# Dockerfile.base via `uv tool install`, so no /opt clone reveals it and
|
||||
# until v1.8.6 the manifest could not answer "which palace shipped here?" —
|
||||
# a palace bug could not be correlated to an image, which is precisely the
|
||||
# correlation this file exists to provide. Read from the INSTALLED BINARY,
|
||||
# not from ARG MEMPALACE_VERSION, per the ground-truth rule above: that is
|
||||
# what catches an install which resolved to something other than the pin.
|
||||
# `mempalace --version` prints "MemPalace 3.7.1" — NAME-PREFIXED, unlike
|
||||
# pi's bare "0.84.2" — hence the $NF pick rather than a straight read. The
|
||||
# leading-digit test then rejects usage/error text (a renamed flag prints a
|
||||
# usage block) and degrades to JSON null, so this can never fail the build.
|
||||
MP_V="$(mempalace --version 2>/dev/null | head -n1 | tr -d '\r' | awk '{print $NF}')"; \
|
||||
case "$MP_V" in [0-9]*) MP_CORE="\"${MP_V}\"" ;; *) MP_CORE='null' ;; esac; \
|
||||
STUDIO_REV='null'; \
|
||||
if [ -d /opt/pi-studio/.git ]; then STUDIO_REV="\"$(rev /opt/pi-studio)\""; fi; \
|
||||
# The vendored skill snapshot's fingerprint is MEASURED here, not passed
|
||||
# in as a build-arg, per the ground-truth rule above: SKILLSET_SNAPSHOT_REF
|
||||
# is a CLAIM about which skillset commit the file came from, while this
|
||||
# hash is what the image actually ships. Recorded together they let any
|
||||
# reader with the skillset checked out — which on this fleet is every
|
||||
# host, since all four compose stacks mount it — verify the claim at
|
||||
# RUNTIME, without CI ever needing access to the private repo. Degrades
|
||||
# to JSON null rather than failing the build if the directory is absent;
|
||||
# the smoke assertion is what turns that into a loud failure.
|
||||
#
|
||||
# Hashes the whole DIRECTORY, not just SKILL.md: a single-file hash
|
||||
# answers "did this one file change", not "is the live copy the same
|
||||
# skill" — a live checkout that added or edited a SIBLING file (a
|
||||
# reference/ doc, a helper script) would still report "identical to
|
||||
# baked snapshot" against a file-only hash. pi-extensions already ships
|
||||
# two files for exactly this reason (SKILL.md + evaluate-extension-usage.py),
|
||||
# so this is not a hypothetical. Deterministic over `find | sort`, never
|
||||
# readdir order: relative paths + per-file sha256, folded into one hash.
|
||||
# pi-devbox-version mirrors this exact pipeline over the live directory so
|
||||
# the two sides are comparable — if you change this, change that too.
|
||||
tree_sha256() { \
|
||||
( cd "$1" && find . -type f -print | LC_ALL=C sort | xargs -r sha256sum ) 2>/dev/null | sha256sum | cut -d' ' -f1; \
|
||||
}; \
|
||||
SKILL_SNAP='null'; \
|
||||
_snap_dir=/usr/local/share/pi-devbox/skills/mempalace; \
|
||||
if [ -d "$_snap_dir" ] && [ -n "$(find "$_snap_dir" -type f -print -quit)" ]; then \
|
||||
SKILL_SNAP="\"$(tree_sha256 "$_snap_dir")\""; \
|
||||
fi; \
|
||||
# ── WHICH pi-extensions skill copy actually shipped ──
|
||||
# Closes the silent-fallback hole. The refresh step above is guarded by
|
||||
# `[ -f /opt/pi-extensions/skill/SKILL.md ]`, so a build whose clone predates
|
||||
# the co-located skill (or a fork pointing at a mirror without it) keeps the
|
||||
# vendored floor and still succeeds — GREEN, with nothing anywhere recording
|
||||
# that a snapshot shipped instead of the package copy. Measured 2026-09-10:
|
||||
# the floor had been stale since 2026-07-30, so that fallback would have
|
||||
# shipped a six-week-old skill silently. The floor is fresh now and gated by
|
||||
# the skill-floor CI job, but "the fallback is currently harmless" is not the
|
||||
# same as "you can tell which copy you got", and only the second survives.
|
||||
#
|
||||
# MEASURED, never claimed, per the ground-truth rule above: the branch
|
||||
# condition is re-derived from the same test the refresh step used, and the
|
||||
# served bytes are then compared against the clone. A build-arg could not
|
||||
# express this at all, since the outcome depends on the clone's contents.
|
||||
# package served bytes == the clone's skill/ (the normal path)
|
||||
# vendored-floor the clone has no skill/ at this ref (fallback shipped)
|
||||
# divergent both exist but differ — e.g. the clone ships SKILL.md but
|
||||
# not evaluate-extension-usage.py, so the served directory is
|
||||
# a MIX of package and floor. Worth its own value: it is the
|
||||
# one state neither of the other two names honestly.
|
||||
# No OCI label mirrors this, deliberately: LABEL cannot take a value computed
|
||||
# in a RUN, and a label fed from an ARG would be exactly the claim-not-
|
||||
# measurement this block exists to avoid.
|
||||
_px_dir=/usr/local/share/pi-devbox/skills/pi-extensions; \
|
||||
PIEXT_SRC='null'; PIEXT_HASH='null'; \
|
||||
if [ -d "$_px_dir" ] && [ -n "$(find "$_px_dir" -type f -print -quit)" ]; then \
|
||||
PIEXT_HASH="\"$(tree_sha256 "$_px_dir")\""; \
|
||||
if [ -f /opt/pi-extensions/skill/SKILL.md ]; then \
|
||||
if [ "$(tree_sha256 "$_px_dir")" = "$(tree_sha256 /opt/pi-extensions/skill)" ]; then \
|
||||
PIEXT_SRC='"package"'; \
|
||||
else \
|
||||
PIEXT_SRC='"divergent"'; \
|
||||
fi; \
|
||||
else \
|
||||
PIEXT_SRC='"vendored-floor"'; \
|
||||
fi; \
|
||||
fi; \
|
||||
{ \
|
||||
echo '{'; \
|
||||
echo " \"release_tag\": \"${RELEASE_TAG}\","; \
|
||||
echo " \"build_date\": \"${BUILD_DATE}\","; \
|
||||
echo " \"source_revision\": \"${SOURCE_REVISION}\","; \
|
||||
echo " \"pi_version\": \"${PI_V}\","; \
|
||||
# Sibling of pi_version, NOT a member of components{}: that map holds git
|
||||
# SHAs and `pi-devbox-version` renders it with .value[0:12], which would
|
||||
# silently truncate a longer version string.
|
||||
echo " \"mempalace_version\": ${MP_CORE},"; \
|
||||
# Siblings, NOT members of components{}, for two independent reasons:
|
||||
# that map means "HEAD of a clone present in this image" and the
|
||||
# skillset is not cloned here (calling it a component would be a
|
||||
# lie a future reader would act on), and `pi-devbox-version` renders
|
||||
# every components{} value with .value[0:12] — which would truncate
|
||||
# a 64-hex sha256 into something that looks like a short commit.
|
||||
# Named `_tree_sha256`, not `_sha256`: it measures every file under the
|
||||
# vendored skill directory, not one file — see tree_sha256() above.
|
||||
echo " \"skillset_snapshot_ref\": \"${SKILLSET_SNAPSHOT_REF}\","; \
|
||||
echo " \"skillset_snapshot_tree_sha256\": ${SKILL_SNAP},"; \
|
||||
echo " \"pi_extensions_skill_source\": ${PIEXT_SRC},"; \
|
||||
echo " \"pi_extensions_skill_tree_sha256\": ${PIEXT_HASH},"; \
|
||||
echo " \"components\": {"; \
|
||||
echo " \"pi-toolkit\": \"$(rev /opt/pi-toolkit)\","; \
|
||||
echo " \"pi-extensions\": \"$(rev /opt/pi-extensions)\","; \
|
||||
echo " \"pi-fork\": \"$(rev /opt/pi-fork)\","; \
|
||||
echo " \"pi-observational-memory\": \"$(rev /opt/pi-observational-memory)\","; \
|
||||
echo " \"pi-atelier\": \"$(rev /opt/pi-atelier)\","; \
|
||||
echo " \"mempalace-toolkit\": \"$(rev /opt/mempalace-toolkit)\","; \
|
||||
echo " \"pi-studio\": ${STUDIO_REV}"; \
|
||||
echo " }"; \
|
||||
echo '}'; \
|
||||
} > /etc/pi-devbox/build-manifest.json; \
|
||||
echo "── build manifest ──"; cat /etc/pi-devbox/build-manifest.json
|
||||
|
||||
# WORKDIR / ENTRYPOINT / CMD inherited from base.
|
||||
@@ -0,0 +1,60 @@
|
||||
# Ideas & backlog
|
||||
|
||||
A living list of potential improvements for pi-devbox that are **not yet
|
||||
scheduled**. This is intentionally lightweight — a place to park ideas so they
|
||||
aren't lost between sessions. When an item ships, describe it in
|
||||
[`CHANGELOG.md`](CHANGELOG.md) and remove it from here.
|
||||
|
||||
Rough effort tags: 🟢 small · 🟡 medium · 🔴 large. Status: `idea` (unvetted) ·
|
||||
`planned` (agreed, not started).
|
||||
|
||||
---
|
||||
|
||||
## Supply-chain hardening
|
||||
|
||||
- 🟡 `planned` — **Pin CI actions to commit SHAs.** The workflows use floating
|
||||
major tags (`actions/checkout@v4`, `docker/build-push-action@v7`,
|
||||
`docker/setup-buildx-action@v4`, `docker/login-action@v3`,
|
||||
`docker/setup-qemu-action@v3`). This is inconsistent with the project's own
|
||||
philosophy of SHA-pinning *content* refs (pi, pi-studio, pi-fork, …) to defeat
|
||||
floating refs. Pin each action to a SHA with a trailing `# vX.Y.Z` comment.
|
||||
Pairs naturally with the renovate item below to keep the pins fresh.
|
||||
|
||||
- 🟡 `planned` — **Vulnerability scanning in CI.** No CVE scan runs on the
|
||||
published images today. Add a `trivy image` (or grype) job to
|
||||
`docker-publish.yml` after `smoke`. Start non-blocking (report only), then
|
||||
tighten to fail on `HIGH`/`CRITICAL` with an available fix.
|
||||
|
||||
- 🟢🟡 `planned` — **Standardize build provenance → buildx SBOM + attestations.**
|
||||
The image already carries hand-rolled provenance (OCI labels +
|
||||
`build-manifest`). `docker/build-push-action` can emit a standard SBOM and
|
||||
SLSA provenance attestation nearly for free (`provenance: mode=max`,
|
||||
`sbom: true`). Makes provenance machine-consumable and pairs well with the
|
||||
trivy item (scan the SBOM).
|
||||
|
||||
## Dockerfile hardening
|
||||
|
||||
- 🟡 `idea` — **Address hadolint DL4006 properly.** Currently ignored in
|
||||
`.hadolint.yaml`. The clean fix is `SHELL ["/bin/bash", "-o", "pipefail",
|
||||
"-c"]` so piped `RUN`s fail on the first non-zero stage. This changes the
|
||||
default `RUN` shell from `sh` to `bash` for all subsequent layers, so it is
|
||||
base-affecting and needs a careful pass over existing `RUN`s before removing
|
||||
the ignore.
|
||||
|
||||
## Developer experience
|
||||
|
||||
- 🟢 `idea` — **`Makefile`/`justfile` for local iteration.** Reproducing a CI
|
||||
build locally means hand-assembling many `--build-arg`s. Thin targets
|
||||
(`make build-base`, `make build-variant`, `make smoke`, `make lint`) would
|
||||
make local testing painless and document the canonical invocations.
|
||||
|
||||
- 🟡 `idea` — **Dependency-update automation (renovate).** With CI actions
|
||||
SHA-pinned (above), a `renovate.json` keeps those pins — plus the pinned tool
|
||||
versions (`ACTIONLINT_VERSION`, `HADOLINT_VERSION`, gosu, etc.) — current via
|
||||
automated PRs. Requires a renovate runner against the Gitea instance.
|
||||
|
||||
## Housekeeping
|
||||
|
||||
- 🟢 `idea` — **Registry retention for `base-<hash>` tags.** The base-hash
|
||||
caching scheme accumulates `base-<hash>` tags over time. Confirm whether the
|
||||
registry prunes old ones, and add a retention/cleanup step if not.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Joakim Persson
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Third-party notices
|
||||
|
||||
pi-devbox is distributed under the MIT License (see [`LICENSE`](LICENSE)), which
|
||||
covers **this repository's own contents** — the Dockerfiles, entrypoint scripts,
|
||||
`rootfs/` seeds, CI workflows, and docs.
|
||||
|
||||
The **published container images** (`joakimp/pi-devbox:*`) additionally *bundle*
|
||||
third-party software, each of which remains under its own license. This file is
|
||||
a good-faith summary; the authoritative sources are the upstream projects and,
|
||||
for OS packages, the per-package copyright files inside the image at
|
||||
`/usr/share/doc/<package>/copyright`.
|
||||
|
||||
## pi and its extensions (installed in the variant layer)
|
||||
|
||||
| Component | Upstream | License |
|
||||
| --- | --- | --- |
|
||||
| pi (`@earendil-works/pi-coding-agent`) | npm | MIT |
|
||||
| pi-fork | github.com/elpapi42/pi-fork | MIT |
|
||||
| pi-observational-memory | github.com/elpapi42/pi-observational-memory | MIT |
|
||||
| pi-studio *(`-studio` variant only)* | github.com/omaclaren/pi-studio | MIT |
|
||||
| pi-atelier | github.com/michaelmjhhhh/pi-atelier | MIT |
|
||||
| pi-toolkit, pi-extensions, mempalace-toolkit | authored by the maintainer (Joakim Persson) | MIT |
|
||||
|
||||
## MemPalace (AI memory)
|
||||
|
||||
| Component | Upstream | License |
|
||||
| --- | --- | --- |
|
||||
| mempalace (core, MCP server) | github.com/MemPalace/mempalace (PyPI: `mempalace`) | MIT — the GitHub repo declares MIT; the PyPI package's own metadata omits a license classifier, so if you need clearance from the package artifact alone, verify against the repo's `LICENSE` file rather than the sdist/wheel metadata |
|
||||
|
||||
## Browser automation
|
||||
|
||||
| Component | Upstream | License |
|
||||
| --- | --- | --- |
|
||||
| agent-browser | github.com/vercel-labs/agent-browser (npm: `agent-browser`) | Apache-2.0 |
|
||||
| Playwright | github.com/microsoft/playwright (npm: `playwright`) | Apache-2.0 |
|
||||
| Chromium | chromium.googlesource.com/chromium/src | BSD-3-Clause for Chromium's own code, plus a large set of bundled third-party components each under their own license (see Chromium's own `LICENSE`/`about:credits`). The binary in this image is **not compiled here** — it is the build Playwright downloads for its pinned version ("Chrome for Testing"), installed via `playwright install --with-deps chromium` at `/usr/local/share/ms-playwright/`. Treat Playwright's own distribution terms for that build as authoritative over any summary here. |
|
||||
|
||||
## Tooling baked into the base image
|
||||
|
||||
| Component | Upstream | License (best effort) |
|
||||
| --- | --- | --- |
|
||||
| gosu | github.com/tianon/gosu | Apache-2.0 |
|
||||
| Node.js | nodejs.org | MIT (bundles components under their own licenses) |
|
||||
| uv | github.com/astral-sh/uv | Apache-2.0 OR MIT |
|
||||
| Neovim | neovim.io | Apache-2.0 + Vim license |
|
||||
| Pandoc | pandoc.org | GPL-2.0-or-later |
|
||||
| Typst | github.com/typst/typst | Apache-2.0 |
|
||||
| ripgrep / fd / micro / tealdeer / yq (mikefarah) | respective repos | MIT / Apache-2.0 / Unlicense (varies) |
|
||||
|
||||
## Base OS
|
||||
|
||||
The image is built `FROM` a Debian base and installs packages via `apt`. Debian
|
||||
and its packages are distributed under their respective licenses (GPL, LGPL,
|
||||
MIT, BSD, and others). See each package's copyright file in the image under
|
||||
`/usr/share/doc/<package>/copyright`.
|
||||
|
||||
---
|
||||
|
||||
*Licenses marked "best effort" are widely known but were not each verified at
|
||||
the exact bundled version; consult the upstream project for authoritative
|
||||
terms. Corrections welcome.*
|
||||
@@ -0,0 +1,111 @@
|
||||
# Shared MemPalace server (optional) — one palace for many clients.
|
||||
#
|
||||
# Runs `mempalace-mcp` over HTTP so several containers/harnesses (pi +
|
||||
# opencode + native) can share ONE palace instead of each keeping its own.
|
||||
# Point every client at it by setting, in that client's .env:
|
||||
#
|
||||
# MEMPALACE_REMOTE_URL=http://<reachable-host>:8765/mcp
|
||||
# MEMPALACE_REMOTE_TOKEN=<the shared bearer token>
|
||||
#
|
||||
# (see .env.example). When set, the client connects over HTTP and does NOT
|
||||
# spawn its own local mempalace-mcp.
|
||||
#
|
||||
# Start: docker compose -f docker-compose.mempalace.yml up -d
|
||||
# Stop: docker compose -f docker-compose.mempalace.yml down
|
||||
# Logs: docker compose -f docker-compose.mempalace.yml logs -f
|
||||
#
|
||||
# Why reuse the devbox image? mempalace-mcp is already installed in it, and
|
||||
# reusing it GUARANTEES the server's mempalace version matches the clients'
|
||||
# (both are pinned by the same image build). Override with a slimmer image via
|
||||
# MEMPALACE_SERVER_IMAGE if you prefer (it must provide `mempalace-mcp`).
|
||||
#
|
||||
# ⚠ SECURITY: the HTTP transport IS authenticated as of mempalace 3.6.0 — an
|
||||
# earlier version of this comment said otherwise and was wrong. The server
|
||||
# compares `Authorization: Bearer <token>` with hmac.compare_digest and
|
||||
# **refuses to start on a non-loopback bind without a token**, so
|
||||
# MEMPALACE_REMOTE_TOKEN below is required, not optional: without it this
|
||||
# service crash-loops. It also pins `Host` and allowlists `Origin`.
|
||||
#
|
||||
# Still do not publish port 8765 to an untrusted network. The default binds to
|
||||
# 127.0.0.1 (host loopback) only. To let sibling containers reach it, attach
|
||||
# them to the shared `mempalace-net` network (container-to-container, no host
|
||||
# port needed — use http://mempalace-server:8765/mcp). To reach it from
|
||||
# elsewhere, terminate TLS in a tunnel/reverse proxy and let the bearer token be
|
||||
# the authentication — do NOT add browser-shaped auth (SSO/PIN/password) in
|
||||
# front, because every MCP client here is a headless JSON-RPC POST and would
|
||||
# receive a login page where JSON should be.
|
||||
|
||||
name: mempalace-server
|
||||
|
||||
services:
|
||||
mempalace:
|
||||
image: ${MEMPALACE_SERVER_IMAGE:-joakimp/pi-devbox:latest}
|
||||
container_name: mempalace-server
|
||||
# Bypass the devbox entrypoint (dev-shell/LAN/config setup) and run the
|
||||
# HTTP MCP server directly. HOME + explicit --palace pin the data path so
|
||||
# it does not depend on the image's default user/HOME. Runs as root so it
|
||||
# can initialise the fresh named volume; the volume is dedicated to this
|
||||
# server (clients reach it over HTTP, never by mounting it).
|
||||
entrypoint: []
|
||||
user: "0:0"
|
||||
environment:
|
||||
- HOME=/data
|
||||
# Required: mempalace refuses a non-loopback bind without a token (it
|
||||
# would exit at startup and, with restart:unless-stopped, crash-loop).
|
||||
# `:?` fails fast at `docker compose up` with a readable message instead.
|
||||
# Clients send the same value as MEMPALACE_REMOTE_TOKEN.
|
||||
- MEMPALACE_MCP_HTTP_TOKEN=${MEMPALACE_REMOTE_TOKEN:?set MEMPALACE_REMOTE_TOKEN in .env — the shared palace requires a bearer token}
|
||||
command:
|
||||
- mempalace-mcp
|
||||
- --transport
|
||||
- http
|
||||
- --host
|
||||
- "0.0.0.0"
|
||||
- --port
|
||||
- "8765"
|
||||
- --palace
|
||||
- /data/.mempalace
|
||||
restart: unless-stopped
|
||||
# Loopback-only by default (see SECURITY note). Use "8765:8765" to expose on
|
||||
# all host interfaces, or drop `ports:` entirely and rely on mempalace-net.
|
||||
ports:
|
||||
- "127.0.0.1:8765:8765"
|
||||
volumes:
|
||||
# The shared palace data — precious; back this up.
|
||||
- mempalace-shared:/data/.mempalace
|
||||
# Embedding-model cache (~79 MB, disposable) so search does not re-download.
|
||||
- mempalace-shared-chroma:/data/.cache/chroma
|
||||
# Transcript inbox. Clients cannot mine into a remote palace directly:
|
||||
# `mempalace_mine` expands its source path in THIS process, so it can only
|
||||
# see paths inside this container. Each client rsyncs its staged session
|
||||
# exports to a per-device subdirectory on the host (see
|
||||
# MEMPALACE_PI_SSH_TARGET in .env.example) and then calls mempalace_mine
|
||||
# with the container-side path below (MEMPALACE_PI_REMOTE_PATH=/data/feed).
|
||||
# Read-only: mining only reads sources, and all locks live palace-side.
|
||||
- ${MEMPALACE_FEED_DIR:-./feed}:/data/feed:ro
|
||||
networks:
|
||||
- mempalace-net
|
||||
healthcheck:
|
||||
# GET /healthz, which is Host/Origin-gated but deliberately token-free —
|
||||
# so this probe needs no credentials. Do NOT go back to POSTing
|
||||
# `tools/list` here: that carries no Authorization header and now 401s,
|
||||
# marking a perfectly healthy server unhealthy forever. The Host pin is
|
||||
# only enforced on loopback *binds* (this one is 0.0.0.0), so a request to
|
||||
# 127.0.0.1 inside the container passes.
|
||||
test:
|
||||
- CMD
|
||||
- python3
|
||||
- -c
|
||||
- "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8765/healthz',timeout=5).status==200 else 1)"
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 3
|
||||
start_period: 60s
|
||||
|
||||
volumes:
|
||||
mempalace-shared:
|
||||
mempalace-shared-chroma:
|
||||
|
||||
networks:
|
||||
mempalace-net:
|
||||
name: mempalace-net
|
||||
+32
-5
@@ -16,8 +16,14 @@ services:
|
||||
# To build from source instead of pulling from Docker Hub:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile.variant
|
||||
# args:
|
||||
# PI_VERSION: "latest"
|
||||
# # Pin a specific base build by hash instead of tracking base-latest:
|
||||
# BASE_IMAGE: "joakimp/pi-devbox:base-<hash>"
|
||||
# # PI_VERSION must be a concrete version, not 'latest', to defeat
|
||||
# # the registry-buildcache cache-hit footgun. CI resolves this from
|
||||
# # the npm registry; for a local build you can set it manually.
|
||||
# PI_VERSION: "0.79.1"
|
||||
container_name: pi-devbox
|
||||
stdin_open: true
|
||||
tty: true
|
||||
@@ -25,9 +31,13 @@ services:
|
||||
- .env
|
||||
environment:
|
||||
- TERM=xterm-256color
|
||||
- GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
|
||||
- GITEA_HOST=${GITEA_HOST:-}
|
||||
- GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_PERSONAL_ACCESS_TOKEN:-}
|
||||
# Secrets (GITEA_*, GITHUB_*, and any others) are delivered to the
|
||||
# container via `env_file: .env` above — do NOT duplicate them here.
|
||||
# An `environment:` entry overrides env_file AND is interpolated from
|
||||
# the host shell, so a stale shell export (e.g. one auto-loaded by a
|
||||
# dotenv hook) would silently shadow the value in your .env. Keeping
|
||||
# secrets env_file-only decouples the container from the host shell.
|
||||
# See .env.example for the full list of supported variables.
|
||||
volumes:
|
||||
# Host workspace — mount your project here
|
||||
- ${WORKSPACE_PATH:-.}:/workspace
|
||||
@@ -35,12 +45,25 @@ services:
|
||||
# SSH keys (read-only) — for git push/pull
|
||||
- ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro
|
||||
|
||||
# Optional: host-owned shell config + LAN jump overrides. The image's
|
||||
# ~/.bash_aliases sources ~/.config/devbox-shell/bash_aliases if present,
|
||||
# and setup-lan-access.sh reads ~/.config/devbox-shell/ssh-lan.conf for
|
||||
# named-peer `ProxyJump host` overrides (reach LAN peers by name via
|
||||
# `dssh <peer>`; see opencode-devbox's ssh-lan.conf.example).
|
||||
# - ~/.config/devbox-shell:/home/developer/.config/devbox-shell:ro
|
||||
|
||||
# Optional: mount skillset repo for automatic skill/instruction deployment.
|
||||
# - ${SKILLSET_PATH}:/home/developer/skillset
|
||||
|
||||
# Persist pi config (settings.json, extensions, sessions, auth)
|
||||
- devbox-pi-config:/home/developer/.pi
|
||||
|
||||
# Persist the generated LAN-jump keypair (~/.ssh-local) across recreates.
|
||||
# setup-lan-access.sh generates this key once and reuses it; persisting
|
||||
# it means you authorize it on the host ONCE rather than re-authorizing
|
||||
# after every `docker compose up --force-recreate`.
|
||||
- devbox-ssh-local:/home/developer/.ssh-local
|
||||
|
||||
# Persist bash history across container recreations
|
||||
- devbox-shell-history:/home/developer/.cache/bash
|
||||
|
||||
@@ -53,7 +76,10 @@ services:
|
||||
# Persist uv data (Python installs, tool installs)
|
||||
- devbox-uv:/home/developer/.local/share/uv
|
||||
|
||||
# Optional: persist MemPalace data (conversation memory, knowledge graph)
|
||||
# Optional: persist MemPalace data (conversation memory, knowledge graph).
|
||||
# Applies to the LOCAL palace only (the default). In EXTERNAL mode
|
||||
# (MEMPALACE_REMOTE_URL set in .env) the shared server owns the data, so
|
||||
# this volume is irrelevant.
|
||||
# - devbox-palace:/home/developer/.mempalace
|
||||
|
||||
# Optional: persist ChromaDB embedding model cache (~79 MB)
|
||||
@@ -64,6 +90,7 @@ services:
|
||||
|
||||
volumes:
|
||||
devbox-pi-config:
|
||||
devbox-ssh-local:
|
||||
devbox-shell-history:
|
||||
devbox-zoxide:
|
||||
devbox-nvim-data:
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
# Design: single-writer MemPalace broker (cross-host serialization)
|
||||
|
||||
> **Status:** DRAFT / RFC — not yet implemented. Captures the design so it can be
|
||||
> picked up later. Authored 2026-06-14.
|
||||
> **Owner:** unassigned. **Tracking:** queue item #4 ("host-side mempalace-mcp
|
||||
> daemon over a UNIX/shared socket").
|
||||
|
||||
## Problem
|
||||
|
||||
The pi-devbox container's `~/.mempalace` (`/home/developer/.mempalace`) is a
|
||||
**virtiofs bind-mount of the host's `/Users/joakim/.mempalace`** (verified
|
||||
2026-06-14 via `/proc/mounts`: `mac /home/developer/.mempalace virtiofs rw`).
|
||||
Container pi and host-native pi therefore **read and write ONE shared palace** —
|
||||
full memory parity already exists; nothing needs to be built to *enable* sharing.
|
||||
|
||||
The actual hazard is the opposite of sharing: **concurrency**. Two pi processes
|
||||
(one native on the host, one in the container) can open the same
|
||||
`chroma.sqlite3` / `knowledge_graph.sqlite3` and write at the same time. The
|
||||
palace directory already shows the scars of this:
|
||||
|
||||
- `chroma.sqlite3.broken-20260505`
|
||||
- many `*.corrupt-20260528`
|
||||
- a long run of `*.drift-2026*`
|
||||
- `locks/` with `mine_palace_*.lock` files, including a **stale** one.
|
||||
|
||||
These are mempalace's defensive lock + auto-snapshot/repair machinery firing
|
||||
under concurrent access.
|
||||
|
||||
### Why a shared lock file is NOT sufficient
|
||||
|
||||
The container runs inside a Linux VM (OrbStack / Docker Desktop on macOS); the
|
||||
palace bytes live on the macOS host, surfaced into the VM via virtiofs.
|
||||
Consequences:
|
||||
|
||||
- A **UNIX-domain socket file** visible at `~/.mempalace/broker.sock` inside the
|
||||
container is a *host-kernel* object. The container's kernel can see the inode
|
||||
but **cannot connect to it** across the VM boundary.
|
||||
- **flock / advisory lockfiles are not coherent across the host↔VM boundary.**
|
||||
A lock taken on the host is not reliably seen in the container and vice-versa.
|
||||
(The stale `mine_palace_*.lock` is direct evidence the existing lock scheme is
|
||||
not bulletproof across this boundary.)
|
||||
|
||||
**Therefore the only trustworthy serialization is to route every write through a
|
||||
single process.** That single process is the broker. The design question is *not*
|
||||
"how do we lock" — it's "**where does the one writer live, and how does every pi
|
||||
(host or container) reach it across the VM boundary?**"
|
||||
|
||||
## Goals
|
||||
|
||||
1. Exactly one process opens the palace SQLite files at any time (single writer;
|
||||
concurrent reads are fine).
|
||||
2. Works in all three topologies on a given host:
|
||||
- native pi only,
|
||||
- native pi + container pi,
|
||||
- container pi only.
|
||||
3. pi configuration is **identical** in every topology (no per-environment MCP
|
||||
config divergence).
|
||||
4. No new corruption pathway introduced; degrade safely when the broker is
|
||||
genuinely unreachable and there are no peers.
|
||||
|
||||
### Non-goals (for this iteration)
|
||||
|
||||
- opencode / opencode-devbox co-existence (see "Co-existence with opencode"
|
||||
below — deferred until the pi case is solved).
|
||||
- Multi-host palace replication. This is about one host's local palace.
|
||||
- Changing mempalace's on-disk format or its public MCP tool surface.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
pi (host) ─stdio─► mp-shim ─┐
|
||||
├─► mempalace-broker ─► chroma.sqlite3
|
||||
pi (ctr) ─stdio─► mp-shim ─┘ (SINGLE owner; knowledge_graph.sqlite3
|
||||
serialized writer, + in-memory HNSW index
|
||||
concurrent readers)
|
||||
```
|
||||
|
||||
### `mempalace-broker`
|
||||
|
||||
A long-lived process that is the **only** opener of the palace SQLite files. It:
|
||||
|
||||
- runs the real mempalace engine,
|
||||
- holds the HNSW index in memory,
|
||||
- pushes all mutations through a single writer queue (reads may fan out),
|
||||
- exposes the mempalace MCP JSON-RPC surface over one or more transports,
|
||||
- is the canonical owner of palace state for the lifetime of the host session.
|
||||
|
||||
**Bonus:** a single always-resident owner also eliminates the stale-HNSW-index
|
||||
problem that `mempalace_reconnect` exists to work around — there is never an
|
||||
external writer to desync the in-memory index against.
|
||||
|
||||
### `mp-shim`
|
||||
|
||||
A tiny stdio↔transport adapter. pi's mempalace MCP config points at the shim
|
||||
**everywhere, unchanged**. pi still believes it is speaking stdio MCP to a local
|
||||
server; the shim forwards JSON-RPC to the broker over whichever transport is
|
||||
available, and handles all discovery / startup / election complexity. Keeping
|
||||
pi's config identical across topologies is a hard requirement (goal #3) and the
|
||||
shim is what makes it possible.
|
||||
|
||||
## Canonical owner = the host
|
||||
|
||||
The broker's home is **always the host**, because:
|
||||
|
||||
1. The palace bytes physically live there (`/Users/joakim/.mempalace`).
|
||||
2. The host outlives any container — ownership does not evaporate on
|
||||
`docker compose down`.
|
||||
3. Containers already have a route back to it (`host.docker.internal` and the
|
||||
verified dssh ControlMaster bridge).
|
||||
|
||||
The broker binds **two listeners feeding one queue**:
|
||||
|
||||
- **AF_UNIX** at `$MEMPALACE_PATH/broker.sock` — for host-native pi (fast,
|
||||
filesystem-perms-secured).
|
||||
- a **cross-boundary** transport for container clients (below).
|
||||
|
||||
## Transport matrix
|
||||
|
||||
| Topology | Broker runs on | Host pi reaches it via | Container pi reaches it via |
|
||||
|---|---|---|---|
|
||||
| native only | host | AF_UNIX socket | — |
|
||||
| native + container | host | AF_UNIX socket | SSH-forwarded socket (preferred) or TCP |
|
||||
| container only | host (started via bridge) | — | SSH-forwarded socket or TCP |
|
||||
|
||||
### Cross-boundary transport options
|
||||
|
||||
**(a) SSH-forwarded UNIX socket over the existing dssh ControlMaster — PREFERRED.**
|
||||
The container's `setup-lan-access.sh` already establishes a ControlMaster to the
|
||||
host with `ControlPersist 4h`. The container shim forwards the host broker socket
|
||||
over that master:
|
||||
|
||||
```
|
||||
ssh -F ~/.ssh-local/config \
|
||||
-L "$XDG_RUNTIME_DIR/mp.sock:$HOME/.mempalace/broker.sock" host
|
||||
```
|
||||
|
||||
then connects to the local forwarded socket. Auth = SSH key; nothing is
|
||||
LAN-exposed; no extra shared secret needed; rides the persistent master so setup
|
||||
cost is near-zero. Most portable across non-OrbStack hosts.
|
||||
|
||||
**(b) TCP on `host.docker.internal:PORT` — fallback.** Simpler, but the broker
|
||||
must bind a routable interface (not just `127.0.0.1`), which requires a
|
||||
**shared-secret token** to prevent other local/LAN processes from talking to it.
|
||||
The token is written to `broker.json` in the virtiofs-mounted palace dir
|
||||
(readable from both sides). More care required to get the bind + auth right.
|
||||
|
||||
## Discovery + on-demand start (the shim's algorithm)
|
||||
|
||||
Run by the shim on every pi session start, so it is correct regardless of who is
|
||||
already running:
|
||||
|
||||
```
|
||||
1. If $MEMPALACE_BROKER is set → use it verbatim (escape hatch).
|
||||
2. Read $MEMPALACE_PATH/broker.json → endpoint + pid + token.
|
||||
Try to connect (UNIX if host; forwarded-sock / TCP if container).
|
||||
If connected & healthy → done.
|
||||
3. Broker not reachable → START IT:
|
||||
- On host: flock($MEMPALACE_PATH/broker.lock, non-blocking)
|
||||
win → exec broker, wait for broker.json, connect.
|
||||
lose → someone else is starting it; backoff + retry connect.
|
||||
- In container: run `ssh host 'mempalace-broker --ensure'` (idempotent;
|
||||
performs the SAME flock election ON THE HOST), then forward +
|
||||
connect.
|
||||
4. Last-resort fallback (no broker, cannot start one):
|
||||
open the palace DIRECTLY — but ONLY after asserting this process is the sole
|
||||
writer (no other live broker/pid recorded in broker.json). Degrades to
|
||||
today's behaviour for the genuinely-alone case; never used when a broker
|
||||
exists.
|
||||
```
|
||||
|
||||
**Key trick:** host-side election uses `flock` on the host, where it is coherent
|
||||
(same kernel) — bulletproof. The cross-boundary case **never relies on cross-VM
|
||||
locking**; it relies on `ssh host 'broker --ensure'`, which runs the election on
|
||||
the host where flock works. That is what makes the design topology-independent.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
- Broker writes `broker.json` (endpoint + pid + token) **atomically** after
|
||||
binding.
|
||||
- Broker holds `broker.lock` for its entire lifetime → at most one host broker.
|
||||
- Idle-exit after N minutes with no connected clients; the next client
|
||||
re-elects. (Or keep-alive; idle-exit is friendlier on resources.)
|
||||
- Clients reclaim a stale lock if the pid recorded in `broker.json` is dead.
|
||||
- Clients retry with backoff while a broker is mid-startup.
|
||||
|
||||
## Engine vs. shim — what the image must still ship
|
||||
|
||||
The component bundled in the images today is really **two separable pieces**:
|
||||
|
||||
- the **mempalace engine** — opens the SQLite files, computes embeddings, owns
|
||||
the HNSW index (the heavy part: chromadb, embedding model, etc.), and
|
||||
- the thin client surface pi actually talks to.
|
||||
|
||||
In the brokered design these split cleanly:
|
||||
|
||||
- the **broker** is the only thing that runs the *engine*;
|
||||
- the **shim** is **engine-free** — it just forwards MCP JSON-RPC. It needs no
|
||||
chromadb, no embedding model, no heavy deps. Embeddings/search happen
|
||||
broker-side. (Potential image-slimming opportunity, though see below for why
|
||||
we keep the engine bundled anyway.)
|
||||
|
||||
Whether the bundled engine is "used as-is" or merely fronted by the broker
|
||||
**depends on who owns the broker**:
|
||||
|
||||
**A) Host runs the broker (native, or native+container — the common case).**
|
||||
The *host's* engine is authoritative and used as-is. The broker is purely an
|
||||
intermediate step so writes can't collide; the host engine does the read/write.
|
||||
The container's **bundled engine is dormant** — the container uses only its shim
|
||||
to reach the host broker. The engine in the image is not needed for this path.
|
||||
|
||||
**B) Container lands on a host with no mempalace (fresh-host case).**
|
||||
The bundled engine earns its keep — you cannot conjure an engine onto the host
|
||||
without installing one. Either the container runs the broker *itself*
|
||||
(in-container ownership, bundled engine used as-is) or it falls back to degraded
|
||||
direct mode (single writer, bundled engine used directly).
|
||||
|
||||
**Decision: keep shipping the engine in the images** — but for three specific
|
||||
reasons, not because the brokered path needs it:
|
||||
|
||||
1. **Self-containedness** — pi-devbox's promise is "works on any host." A
|
||||
container with no memory unless the host pre-installed mempalace breaks that,
|
||||
especially for the Docker Hub audience.
|
||||
2. **Fresh-host bootstrap** (case B) — no host engine to borrow.
|
||||
3. **Degraded fallback** — the no-broker-reachable path opens the DB locally and
|
||||
needs the engine present.
|
||||
|
||||
In the host-managed common case the bundled engine is just dormant insurance;
|
||||
the shim is the only piece the container actively uses.
|
||||
|
||||
### Version-coherence note
|
||||
|
||||
Because **only the broker's engine ever writes**, its version defines the
|
||||
on-disk format. Host-vs-bundled engine version skew is therefore **harmless in
|
||||
the brokered path** (only one engine ever touches the bytes). Skew only bites in
|
||||
**degraded direct mode**, where the container writes with a possibly-different
|
||||
engine version than the host would. This argues for the broker pinning/owning
|
||||
the authoritative engine version and treating the bundled engine as
|
||||
fallback-only.
|
||||
|
||||
> Partially resolves the "where the broker binary ships" open question below:
|
||||
> the **shim** must ship on both sides; the **engine** must ship on the host
|
||||
> (to run the broker) and stays bundled in the image as fallback/bootstrap
|
||||
> insurance, not as the authoritative writer in the common case.
|
||||
|
||||
## The genuinely hard case
|
||||
|
||||
**Container-only with no SSH bridge configured** (e.g. plain Linux Docker,
|
||||
`HOST_SSH_USER` unset, no `host.docker.internal`). The container cannot start or
|
||||
reach a host broker. Options, none free:
|
||||
|
||||
1. **Require the bridge** for multi-writer container setups, and document it as a
|
||||
precondition. Reasonable: pi-devbox already ships `setup-lan-access.sh` and
|
||||
the bridge is the supported path.
|
||||
2. **Run the broker inside the container**, publishing a Docker port the host can
|
||||
later reach. Works, but inverts ownership and the broker dies with the
|
||||
container — only acceptable if containers are the *sole* writers on that host.
|
||||
3. **Accept degraded mode** (algorithm step 4): a lone container with no peers
|
||||
has no concurrency, so direct access is safe *as long as* nothing else opens
|
||||
the palace concurrently. The host shim also checks `broker.json` before
|
||||
opening directly, so a later host pi will not silently start a second
|
||||
uncoordinated writer.
|
||||
|
||||
**Summary:** fully robust for native-only, native+container, and
|
||||
container-only-with-bridge. The only residual sharp edge is container-only
|
||||
*without* a bridge *and* a future concurrent host writer — intrinsic (no shared
|
||||
coherent lock exists across that boundary), best handled by mandating the bridge
|
||||
rather than pretending file locks work.
|
||||
|
||||
## Co-existence with opencode / opencode-devbox (DEFERRED — context only)
|
||||
|
||||
The palace is shared by more than pi. opencode (native) and opencode-devbox
|
||||
(container) also write to the same `~/.mempalace`. **Assumption to verify:**
|
||||
opencode sessions write to **different wings** than pi sessions (pi uses
|
||||
`wing_pi`, diaries per-agent, etc.), so cross-tool intermixing into the *same*
|
||||
destination may be a non-issue at the application level.
|
||||
|
||||
However, the corruption risk here is at the **SQLite-file level, not the wing
|
||||
level** — two processes writing different wings of the *same* `chroma.sqlite3`
|
||||
concurrently is still a concurrent write to one file. So the broker, once it
|
||||
exists, is the right serialization point for opencode too: opencode's mempalace
|
||||
client would route through the same broker via the same shim mechanism.
|
||||
|
||||
**Decision:** do not design for opencode co-existence yet. Resolve the pi case
|
||||
first; then revisit whether opencode clients adopt the same shim. The residual
|
||||
risk in the interim is native + container *opencode* sessions writing the same
|
||||
palace simultaneously — explicitly deferred ("cross that bridge later").
|
||||
|
||||
## Open questions / TODO before implementation
|
||||
|
||||
- Does the mempalace engine expose an embeddable entrypoint suitable for running
|
||||
inside a long-lived broker, or does the broker wrap the existing MCP server
|
||||
binary and multiplex stdio clients onto it? (Affects whether reads can truly
|
||||
fan out or are also serialized.)
|
||||
- Idle-exit timeout default + whether to expose it via env.
|
||||
- `broker.json` schema + atomic-write + stale-pid-reclaim details.
|
||||
- TCP-path token handling and safe bind interface selection on Linux Docker
|
||||
(`--add-host=host.docker.internal:host-gateway`).
|
||||
- Where the broker binary ships: baked into `Dockerfile.base`? host install via
|
||||
pi-toolkit / mempalace-toolkit? Both, since both sides need the shim and the
|
||||
host needs the broker.
|
||||
- Smoke-test plan: prove single-writer invariant under a deliberate concurrent
|
||||
host+container write storm (should produce zero `.corrupt`/`.drift` snapshots).
|
||||
@@ -0,0 +1,379 @@
|
||||
# Observational memory — why this image has it, and what it does for you
|
||||
|
||||
**Audience:** anyone using this container for long pi sessions who has wondered
|
||||
what `recall`, `/om:status` and "compacted memory" are, or whether they should
|
||||
leave any of it switched on.
|
||||
|
||||
**Companion documents:** the extension ships its own reference docs at
|
||||
`/opt/pi-observational-memory/docs/` —
|
||||
[`concepts.md`](https://github.com/elpapi42/pi-observational-memory/blob/main/docs/concepts.md)
|
||||
(the model),
|
||||
[`how-it-works.md`](https://github.com/elpapi42/pi-observational-memory/blob/main/docs/how-it-works.md)
|
||||
(hooks and internals) and
|
||||
[`configuration.md`](https://github.com/elpapi42/pi-observational-memory/blob/main/docs/configuration.md)
|
||||
(every setting). Pi's own compaction mechanics are in
|
||||
`/usr/lib/node_modules/@earendil-works/pi-coding-agent/docs/compaction.md`.
|
||||
Those are normative; this document is the **deployment** view — what is pinned
|
||||
here, how it is wired, what it costs, and how it differs from MemPalace. For the
|
||||
palace, see
|
||||
[`mempalace-toolkit/docs/fleet-memory.md`](https://gitea.jordbo.se/joakimp/mempalace-toolkit/src/branch/main/docs/fleet-memory.md).
|
||||
|
||||
> Verified on pi-devbox **v1.8.9** (`release_tag v1.8.9`, source `aac4a1c`),
|
||||
> which bakes pi-observational-memory **v3.0.4** at commit `ce9fc98` — the value
|
||||
> in `/etc/pi-devbox/build-manifest.json` → `components.pi-observational-memory`.
|
||||
> Every number below was read from that tree, from pi's own docs, or from the
|
||||
> live container. The pi-side mechanics were first read at pi **0.84.3** and
|
||||
> re-checked at **0.84.4** (v1.8.12), which moved one of them — see §3.
|
||||
|
||||
---
|
||||
|
||||
## 1. The problem it solves
|
||||
|
||||
A long pi session outgrows the model's context window. Pi's answer is
|
||||
**compaction**: fold the older part of the conversation into a summary and keep
|
||||
recent messages verbatim. That is unavoidable, and it is where sessions go
|
||||
wrong — the summary is produced *at the moment of pressure*, by a model, about a
|
||||
transcript that is about to leave the context.
|
||||
|
||||
Observational memory changes *when* the remembering happens. Instead of
|
||||
summarising in a panic at the end, it keeps a small **ledger** up to date while
|
||||
the session runs, and compaction then just folds that ledger.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A0["plain compaction"] --> A1["context fills"]
|
||||
A1 --> A2["a model summarises<br/>under pressure"]
|
||||
A2 --> A3["prose summary,<br/>no way back"]
|
||||
B0["with observational<br/>memory"] --> B1["context fills"]
|
||||
B1 --> B2["ledger written<br/>as you work"]
|
||||
B2 --> B3["compaction folds<br/>the ledger"]
|
||||
B3 --> B4["ids you can<br/>recall"]
|
||||
```
|
||||
|
||||
Top row is pi on its own: one model call at the worst possible moment, detail
|
||||
chosen in a hurry, and the original wording gone from view. Bottom row is this
|
||||
image's default: the thinking happened earlier on a cheap model, the fold is
|
||||
deterministic, and every line in the result carries an id that resolves back to
|
||||
the exact source.
|
||||
|
||||
## 2. The mental model: three layers and a ledger
|
||||
|
||||
| Layer | What it is | Example |
|
||||
|---|---|---|
|
||||
| **Observation** | a timestamped, source-backed event from the conversation | "user rejected option B because it needs a base rebuild" |
|
||||
| **Reflection** | a durable conclusion *backed by* observations | "the user optimises for avoiding 67-minute rebuilds" |
|
||||
| **Drop** | a tombstone retiring an observation from active memory | the superseded detail of a bug that is now fixed |
|
||||
|
||||
These are appended to the session as silent ledger entries
|
||||
(`om.observations.recorded`, `om.reflections.recorded`,
|
||||
`om.observations.dropped`) and **folded** — replayed in order — to produce the
|
||||
memory state. The ledger is the source of truth; what you see in a compacted
|
||||
session is a rendering of it.
|
||||
|
||||
Two properties follow, and both matter later:
|
||||
|
||||
- **The ledger itself costs no context.** Those entries are pi `custom` entries,
|
||||
which *"do not participate in LLM context"* (pi `docs/session-format.md`). They
|
||||
sit in the session file and reach the model only via the fold at compaction.
|
||||
- **Memory is branch-local.** A pi session is a tree (resume, fork), and the fold
|
||||
follows the current branch only, so a forked branch does not inherit another
|
||||
branch's view.
|
||||
|
||||
## 3. The lifecycle
|
||||
|
||||
Three background workers and one compaction hook, driven by *raw token
|
||||
progress* rather than wall-clock time. Defaults in brackets.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
T(["turn_end"]) --> O{"10k raw tokens<br/>since observing?"}
|
||||
O -- yes --> OBS["<b>observer</b> runs"]
|
||||
O -- "no" --> R{"20k tokens<br/>since reflecting?"}
|
||||
R -- yes --> REF["<b>reflector</b> runs"]
|
||||
REF -- "if pool over 10k" --> DR["<b>dropper</b> prunes"]
|
||||
S(["agent_settled"]) --> C{"81k tokens<br/>since compacting?"}
|
||||
C -- yes --> CP["ctx.compact()"]
|
||||
CP --> H(["session_before_compact"])
|
||||
A(["pi autoCompact<br/>idle, or mid-run<br/>after a tool batch"]) --> H
|
||||
H --> F["fold the ledger<br/>no model call"]
|
||||
F --> VIS["compacted memory"]
|
||||
```
|
||||
|
||||
- **observer** — `observeAfterTokens` [10000]: writes observations for the
|
||||
conversation it has not covered yet.
|
||||
- **reflector** — `reflectAfterTokens` [20000]: promotes patterns across
|
||||
observations into durable reflections.
|
||||
- **dropper** — no clock of its own. It is post-reflection maintenance, gated on
|
||||
a *successful same-turn* reflection **and** an active pool above
|
||||
`observationsPoolTargetTokens` [10000]. Not a third worker on a third
|
||||
threshold.
|
||||
- **compaction** — `compactAfterTokens` [81000], checked at `agent_settled`, so
|
||||
*this* trigger never interrupts a turn. Pi will also compact on its own when
|
||||
the context is nearly full (`contextTokens > contextWindow - reserveTokens`,
|
||||
`reserveTokens` [16384]), and **from pi 0.84.4 that check also runs mid-run** —
|
||||
after a tool batch's results are appended, before the next assistant response,
|
||||
skipped only when the batch ends the run and no queued message needs another
|
||||
response. So `session_before_compact` has **two** entry points and the second
|
||||
one can fire *inside* a turn. Harmless for the fold itself, which makes no
|
||||
model call, but worth stating plainly: "never interrupts a turn" was only ever
|
||||
true of the observational-memory trigger, and reads as a promise about pi's.
|
||||
|
||||
## 4. What compaction actually does to your context
|
||||
|
||||
This is the question the rest of the document used to leave hanging: if the old
|
||||
conversation is folded away, is the session back to knowing nothing?
|
||||
|
||||
**No.** Compaction replaces *part* of the context, not all of it, and it deletes
|
||||
nothing at all from disk.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
SYS["system prompt<br/>+ AGENTS.md"] --> CTX["what the model sees<br/>on the next turn"]
|
||||
SUM["folded memory:<br/>reflections + observations"] --> CTX
|
||||
TAIL["recent turns,<br/>verbatim"] --> CTX
|
||||
DISK[("session .jsonl: all of it")] -. "recall(id)" .-> CTX
|
||||
```
|
||||
|
||||
Where each piece comes from:
|
||||
|
||||
- **System prompt and `AGENTS.md` — never compacted, because they were never
|
||||
conversation.** Pi rebuilds them from disk on every request
|
||||
(`loadContextFileFromDir`), so they cannot be lost by compaction.
|
||||
- **The verbatim tail — sized by a token budget, not a message count.** Pi walks
|
||||
backwards from the newest entry accumulating token estimates until
|
||||
`keepRecentTokens` [20000] is reached; that entry becomes `firstKeptEntryId`,
|
||||
and *everything from there on is kept unchanged*. Cut points land on turn
|
||||
boundaries, never mid-tool-call. So the most recent ~20k tokens of real work —
|
||||
your last instructions, the diffs, the test output — survive word for word.
|
||||
- **The folded memory — replaces only what came before that cut.** Rendered from
|
||||
the ledger's records: reflections and observations, each with its 12-hex id.
|
||||
- **The session file — untouched.** Compaction *appends* a `compaction` entry
|
||||
(`{"type":"compaction", summary, firstKeptEntryId, tokensBefore, …}`) and
|
||||
rebuilds context from it on later turns. Nothing is rewritten in place; the
|
||||
only documented way to remove session content is deleting the whole `.jsonl`.
|
||||
|
||||
That last point is what makes the answer to "is the detail gone?" *no* rather
|
||||
than *mostly*: `recall` does not read the context window at all. It calls
|
||||
`sessionManager.getBranch()` — the full branch from the root — and resolves an
|
||||
observation id back to the original entries. Detail that left the model's view
|
||||
an hour ago is still one `recall` away.
|
||||
|
||||
**Repeated compaction does not summarise the summary.** The rendered text is
|
||||
always built from live observation/reflection *records*, never from the previous
|
||||
compaction's prose, so there is no generation-loss spiral. (Mechanically the
|
||||
projection is incremental — it re-derives back to the last full-fold boundary and
|
||||
carries the rest forward, escalating to a genuine re-fold from the branch root
|
||||
when the observation pool reaches `observationsPoolMaxTokens` [20000].)
|
||||
|
||||
So the honest summary of the state after compaction: **the model keeps its
|
||||
instructions, keeps recent work verbatim, trades older turns for a dense
|
||||
id-carrying digest of them, and can pull any of it back on demand.** Not a fresh
|
||||
start — a smaller, cheaper, still-navigable one.
|
||||
|
||||
### One caveat about "no model call"
|
||||
|
||||
If the ledger is empty — compaction fires before the observer has ever run — the
|
||||
hook returns nothing and *declines ownership*, and pi's own model-based
|
||||
summariser runs instead:
|
||||
|
||||
```ts
|
||||
const summary = renderSummary(projection.reflections, projection.observations);
|
||||
if (summary.length === 0) {
|
||||
// Decline ownership so Pi's native summarizer preserves the pre-cut context.
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
In steady state (any session old enough to have produced one observation) om's
|
||||
hook wins and compaction is model-free. "Never calls a model" is true in practice
|
||||
and false in principle; the fallback is deliberate, so an empty ledger degrades
|
||||
to normal pi rather than to no summary at all.
|
||||
|
||||
## 5. What you actually get
|
||||
|
||||
- **Compaction stops being a stall.** In steady state the latency path is
|
||||
deterministic work over ledger entries, not a summarisation call.
|
||||
- **Nothing important vanishes silently.** Compaction is lossy by design, but
|
||||
every item keeps a 12-character id, and `recall(<id>)` returns the exact
|
||||
evidence — original wording, reasoning, file path, error text.
|
||||
- **The bookkeeping runs on a cheaper model than your session.** In this image
|
||||
that is deliberate and visible (§7): background workers on Haiku, session on
|
||||
Opus.
|
||||
- **It is automatic.** No habit to maintain, unlike the palace protocol — which is
|
||||
exactly why the two complement each other (§11).
|
||||
- **Forks stay clean.** Branch-local memory means a `fork` sub-agent's noise does
|
||||
not leak into the parent's folded memory.
|
||||
|
||||
## 6. `recall` is not a search tool
|
||||
|
||||
`recall` takes **one specific 12-hex id** that already appears in compacted
|
||||
memory or in `/om:view`. It cannot be given a topic. It can return an observation
|
||||
(marked `active` or `dropped`), or a reflection together with the observations
|
||||
supporting it.
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant M as compacted memory
|
||||
participant A as agent
|
||||
participant L as ledger
|
||||
M->>A: "[high] user rejected option B (a1b2c3d4e5f6)"
|
||||
A->>L: recall("a1b2c3d4e5f6")
|
||||
L-->>A: exact observation + source ids
|
||||
Note over A: acts on the original wording
|
||||
```
|
||||
|
||||
The rule of thumb the agent skill uses: recall **before a load-bearing action**
|
||||
that rests on a compressed memory — shipping a change, asserting a fact,
|
||||
answering "why do you believe that". One recall is cheap; redoing finished work
|
||||
is not.
|
||||
|
||||
## 7. How it is wired in this image
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
IMG["baked in the image:<br/>v3.0.4 @ ce9fc98"] --> REG["settings.json<br/>packages[]"]
|
||||
REG --> SESS["your pi session"]
|
||||
SESS -- "your turns" --> SM["session model:<br/>Opus"]
|
||||
SESS -- "observer, reflector,<br/>dropper" --> WM["memory model:<br/>Haiku"]
|
||||
SESS -- "ledger entries" --> JL["session .jsonl"]
|
||||
JL --> VOL[("devbox-pi-config<br/>volume")]
|
||||
```
|
||||
|
||||
Four consequences of that wiring:
|
||||
|
||||
1. **There is no separate database.** Memory *is* entries inside the ordinary pi
|
||||
session file (`~/.pi/agent/sessions/<project>/<timestamp>_<uuid>.jsonl`).
|
||||
Nothing extra to back up, nothing to migrate.
|
||||
2. **It survives container recreate**, because `~/.pi` is the `devbox-pi-config`
|
||||
named volume (`docker-compose.yml`) — the same one holding your pi config and
|
||||
session history.
|
||||
3. **`packages[]` is the only source of truth for which copy is loaded.** A clone
|
||||
at `/workspace/pi-observational-memory` may exist (and today matches `/opt`
|
||||
byte-for-byte at `ce9fc98`) — its presence proves nothing. To run a patched
|
||||
build you point `packages[]` at it explicitly and start a new session.
|
||||
4. **The worker model is a deliberate choice, and it is yours to change.** The
|
||||
seeded config sends background work to Haiku while your session runs Opus:
|
||||
|
||||
```json
|
||||
"observational-memory": {
|
||||
"model": { "provider": "amazon-bedrock", "id": "eu.anthropic.claude-haiku-4-5-20251001-v1:0" },
|
||||
"debugLog": false
|
||||
}
|
||||
```
|
||||
|
||||
## 8. What it costs
|
||||
|
||||
| Resource | Cost |
|
||||
|---|---|
|
||||
| Model calls | up to **three** background calls per consolidation pass (observer, reflector, dropper), each capped at `agentMaxTurns` [16], on the configured memory model — not your session model |
|
||||
| Latency in your turns | none by construction: workers run from `turn_end`, compaction runs when pi is idle, and the fold itself does no model work |
|
||||
| Disk | negligible — JSON lines inside a session file that would exist anyway (measured here: `~/.pi/agent/sessions` = 30 MB total, tens of `om.*` entries per session) |
|
||||
| Context window | **zero until compaction.** `custom` entries do not enter LLM context; only the folded summary does |
|
||||
| Attention | none once configured; there is no protocol for you or the agent to remember |
|
||||
|
||||
If that is still more than you want on a given run, §9's `passive` switch turns
|
||||
off all proactive work while keeping `recall` and `/om:*` usable.
|
||||
|
||||
## 9. Configuration
|
||||
|
||||
Global: `~/.pi/agent/settings.json` (persisted in the volume). Per project:
|
||||
`<project>/.pi/settings.json`, which overrides global. Precedence is
|
||||
project → global → environment, and the environment can only override `passive`.
|
||||
|
||||
| Key | Default | What it changes |
|
||||
|---|---|---|
|
||||
| `observeAfterTokens` | `10000` | observer cadence — lower means smaller chunks and more calls |
|
||||
| `reflectAfterTokens` | `20000` | reflector cadence (and thereby dropper opportunities) |
|
||||
| `observerChunkMaxTokens` | 20% of the memory model's context window, else `60000` | cap on one observer run's input |
|
||||
| `compactAfterTokens` | `81000` | when proactive auto-compaction fires |
|
||||
| `observationsPoolMaxTokens` | `20000` | pool size at which compaction does a full re-fold from the branch root |
|
||||
| `observationsPoolTargetTokens` | half of max (`10000`) | what the dropper aims back down to |
|
||||
| `agentMaxTurns` | `16` | shared turn cap for the three workers |
|
||||
| `model` | unset → session model | send background work to a cheaper/faster model |
|
||||
| `showWorkerNotifications` | `true` | routine "observer ran" notices |
|
||||
| `passive` | `false` | **kill switch** for all proactive background work; `recall` and `/om:*` still work |
|
||||
| `debugLog` | `false` | per-session NDJSON trace at `~/.pi/agent/observational-memory/debug/<session-id>.ndjson` |
|
||||
|
||||
Pi's own compaction knobs live under a separate `compaction` key —
|
||||
`keepRecentTokens` [20000] sets the verbatim tail from §4, `reserveTokens`
|
||||
[16384] the headroom that triggers pi's own compaction.
|
||||
|
||||
One-off passive run, no config edit:
|
||||
|
||||
```bash
|
||||
PI_OBSERVATIONAL_MEMORY_PASSIVE=1 pi
|
||||
```
|
||||
|
||||
Invalid values are ignored rather than fatal, so a typo degrades to the default
|
||||
instead of breaking your session — which also means a typo is silent. Check with
|
||||
`/om:status`.
|
||||
|
||||
## 10. Confirming it is actually working
|
||||
|
||||
Do not infer health from the absence of a warning; look:
|
||||
|
||||
```bash
|
||||
# 1. inside pi — the authoritative view
|
||||
/om:status # visible-vs-full drift, thresholds, worker state
|
||||
/om:view # what the agent currently sees
|
||||
/om:view full # full ledger truth at the branch tip
|
||||
|
||||
# 2. from a shell — are ledger entries being written, and has it compacted?
|
||||
grep -o '"customType":"om\.[a-z.]*"' \
|
||||
"$(ls -t ~/.pi/agent/sessions/*/*.jsonl | head -1)" | sort | uniq -c
|
||||
grep -c '"type":"compaction"' "$(ls -t ~/.pi/agent/sessions/*/*.jsonl | head -1)"
|
||||
|
||||
# 3. which copy is loaded, and at what commit
|
||||
python3 -c "import json;print(json.load(open('$HOME/.pi/agent/settings.json'))['packages'])"
|
||||
git -c safe.directory=/opt/pi-observational-memory -C /opt/pi-observational-memory rev-parse HEAD
|
||||
```
|
||||
|
||||
Ledger entries are `"type":"custom"` with `"customType":"om.…"`. Do not grep for
|
||||
`custom_message` — that is a *different* pi API for entries that **do** enter LLM
|
||||
context, used here by the MemPalace mailbox (`customType: "mempalace-mailbox"`),
|
||||
not by om.
|
||||
|
||||
## 11. It is not the same thing as MemPalace
|
||||
|
||||
Both are called "memory" and they solve different problems. Nothing is wrong with
|
||||
running both — this image does, and they cover each other's failure modes.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
O0["observational<br/>memory"] --> O1["horizon:<br/>this session"]
|
||||
O1 --> O2["scope: one branch,<br/>one machine"]
|
||||
O2 --> O3["automatic"]
|
||||
O3 --> O4["retrieval:<br/>recall(id)"]
|
||||
P0["MemPalace"] --> P1["horizon: months,<br/>machines"]
|
||||
P1 --> P2["scope:<br/>the fleet"]
|
||||
P2 --> P3["protocol-driven"]
|
||||
P3 --> P4["retrieval:<br/>search, KG, mailbox"]
|
||||
```
|
||||
|
||||
| Question | Answer |
|
||||
|---|---|
|
||||
| "What did we decide 200 turns ago in *this* session?" | observational memory (and `recall` for the exact wording) |
|
||||
| "What did we decide last month, or on another machine?" | MemPalace (`mempalace_search`, diaries) |
|
||||
| "What is true *right now* about version X?" | MemPalace knowledge graph |
|
||||
| "Does another machine need something from me?" | MemPalace coordination log — see [Cross-machine agent coordination](../README.md#cross-machine-agent-coordination) |
|
||||
| "Why is compaction not losing my session?" | observational memory |
|
||||
|
||||
The crisp version: **observational memory keeps a session coherent; the palace
|
||||
keeps the fleet coherent.** A container recreate wipes neither — but only because
|
||||
`~/.pi` and the palace both live outside the container filesystem.
|
||||
|
||||
## 12. Gotchas
|
||||
|
||||
- **Branch-local means branch-local.** Resuming or forking changes which ledger
|
||||
is folded. Memory that "disappeared" is usually on another branch.
|
||||
- **`recall` needs an id, not a topic.** If you only have a topic, that is a
|
||||
palace search, not a recall.
|
||||
- **A `/workspace` clone is not evidence of what is loaded** — see §7.3.
|
||||
- **`showWorkerNotifications: true` is not proof of work**; it reports runs, and
|
||||
an observer that deliberately emits nothing writes no ledger entry and simply
|
||||
retries after another `observeAfterTokens`.
|
||||
- **A turn bigger than `keepRecentTokens` splits.** The cut then lands mid-turn at
|
||||
an assistant message and pi merges two summaries — rare, but it is why a very
|
||||
large single turn can lose more verbatim detail than you would expect.
|
||||
- **`git log` in the baked tree needs `safe.directory`** (`/opt` is root-owned):
|
||||
`git -c safe.directory=/opt/pi-observational-memory -C /opt/pi-observational-memory log`.
|
||||
Executable
+562
@@ -0,0 +1,562 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ── Startup banner: which pi-devbox build is this? ─────────────────
|
||||
# Printed FIRST, before the setup noise below, so it's the first thing
|
||||
# visible when the container starts (CMD is `bash -l`, tty:true in compose,
|
||||
# so this reaches the same stream as the interactive shell the user lands
|
||||
# in). Reads the ground-truth manifest baked in Dockerfile.variant; a no-op
|
||||
# with a short stderr notice on images built before it existed.
|
||||
# `--no-skills`: this runs FIRST, before the baked skill links are created
|
||||
# below and long before the skillset deploy + devbox-skill-reconcile run at the
|
||||
# end of this script, so the skill-source section would report a pre-reconcile
|
||||
# state that is about to change. Wrong-but-plausible is worse than absent.
|
||||
command -v pi-devbox-version >/dev/null 2>&1 && pi-devbox-version --no-skills || true
|
||||
|
||||
# ── SSH ControlMaster socket dir ────────────────────────────────
|
||||
# Companion to /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the
|
||||
# base image — that file declares ControlPath=/tmp/sshcm/%r@%h:%p; this
|
||||
# creates the directory with the right permissions on every container
|
||||
# start. /tmp is per-container so the dir doesn't survive recreation;
|
||||
# baking it into a Dockerfile layer would be wrong.
|
||||
# Mode 700 is required — OpenSSH refuses to use a ControlPath dir that
|
||||
# others can write to.
|
||||
mkdir -p /tmp/sshcm
|
||||
chmod 700 /tmp/sshcm
|
||||
|
||||
# ── LAN access + writable SSH sidecar: host-OS-agnostic helper ──────
|
||||
# Generates the writable ~/.ssh-local/config on EVERY host OS: a `Host *`
|
||||
# ControlPath redirect into ~/.ssh-local/cm (so `ssh -F` / dssh / dscp work
|
||||
# even when ~/.ssh is bind-mounted read-only) plus `Include ~/.ssh/config`. On
|
||||
# VM-backed hosts (macOS OrbStack / Docker Desktop) it ALSO adds an
|
||||
# SSH-jump-via-host block so the container can reach the host's
|
||||
# directly-attached LAN peers; on native Linux (LAN reachable directly) the
|
||||
# jump block is omitted but the sidecar is still rendered. Controlled by
|
||||
# DEVBOX_LAN_ACCESS (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the
|
||||
# script header.
|
||||
if [ -r /usr/local/lib/pi-devbox/setup-lan-access.sh ]; then
|
||||
bash /usr/local/lib/pi-devbox/setup-lan-access.sh || true
|
||||
fi
|
||||
|
||||
# ── Shell defaults: copy baked files from /etc/skel-devbox/ if absent
|
||||
# Respects host bind-mounts and user customizations — existing files
|
||||
# are never overwritten. To restore defaults: rm ~/.bash_aliases (or
|
||||
# .inputrc) and recreate the container, or cp from /etc/skel-devbox/
|
||||
# directly.
|
||||
SKEL_DIR="/etc/skel-devbox"
|
||||
if [ -d "$SKEL_DIR" ]; then
|
||||
for f in .bash_aliases .inputrc .gitignore_global; do
|
||||
if [ -f "$SKEL_DIR/$f" ] && [ ! -e "$HOME/$f" ]; then
|
||||
cp "$SKEL_DIR/$f" "$HOME/$f"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── Image-baked skills: link into ~/.agents/skills ───────────────────
|
||||
# Skills shipped IN the image (under /usr/local/share/pi-devbox/skills/) are
|
||||
# made available regardless of whether a skillset repo is mounted. Done EARLY
|
||||
# — before the pi-toolkit/extensions deploy below — so the symlinks exist by
|
||||
# the time anything gates on "container ready": the smoke-test readiness probe
|
||||
# waits on pi-deploy markers (keybindings.json, mempalace.ts) that only land
|
||||
# AFTER this point, so linking here closes a sample-too-early race that failed
|
||||
# the runtime skill-link assertion. Pointing at the image path (/usr/local/...)
|
||||
# keeps the skill fresh from the image and surviving volume recreate (unlike
|
||||
# anything baked under a home dir, which a named volume would shadow). Created
|
||||
# only when absent, so a user override is never clobbered.
|
||||
#
|
||||
# NB: "created only when absent" does NOT hand a same-named skillset skill
|
||||
# priority — the opposite. The skillset deploy runs at the end of this script
|
||||
# and classifies these links as foreign, so through v1.8.4 the BAKED copy
|
||||
# always won and an edit pushed to a skillset-owned skill was invisible until
|
||||
# the next image build. The links below are therefore the FALLBACK only;
|
||||
# devbox-skill-reconcile (invoked right after the skillset deploy) hands the
|
||||
# skillset-OWNED skills back to the live clone. Ownership is per-skill, listed
|
||||
# in skills/skillset-owned.txt — see VENDORED.md for why pi-extensions must
|
||||
# keep losing to the baked copy.
|
||||
DEVBOX_SKILLS_SRC=/usr/local/share/pi-devbox/skills
|
||||
if [ -d "$DEVBOX_SKILLS_SRC" ]; then
|
||||
mkdir -p "$HOME/.agents/skills"
|
||||
for _sk in "$DEVBOX_SKILLS_SRC"/*/; do
|
||||
[ -d "$_sk" ] || continue
|
||||
_skname=$(basename "$_sk")
|
||||
if [ ! -e "$HOME/.agents/skills/$_skname" ]; then
|
||||
# -sfn, not -s: `[ ! -e ]` is TRUE for a DANGLING symlink (-e follows the
|
||||
# link), and since v1.8.5 these links can point into /workspace/skillset
|
||||
# (see devbox-skill-reconcile, invoked after the skillset deploy). If that
|
||||
# mount vanishes while the writable layer survives — a `docker restart` or
|
||||
# a host reboot under restart: unless-stopped, as opposed to a recreate —
|
||||
# plain `ln -s` fails with "File exists" and, under `set -e`, aborts
|
||||
# container start before `exec "$@"`. With -f the broken link heals back to
|
||||
# the baked fallback, and the reconciler re-points it in the same boot if
|
||||
# the clone is back.
|
||||
ln -sfn "${_sk%/}" "$HOME/.agents/skills/$_skname"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── MemPalace: initialize palace for the workspace if mempalace is installed
|
||||
# Creates the palace directory structure on first run. Idempotent — skips
|
||||
# if palace already exists, so upgrades from older versions preserve
|
||||
# existing data. `--yes` auto-accepts detected entities so the init is
|
||||
# non-interactive.
|
||||
if command -v mempalace &>/dev/null && [ -d /workspace ]; then
|
||||
# Read the root from the same variable mempalace itself reads (set as an
|
||||
# image ENV in Dockerfile.base since mempalace 3.10.0 started resolving
|
||||
# ~/.config/mempalace for an EMPTY ~/.mempalace). The fallback keeps the
|
||||
# historical location for anyone running this script with the ENV unset;
|
||||
# the point of naming the variable here is that this test and mempalace's
|
||||
# own resolution can no longer disagree about where the palace lives — a
|
||||
# disagreement that would make this branch fire on every start.
|
||||
PALACE_DIR="${MEMPALACE_CONFIG_DIR:-${HOME}/.mempalace}"
|
||||
if [ ! -d "$PALACE_DIR/palace" ]; then
|
||||
echo "Initializing MemPalace for workspace (non-interactive)..."
|
||||
# </dev/null: mempalace init has an interactive "Mine this directory
|
||||
# now? [Y/n]" prompt that --yes does not auto-answer in all paths.
|
||||
# Without redirected stdin, the process blocks here forever when run
|
||||
# from `docker run -it` (the TTY keeps stdin open). EOF on stdin
|
||||
# makes the prompt fall through to its default (skip).
|
||||
mempalace init --yes /workspace </dev/null >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── MemPalace: pi transcript feeder ─────────────────────────────────
|
||||
# mempalace-toolkit ships `mempalace-pi-session`, which mines pi's own JSONL
|
||||
# session transcripts into the palace. pi's mempalace extension drives it on
|
||||
# session_shutdown and on a debounced agent_settled; this is the catch-up for
|
||||
# the one case no handler can cover — a hard kill (docker kill, OOM, host
|
||||
# reboot) runs nothing at all, so without this the previous life's transcripts
|
||||
# are never mined.
|
||||
#
|
||||
# No MEMPALACE_PI_STAGE override here on purpose: the feeder stages next to the
|
||||
# palace it feeds (<palace-root>/pi-stage), so the stage and the dedup keys
|
||||
# referencing it share one lifetime — whatever persistence the palace has, the
|
||||
# stage inherits. Pinning it elsewhere (e.g. into the ~/.pi volume) would
|
||||
# re-introduce the very split that design prevents: palace volume kept, stage
|
||||
# volume dropped, and `mempalace sync` then prunes every conversation drawer.
|
||||
#
|
||||
# Backgrounded: a cold mine can take tens of seconds and must never delay the
|
||||
# shell. Contention with a live session is handled by the tool itself (it exits
|
||||
# 0 and lets the palace holder do the mine).
|
||||
|
||||
# Self-heal onto PATH for images whose base predates the toolkit symlink.
|
||||
# ~/.local/bin is already ahead of /usr/local/bin on PATH (Dockerfile.base sets
|
||||
# it in ENV PATH) and is writable by this (non-root) user, unlike /usr/local/bin.
|
||||
if [ -x /opt/mempalace-toolkit/bin/mempalace-pi-session ] && \
|
||||
! command -v mempalace-pi-session >/dev/null 2>&1; then
|
||||
mkdir -p "$HOME/.local/bin"
|
||||
ln -sf /opt/mempalace-toolkit/bin/mempalace-pi-session "$HOME/.local/bin/mempalace-pi-session"
|
||||
fi
|
||||
|
||||
# Resolve the feeder explicitly rather than trusting PATH: this runs before any
|
||||
# login shell, and a silently-skipped catch-up is exactly the failure we are
|
||||
# here to prevent.
|
||||
MEMPALACE_FEEDER=""
|
||||
if command -v mempalace-pi-session >/dev/null 2>&1; then
|
||||
MEMPALACE_FEEDER="mempalace-pi-session"
|
||||
elif [ -x /opt/mempalace-toolkit/bin/mempalace-pi-session ]; then
|
||||
MEMPALACE_FEEDER="/opt/mempalace-toolkit/bin/mempalace-pi-session"
|
||||
fi
|
||||
|
||||
if [ "${MEMPALACE_FEED:-1}" != "0" ] && [ -n "$MEMPALACE_FEEDER" ]; then
|
||||
if [ -n "${MEMPALACE_REMOTE_URL:-}" ] && [ -z "${MEMPALACE_PI_SSH_TARGET:-}" ]; then
|
||||
# Remote palace, but no inbox to ship transcripts to — the feeder genuinely
|
||||
# cannot do anything here, so skipping is right. Saying so is the point:
|
||||
# this branch used to be a bare `:`, and the skip happens *before* the
|
||||
# subshell below that writes mempalace-catchup.log, so a container in this
|
||||
# state contributed nothing to the palace and left no artifact at all — not
|
||||
# even an empty log — to explain why. That is indistinguishable from a
|
||||
# healthy run that simply had nothing to file. `tee` puts the notice both in
|
||||
# the container's start output (docker logs) and at the path anyone
|
||||
# debugging "why is nothing from this container in the palace?" looks first.
|
||||
# This is an entrypoint: a notice must never be able to stop a container
|
||||
# from starting. An unwritable ~/.pi (root-owned volume — a classic Docker
|
||||
# permission accident) makes `mkdir -p` fail, and under `set -e` that would
|
||||
# abort startup entirely: a brand-new failure mode in precisely the branch
|
||||
# that used to do nothing at all. Degrade to stdout-only instead.
|
||||
_mp_log="$HOME/.pi/agent/mempalace-catchup.log"
|
||||
mkdir -p "$HOME/.pi/agent" 2>/dev/null || _mp_log=/dev/null
|
||||
{
|
||||
echo "MemPalace catch-up skipped: remote palace with no transcript inbox."
|
||||
echo " MEMPALACE_REMOTE_URL is set (${MEMPALACE_REMOTE_URL})"
|
||||
echo " but MEMPALACE_PI_SSH_TARGET is not, so there is nowhere to ship this"
|
||||
echo " container's staged sessions. MCP tools still read and write the shared"
|
||||
echo " palace — but this container's own conversations are mined nowhere."
|
||||
echo " Fix: set MEMPALACE_PI_SSH_TARGET (and MEMPALACE_PI_DEVICE) in .env,"
|
||||
echo " or unset MEMPALACE_REMOTE_URL to keep the palace local."
|
||||
echo " Deliberate? MEMPALACE_FEED=0 turns the feed off and silences this."
|
||||
} | tee "$_mp_log" 2>/dev/null || true
|
||||
unset _mp_log
|
||||
else
|
||||
mkdir -p "$HOME/.pi/agent"
|
||||
(
|
||||
"$MEMPALACE_FEEDER" --reason container-start \
|
||||
>"$HOME/.pi/agent/mempalace-catchup.log" 2>&1 || true
|
||||
) &
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── cli_utils: link workspace bin/ commands onto PATH ────────────────
|
||||
# Standalone commands from a mounted cli_utils checkout (git-status-all,
|
||||
# git-pull-all, devbox-sanity, pi-session-repair, ...) live in <repo>/bin. On a
|
||||
# host they reach PATH via cli_utils' own install.sh, whose install_bin step
|
||||
# symlinks them into ~/.local/bin — but that home is on the container's WRITABLE
|
||||
# LAYER, so every recreate loses them and the human is back to typing
|
||||
# /workspace/cli_utils/bin/git-status-all. This is the container equivalent of
|
||||
# that install step, re-run at every start.
|
||||
#
|
||||
# WHY SYMLINKS RATHER THAN A PATH EDIT IN AN rc FILE: ~/.local/bin is already
|
||||
# ahead of /usr/local/bin in ENV PATH (Dockerfile.base), so links here resolve in
|
||||
# NON-interactive shells too — `docker exec <c> git-status-all`, agent tool
|
||||
# shells, scripts. An rc-file PATH edit cannot reach those, because ~/.bashrc
|
||||
# returns early when the shell is not interactive. Measured 2026-08-27 on
|
||||
# tor-ms22: `command -v git-status-all` failed in a non-interactive shell while
|
||||
# working in an interactive one, from exactly that asymmetry.
|
||||
#
|
||||
# Detection order (first hit wins):
|
||||
# 1. CLI_UTILS_CONTAINER_PATH explicit, for non-standard layouts
|
||||
# 2. /workspace/cli_utils repo directly in the workspace root
|
||||
# 3. $HOME/cli_utils dedicated mount
|
||||
# 4. /workspace/*/cli_utils workspace root holds several repo groups
|
||||
# CLI_UTILS_LINK=0 disables. Absent repo = silent no-op, which is the common
|
||||
# case for anyone who does not use cli_utils.
|
||||
if [ "${CLI_UTILS_LINK:-1}" != "0" ]; then
|
||||
CLI_UTILS_BIN=""
|
||||
if [ -n "${CLI_UTILS_CONTAINER_PATH:-}" ] && [ -d "${CLI_UTILS_CONTAINER_PATH}/bin" ]; then
|
||||
CLI_UTILS_BIN="${CLI_UTILS_CONTAINER_PATH}/bin"
|
||||
elif [ -d /workspace/cli_utils/bin ]; then
|
||||
CLI_UTILS_BIN=/workspace/cli_utils/bin
|
||||
elif [ -d "$HOME/cli_utils/bin" ]; then
|
||||
CLI_UTILS_BIN="$HOME/cli_utils/bin"
|
||||
else
|
||||
# `if` bodies, not `&&` chains: under `set -e` a loop whose LAST command is a
|
||||
# false test exits non-zero and would abort the entrypoint. With no match the
|
||||
# glob stays literal, so that is the normal case on any machine without this
|
||||
# repo — i.e. the bug would have been "container will not start", not "links
|
||||
# missing".
|
||||
for _cu in /workspace/*/cli_utils/bin; do
|
||||
if [ -d "$_cu" ]; then
|
||||
CLI_UTILS_BIN="$_cu"
|
||||
break
|
||||
fi
|
||||
done
|
||||
unset _cu
|
||||
fi
|
||||
|
||||
if [ -n "$CLI_UTILS_BIN" ]; then
|
||||
mkdir -p "$HOME/.local/bin" 2>/dev/null || true
|
||||
# Never clobber a real file, and never steal a link that points elsewhere: a
|
||||
# deliberate user override in ~/.local/bin must win, and silently shadowing
|
||||
# an image-provided command is worse than the missing command.
|
||||
for _f in "$CLI_UTILS_BIN"/*; do
|
||||
if [ ! -f "$_f" ] || [ ! -x "$_f" ]; then
|
||||
continue
|
||||
fi
|
||||
_link="$HOME/.local/bin/$(basename "$_f")"
|
||||
if [ -e "$_link" ] && [ ! -L "$_link" ]; then
|
||||
continue
|
||||
fi
|
||||
if [ -L "$_link" ]; then
|
||||
case "$(readlink "$_link")" in
|
||||
"$CLI_UTILS_BIN"/*) ;;
|
||||
*) continue ;;
|
||||
esac
|
||||
fi
|
||||
ln -sf "$_f" "$_link" 2>/dev/null || true
|
||||
done
|
||||
# Prune links we own whose target vanished (command renamed, repo moved),
|
||||
# mirroring the skillset deploy's --prune-stale. A dangling link on PATH
|
||||
# reports "No such file or directory" for a command that simply no longer
|
||||
# exists, which reads as a broken container rather than a removed script.
|
||||
for _link in "$HOME/.local/bin"/*; do
|
||||
[ -L "$_link" ] || continue
|
||||
case "$(readlink "$_link")" in
|
||||
*/cli_utils/bin/*) [ -e "$_link" ] || rm -f "$_link" ;;
|
||||
esac
|
||||
done
|
||||
unset _f _link
|
||||
fi
|
||||
unset CLI_UTILS_BIN
|
||||
fi
|
||||
|
||||
# ── Per-device boot hook ─────────────────────────────────────────────
|
||||
# Runs ~/.config/devbox-shell/init.sh if the host provides one. That directory is
|
||||
# the host-owned, bind-mounted shell-sharing dir (see "Volumes and persistence"),
|
||||
# so a hook placed there survives every recreate WITHOUT an image change — the
|
||||
# boot-time twin of the interactive bridge in /etc/skel-devbox/.bash_aliases,
|
||||
# which sources ~/.config/devbox-shell/bash_aliases for every interactive shell.
|
||||
#
|
||||
# NO NEW TRUST BOUNDARY: that same directory is already sourced into every
|
||||
# interactive shell, i.e. it is already arbitrary code from the same owner. What
|
||||
# is new is only WHEN it runs — once at start, before any shell — which is what
|
||||
# non-interactive fixups (symlinks, dirs, one-off migrations) need.
|
||||
#
|
||||
# Deliberately `bash <file>`, not `.` — a hook must not be able to mutate this
|
||||
# entrypoint's own shell state, and its exit status must not matter. Output goes
|
||||
# to a log rather than the container's start output, so a chatty hook cannot
|
||||
# masquerade as a startup error.
|
||||
if [ -r "$HOME/.config/devbox-shell/init.sh" ]; then
|
||||
mkdir -p "$HOME/.pi/agent" 2>/dev/null || true
|
||||
bash "$HOME/.config/devbox-shell/init.sh" \
|
||||
>"$HOME/.pi/agent/devbox-init.log" 2>&1 || true
|
||||
fi
|
||||
|
||||
# ── Git config defaults ──────────────────────────────────────────────
|
||||
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
|
||||
git config --global user.name "$GIT_USER_NAME"
|
||||
fi
|
||||
if [ -n "${GIT_USER_EMAIL:-}" ] && ! git config --global user.email &>/dev/null; then
|
||||
git config --global user.email "$GIT_USER_EMAIL"
|
||||
fi
|
||||
# Global gitignore for personal/tooling artifacts (*.bak, *~, *.orig, ...).
|
||||
# Seeded above into $HOME/.gitignore_global from /etc/skel-devbox. Point git at
|
||||
# it only if the user has not already set their own core.excludesFile.
|
||||
if [ -f "$HOME/.gitignore_global" ] && ! git config --global core.excludesFile &>/dev/null; then
|
||||
git config --global core.excludesFile "$HOME/.gitignore_global"
|
||||
fi
|
||||
|
||||
# ── pi: deploy toolkit + extensions + mempalace bridge ─────────────
|
||||
# pi is always installed in pi-devbox; no INSTALL_PI guard needed.
|
||||
# Each install.sh is idempotent and backs up real files before linking,
|
||||
# so re-running across container restarts is safe.
|
||||
#
|
||||
# Order: pi-toolkit first (creates ~/.pi/agent/keybindings.json symlink
|
||||
# and writes the AWS env loader), then pi-extensions (symlinks our
|
||||
# extensions), then settings.json bootstrap from the toolkit template,
|
||||
# then the mempalace bridge symlink (one-liner; mempalace-toolkit's
|
||||
# install_skill is intentionally skipped to avoid racing with skillset
|
||||
# auto-deploy below).
|
||||
if command -v pi &>/dev/null; then
|
||||
if [ -d /opt/pi-toolkit ]; then
|
||||
(cd /opt/pi-toolkit && ./install.sh --yes) || \
|
||||
echo "WARN: pi-toolkit install.sh failed (continuing)"
|
||||
fi
|
||||
|
||||
if [ -d /opt/pi-extensions ]; then
|
||||
(cd /opt/pi-extensions && ./install.sh --yes) || \
|
||||
echo "WARN: pi-extensions install.sh failed (continuing)"
|
||||
fi
|
||||
|
||||
# Bootstrap settings.json from template if absent (pi rewrites this
|
||||
# file at runtime — lastChangelogVersion, etc — so we can't symlink it).
|
||||
_pi_settings="$HOME/.pi/agent/settings.json"
|
||||
_pi_template=/opt/pi-toolkit/settings.example.json
|
||||
if [ ! -f "$_pi_settings" ] && [ -f "$_pi_template" ]; then
|
||||
cp "$_pi_template" "$_pi_settings"
|
||||
echo "pi settings.json bootstrapped from template"
|
||||
elif [ -f "$_pi_settings" ] && [ -f "$_pi_template" ] && \
|
||||
[ "${PI_SETTINGS_MERGE:-1}" != "0" ] && command -v jq >/dev/null 2>&1; then
|
||||
# Non-destructive merge: a settings.json on a PRESERVED volume never
|
||||
# otherwise sees new template keys (the bootstrap above only fires when
|
||||
# the file is absent), so config added in an image upgrade — e.g. the
|
||||
# observational-memory / pi-fork blocks or a newly-enabled model — never
|
||||
# reaches existing users. Deep-merge with the template FIRST and the
|
||||
# live file SECOND ('.[0] * .[1]') so the user's values always win and
|
||||
# only keys MISSING from the live file are filled in from the template.
|
||||
# Arrays are treated as leaves (the user's array is kept verbatim, so a
|
||||
# model they deliberately removed is not re-added). Only rewrite when the
|
||||
# merge actually changes something, and back up the original first.
|
||||
# Set PI_SETTINGS_MERGE=0 to disable. Invalid JSON on either side → skip,
|
||||
# never clobber.
|
||||
if _pi_merged=$(jq -s '.[0] * .[1]' "$_pi_template" "$_pi_settings" 2>/dev/null); then
|
||||
if [ -n "$_pi_merged" ] && \
|
||||
! printf '%s' "$_pi_merged" | jq -e --slurpfile cur "$_pi_settings" '. == $cur[0]' >/dev/null 2>&1; then
|
||||
cp "$_pi_settings" "${_pi_settings}.bak.$(date +%Y%m%d-%H%M%S)"
|
||||
printf '%s\n' "$_pi_merged" > "$_pi_settings"
|
||||
echo "pi settings.json: merged new template keys from settings.example.json (backup saved)"
|
||||
fi
|
||||
else
|
||||
echo "WARN: pi settings.json merge skipped (jq could not parse template or live file; left untouched)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# pi↔mempalace MCP bridge — single extension symlink.
|
||||
if [ -f /opt/mempalace-toolkit/extensions/pi/mempalace.ts ] && \
|
||||
command -v mempalace &>/dev/null && \
|
||||
[ ! -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
|
||||
ln -sf /opt/mempalace-toolkit/extensions/pi/mempalace.ts \
|
||||
"$HOME/.pi/agent/extensions/mempalace.ts"
|
||||
fi
|
||||
|
||||
# pi-fork (fork tool) + pi-observational-memory (recall tool) + pi-atelier
|
||||
# (TUI sidebar panels/split-pane) + (in the :latest-studio variant only)
|
||||
# pi-studio (/studio command + studio_* tools + theme). These are pi packages (not symlink-style extensions):
|
||||
# they're cloned to /opt with node_modules baked at BUILD time, then
|
||||
# registered here via `pi install <local-path>`. A local-path install is
|
||||
# instant + in-place (pi loads the extension directly from /opt) +
|
||||
# idempotent (no duplicate package entry on re-run), and stores a relative
|
||||
# path that resolves into the image-layer /opt so it survives volume
|
||||
# recreate. The tools/command register on the NEXT pi start (extensions
|
||||
# bind at startup) or on `/reload`. Guard on settings.json so we only
|
||||
# install once per volume. /opt/pi-studio is present only in the studio
|
||||
# variant; the `[ -d ]` test makes this a no-op everywhere else.
|
||||
#
|
||||
# The guard MUST inspect the `packages` ARRAY, not merely grep the whole
|
||||
# file for the package name. settings.example.json ships a top-level
|
||||
# "pi-fork" CONFIG block (the fork effort profiles, pi-toolkit adb6907,
|
||||
# 2026-06-17), so a whole-file substring grep matches on any settings.json
|
||||
# that was bootstrapped from — or template-merged with — that template.
|
||||
# Worse, the merge above runs FIRST, so it plants the matching string in the
|
||||
# same startup that the loop then reads: `pi install /opt/pi-fork` was
|
||||
# skipped forever and the `fork` tool never registered (v1.0.0 → v1.6.3).
|
||||
# Its siblings escaped only by luck — the template key is
|
||||
# "observational-memory" (no pi- prefix) and there is no studio block.
|
||||
# jq reads the array; the grep fallback matches the stored relative-path
|
||||
# form ("…/opt/<name>\""), which a config KEY can never produce.
|
||||
_pi_pkg_registered() {
|
||||
_pi_reg_settings="$HOME/.pi/agent/settings.json"
|
||||
[ -f "$_pi_reg_settings" ] || return 1
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq -e --arg n "$1" \
|
||||
'(.packages // []) | any((type == "string") and (. == "npm:" + $n or endswith("/" + $n)))' \
|
||||
"$_pi_reg_settings" >/dev/null 2>&1
|
||||
else
|
||||
grep -q "opt/$1\"" "$_pi_reg_settings"
|
||||
fi
|
||||
}
|
||||
|
||||
# ── pi-atelier: retire a stale `npm:pi-atelier`, plus an opt-out ──────
|
||||
# The image now vendors pi-atelier at a pinned, audited tag (PI_ATELIER_REF
|
||||
# in Dockerfile.variant). A leftover `npm:pi-atelier` entry from a
|
||||
# hand-install resolves through ~/.pi/npm-global, which lives on the
|
||||
# devbox-pi-config VOLUME — so it survives image upgrades and keeps whatever
|
||||
# version was installed by hand, unpinned and unaudited. That is not
|
||||
# academic: pi-atelier < 0.7.1 makes pi >= 0.84 hang at startup with
|
||||
# sustained CPU, so leaving it in place turns a pi bump into a TUI that will
|
||||
# not start. And `_pi_pkg_registered` deliberately counts `npm:<name>` as
|
||||
# registered (it respects a user's own npm install), so the loop below would
|
||||
# never replace it.
|
||||
#
|
||||
# We only DELETE the exact `npm:pi-atelier` string; the loop then registers
|
||||
# /opt/pi-atelier in pi's own canonical serialization, so this code never has
|
||||
# to guess the stored relative-path form. Idempotent — after the rewrite
|
||||
# there is no npm entry left to match.
|
||||
#
|
||||
# DEVBOX_ATELIER=0 goes further and removes pi-atelier from `packages`
|
||||
# altogether. That escape hatch lives HERE, in the entrypoint, precisely
|
||||
# because this component's known failure mode is "pi will not start" — which
|
||||
# you cannot repair with `pi uninstall`.
|
||||
_pi_atelier_drop() {
|
||||
# $1 = jq predicate over one `packages` entry, selecting what to REMOVE.
|
||||
# Returns 0 only when the file was actually rewritten (caller logs), 1 for
|
||||
# "nothing to do" — including missing jq or unparseable JSON, which must
|
||||
# never clobber user settings. Backs up first, same convention as the
|
||||
# template merge above.
|
||||
_ad_settings="$HOME/.pi/agent/settings.json"
|
||||
[ -f "$_ad_settings" ] || return 1
|
||||
command -v jq >/dev/null 2>&1 || return 1
|
||||
_ad_new=$(jq "(.packages // []) |= map(select(($1) | not))" "$_ad_settings" 2>/dev/null) || return 1
|
||||
[ -n "$_ad_new" ] || return 1
|
||||
if printf '%s' "$_ad_new" | jq -e --slurpfile cur "$_ad_settings" '. == $cur[0]' >/dev/null 2>&1; then
|
||||
return 1
|
||||
fi
|
||||
# `.bak.atelier.` rather than the merge's plain `.bak.` prefix: both can
|
||||
# fire in the same startup, and a bare seconds-resolution timestamp would
|
||||
# make the second cp overwrite the first one's backup.
|
||||
cp "$_ad_settings" "${_ad_settings}.bak.atelier.$(date +%Y%m%d-%H%M%S)"
|
||||
printf '%s\n' "$_ad_new" > "$_ad_settings"
|
||||
return 0
|
||||
}
|
||||
if [ "${DEVBOX_ATELIER:-1}" = "0" ]; then
|
||||
if _pi_atelier_drop '(. == "npm:pi-atelier") or ((type == "string") and endswith("/pi-atelier"))'; then
|
||||
echo "pi-atelier: unregistered per DEVBOX_ATELIER=0 (settings backup saved)"
|
||||
fi
|
||||
elif [ -d /opt/pi-atelier ]; then
|
||||
if _pi_atelier_drop '. == "npm:pi-atelier"'; then
|
||||
echo "pi-atelier: dropped stale npm: registration — the pinned /opt copy takes over (settings backup saved)"
|
||||
fi
|
||||
fi
|
||||
|
||||
for _pkg in /opt/pi-fork /opt/pi-observational-memory /opt/pi-studio /opt/pi-atelier; do
|
||||
[ -d "$_pkg" ] || continue
|
||||
_name=$(basename "$_pkg")
|
||||
# DEVBOX_ATELIER=0 → leave pi-atelier unregistered (handled just above).
|
||||
if [ "$_name" = "pi-atelier" ] && [ "${DEVBOX_ATELIER:-1}" = "0" ]; then continue; fi
|
||||
if ! _pi_pkg_registered "$_name"; then
|
||||
pi install "$_pkg" >/dev/null 2>&1 || \
|
||||
echo "WARN: pi install $_name failed (continuing)"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── agent-browser: retire a stale volume copy that shadows the image ───
|
||||
# Same hazard class as the pi-atelier retirement above, different delivery
|
||||
# path — and this block exists because that guard did not generalise.
|
||||
# ~/.pi/npm-global lives on the devbox-pi-config VOLUME, so anything ever
|
||||
# installed there with `npm i -g` survives every image upgrade, and PATH puts
|
||||
# it AHEAD of /usr/bin (position 2 vs 8).
|
||||
#
|
||||
# Measured on mbp-m1-2020, 2026-09-06: a 2026-07-17 hand-install pinned
|
||||
# agent-browser 0.27.0 in the volume while the image shipped 0.35.2, so every
|
||||
# session for ~7 weeks ran a stale CLI. The damaging part was not the binary
|
||||
# but its BUNDLED SKILL, which is what the agent actually reads: 3 skillsets /
|
||||
# 17.6 KB core in 0.27.0 vs 8 skillsets / 31.5 KB core in 0.35.2, with ten
|
||||
# subcommands present in the image and undocumented to the agent (a11y,
|
||||
# browser, data, mcp, page, plugin, read, selectors, to, webmcp). A stale tool
|
||||
# announces itself; a stale skill quietly teaches the wrong commands.
|
||||
#
|
||||
# MOVE rather than delete (reversible, same instinct as the settings backups
|
||||
# above), and only when the image ships its own copy — a machine that
|
||||
# deliberately hand-installs agent-browser on an image WITHOUT one keeps it.
|
||||
_ab_vol="$HOME/.pi/npm-global/lib/node_modules/agent-browser"
|
||||
if [ -d "$_ab_vol" ] && [ -d /usr/lib/node_modules/agent-browser ]; then
|
||||
_ab_park="$HOME/.pi/npm-global/.retired-agent-browser-$(date +%Y%m%d-%H%M%S)"
|
||||
if mkdir -p "$_ab_park" 2>/dev/null && mv "$_ab_vol" "$_ab_park/" 2>/dev/null; then
|
||||
# The bin shim is what PATH actually hits; leaving it behind would give a
|
||||
# dangling symlink, which is a worse failure than a stale version.
|
||||
rm -f "$HOME/.pi/npm-global/bin/agent-browser" 2>/dev/null || true
|
||||
echo "agent-browser: retired stale volume copy -> ${_ab_park} (image copy now wins; delete the parked dir when satisfied)"
|
||||
else
|
||||
echo "WARN: agent-browser: stale volume copy at $_ab_vol shadows the image copy and could not be moved; retire it by hand"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── pi-studio: optional loopback bridge (opt-in) ──────────────────────
|
||||
# pi-studio binds its server to 127.0.0.1 inside the container, which a
|
||||
# published Docker port cannot reach. When STUDIO_EXPOSE is truthy (set in
|
||||
# compose), start the `studio-expose` socat bridge in the background so a
|
||||
# published port + `ssh -L` tunnel can reach Studio once the user runs
|
||||
# `/studio --port "$STUDIO_PORT"`. Default OFF — Studio stays loopback-only
|
||||
# (its secure default) unless explicitly opted in. Guarded on the studio
|
||||
# variant (/opt/pi-studio) so it is a no-op in the plain image.
|
||||
case "${STUDIO_EXPOSE:-}" in
|
||||
1|true|TRUE|yes|on)
|
||||
if [ -d /opt/pi-studio ] && command -v studio-expose &>/dev/null && command -v socat &>/dev/null; then
|
||||
echo "STUDIO_EXPOSE set — starting studio-expose bridge on port ${STUDIO_PORT:-8765} (background)"
|
||||
nohup studio-expose "${STUDIO_PORT:-8765}" >/tmp/studio-expose.log 2>&1 &
|
||||
else
|
||||
echo "STUDIO_EXPOSE set but studio-expose/socat/pi-studio unavailable — skipping bridge"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
|
||||
# When the skillset repo is mounted (at $HOME/skillset or /workspace/skillset),
|
||||
# run the deploy script to create relative symlinks for skills and instructions.
|
||||
# This ensures skills resolve correctly inside the container regardless of
|
||||
# where the repo lives on the host. Idempotent — second run is a no-op.
|
||||
#
|
||||
# Detection order:
|
||||
# 1. SKILLSET_CONTAINER_PATH env var (explicit, for non-standard layouts)
|
||||
# 2. $HOME/skillset (dedicated volume mount via SKILLSET_PATH in compose)
|
||||
# 3. /workspace/skillset (skillset is directly inside workspace root)
|
||||
SKILLSET_DEPLOY=""
|
||||
if [ -n "${SKILLSET_CONTAINER_PATH:-}" ] && [ -x "${SKILLSET_CONTAINER_PATH}/deploy-skills.sh" ]; then
|
||||
SKILLSET_DEPLOY="${SKILLSET_CONTAINER_PATH}/deploy-skills.sh"
|
||||
elif [ -x "$HOME/skillset/deploy-skills.sh" ]; then
|
||||
SKILLSET_DEPLOY="$HOME/skillset/deploy-skills.sh"
|
||||
elif [ -x /workspace/skillset/deploy-skills.sh ]; then
|
||||
SKILLSET_DEPLOY="/workspace/skillset/deploy-skills.sh"
|
||||
fi
|
||||
if [ -n "$SKILLSET_DEPLOY" ]; then
|
||||
"$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true
|
||||
# The deploy leaves the early baked links (above) in place as foreign links,
|
||||
# which silently shadows the live clone for skills the skillset OWNS. Repoint
|
||||
# just those; baked stays the fallback, user overrides still win. `|| true`:
|
||||
# a skill-link refinement must never break container start.
|
||||
if command -v devbox-skill-reconcile >/dev/null 2>&1; then
|
||||
devbox-skill-reconcile "$(dirname "$SKILLSET_DEPLOY")" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Execute command ──────────────────────────────────────────────────
|
||||
exec "$@"
|
||||
Executable
+122
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
USER_NAME="developer"
|
||||
CURRENT_UID=$(id -u "$USER_NAME")
|
||||
CURRENT_GID=$(id -g "$USER_NAME")
|
||||
|
||||
# ── UID/GID adjustment ───────────────────────────────────────────────
|
||||
# Priority per dimension: env var > auto-detect from /workspace > no-op
|
||||
# UID and GID are detected independently so a GID-only mismatch (e.g. host
|
||||
# user has UID 1000 but primary group at GID 1001) is still corrected.
|
||||
TARGET_UID="${USER_UID:-}"
|
||||
TARGET_GID="${USER_GID:-}"
|
||||
|
||||
if [ -d /workspace ]; then
|
||||
WORKSPACE_UID=$(stat -c '%u' /workspace 2>/dev/null || stat -f '%u' /workspace 2>/dev/null || echo "")
|
||||
WORKSPACE_GID=$(stat -c '%g' /workspace 2>/dev/null || stat -f '%g' /workspace 2>/dev/null || echo "")
|
||||
# Adopt workspace UID if env var not set and workspace is non-root-owned
|
||||
if [ -z "$TARGET_UID" ] && [ -n "$WORKSPACE_UID" ] && [ "$WORKSPACE_UID" != "0" ] && [ "$WORKSPACE_UID" != "$CURRENT_UID" ]; then
|
||||
TARGET_UID="$WORKSPACE_UID"
|
||||
fi
|
||||
# Adopt workspace GID if env var not set and workspace group differs
|
||||
if [ -z "$TARGET_GID" ] && [ -n "$WORKSPACE_GID" ] && [ "$WORKSPACE_GID" != "0" ] && [ "$WORKSPACE_GID" != "$CURRENT_GID" ]; then
|
||||
TARGET_GID="$WORKSPACE_GID"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Apply UID/GID changes if needed
|
||||
if [ -n "$TARGET_GID" ] && [ "$TARGET_GID" != "$CURRENT_GID" ]; then
|
||||
groupmod -g "$TARGET_GID" "$USER_NAME" 2>/dev/null || true
|
||||
find /home/"$USER_NAME" -not -path "/home/$USER_NAME/.ssh/*" -group "$CURRENT_GID" -exec chgrp "$TARGET_GID" {} + 2>/dev/null || true
|
||||
echo "Adjusted developer GID to $TARGET_GID"
|
||||
fi
|
||||
|
||||
if [ -n "$TARGET_UID" ] && [ "$TARGET_UID" != "$CURRENT_UID" ]; then
|
||||
usermod -u "$TARGET_UID" "$USER_NAME" 2>/dev/null || true
|
||||
find /home/"$USER_NAME" -not -path "/home/$USER_NAME/.ssh/*" -user "$CURRENT_UID" -exec chown "$TARGET_UID" {} + 2>/dev/null || true
|
||||
echo "Adjusted developer UID to $TARGET_UID"
|
||||
fi
|
||||
|
||||
# ── SSH key permissions ──────────────────────────────────────────────
|
||||
# If SSH keys are mounted, fix permissions (skip if read-only mount)
|
||||
if [ -d "/home/$USER_NAME/.ssh" ] && [ "$(ls -A "/home/$USER_NAME/.ssh" 2>/dev/null)" ]; then
|
||||
if touch "/home/$USER_NAME/.ssh/.perm_test" 2>/dev/null; then
|
||||
rm -f "/home/$USER_NAME/.ssh/.perm_test"
|
||||
chmod 700 "/home/$USER_NAME/.ssh"
|
||||
find "/home/$USER_NAME/.ssh" -type f -name "id_*" ! -name "*.pub" -exec chmod 600 {} \; 2>/dev/null || true
|
||||
find "/home/$USER_NAME/.ssh" -type f -name "*.pub" -exec chmod 644 {} \; 2>/dev/null || true
|
||||
[ -f "/home/$USER_NAME/.ssh/known_hosts" ] && chmod 644 "/home/$USER_NAME/.ssh/known_hosts"
|
||||
[ -f "/home/$USER_NAME/.ssh/config" ] && chmod 600 "/home/$USER_NAME/.ssh/config"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Fix ownership of named volume mount points ──────────────────────
|
||||
# Named volumes are created as root on first use. Fix ownership so the
|
||||
# developer user can write to them.
|
||||
FINAL_UID="${TARGET_UID:-$CURRENT_UID}"
|
||||
FINAL_GID="${TARGET_GID:-$CURRENT_GID}"
|
||||
|
||||
# First, fix parent dirs that Docker auto-creates as root:root when it
|
||||
# materializes nested mount points (e.g. mounting a volume at
|
||||
# .local/state/opencode creates .local/state as root). Non-recursive —
|
||||
# we only need the dir node itself; children are handled below or were
|
||||
# created by the user.
|
||||
for parent in \
|
||||
/home/"$USER_NAME"/.local \
|
||||
/home/"$USER_NAME"/.local/share \
|
||||
/home/"$USER_NAME"/.local/state \
|
||||
/home/"$USER_NAME"/.cache \
|
||||
/home/"$USER_NAME"/.config; do
|
||||
if [ -d "$parent" ] && [ "$(stat -c '%u' "$parent" 2>/dev/null)" != "$FINAL_UID" ]; then
|
||||
chown "$FINAL_UID":"$FINAL_GID" "$parent" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
for dir in \
|
||||
/home/"$USER_NAME"/.local/share/opencode \
|
||||
/home/"$USER_NAME"/.local/state/opencode \
|
||||
/home/"$USER_NAME"/.local/share/uv \
|
||||
/home/"$USER_NAME"/.local/share/zoxide \
|
||||
/home/"$USER_NAME"/.local/share/nvim \
|
||||
/home/"$USER_NAME"/.mempalace \
|
||||
/home/"$USER_NAME"/.cache/bash \
|
||||
/home/"$USER_NAME"/.cache/chroma \
|
||||
/home/"$USER_NAME"/.rustup \
|
||||
/home/"$USER_NAME"/.cargo \
|
||||
/home/"$USER_NAME"/.vscode-server \
|
||||
/home/"$USER_NAME"/.config/opencode \
|
||||
/home/"$USER_NAME"/.config/nvim \
|
||||
/home/"$USER_NAME"/.pi \
|
||||
/home/"$USER_NAME"/.ssh-local \
|
||||
/home/"$USER_NAME"/.agents/skills; do
|
||||
[ -d "$dir" ] || continue
|
||||
|
||||
# Sentinel-file fast path: on volumes with thousands of files (nvim
|
||||
# plugins, palace data) the recursive chown used to cost multiple
|
||||
# seconds on every container start even when ownership was already
|
||||
# correct. Now we write a sentinel after a successful chown and skip
|
||||
# the walk when the sentinel matches the target UID:GID.
|
||||
#
|
||||
# If USER_UID changes between runs (user switches hosts, different
|
||||
# workspace owner), the sentinel won't match and the full chown runs.
|
||||
sentinel="$dir/.devbox-owner"
|
||||
expected="$FINAL_UID:$FINAL_GID"
|
||||
if [ -f "$sentinel" ] && [ "$(cat "$sentinel" 2>/dev/null)" = "$expected" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Recursive chown needed. Only do it when the top-level differs too
|
||||
# (covers the common case of fresh root-owned named volumes).
|
||||
if [ "$(stat -c '%u' "$dir" 2>/dev/null)" != "$FINAL_UID" ]; then
|
||||
chown -R "$FINAL_UID":"$FINAL_GID" "$dir" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Write sentinel so subsequent starts skip the recursive walk.
|
||||
# Suppress errors — a read-only mount would fail here, but that would
|
||||
# already have failed above on the chown itself.
|
||||
echo "$expected" > "$sentinel" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# ── Drop to developer user for remaining setup ──────────────────────
|
||||
exec gosu "$USER_NAME" /usr/local/bin/entrypoint-user.sh "$@"
|
||||
Executable
+64
@@ -0,0 +1,64 @@
|
||||
#!/usr/bin/env bash
|
||||
# Pre-push gate for pi-devbox: shellcheck every shell script before it leaves
|
||||
# this clone. Thin wrapper — all logic lives in scripts/lint-shell.sh, which is
|
||||
# the SAME script the CI release gate runs. One copy, not two: a duplicated
|
||||
# check that drifts is the failure this repo keeps paying for.
|
||||
#
|
||||
# Install per clone: git config core.hooksPath hooks
|
||||
# Bypass this gate: git push --no-verify (a guard, not a wall)
|
||||
#
|
||||
# WHY THIS HOOK EXISTS
|
||||
# v1.8.14's first release attempt died at scripts/smoke-test.sh:770 after
|
||||
# build-base had already spent ~46 minutes. shellcheck had ALREADY caught the
|
||||
# defect — SC2289 at severity error, on the very push that introduced it — and
|
||||
# the lint job stayed red for 24 hours, unread, across three runs. The fix at
|
||||
# the time was to gate the release on the same script (the `lint-gate` job).
|
||||
# This hook is the cheaper end of that: the same finding, before the push,
|
||||
# in seconds rather than after a 40 s CI gate or a 46 min build.
|
||||
#
|
||||
# WHY IT COULD NOT EXIST UNTIL NOW
|
||||
# Measured on v1.8.14 (2026-09-09): shellcheck was absent from the devbox
|
||||
# image by all three routes — PATH, dpkg and a filesystem search. So
|
||||
# lint-shell.sh exited 2 in every container, and a hook calling it would have
|
||||
# refused EVERY push rather than gating anything. `shellcheck` was added to
|
||||
# Dockerfile.base in the same change that added this file; on an image built
|
||||
# before that, enable this hook and you will simply be told the gate cannot
|
||||
# run. That is the correct behaviour, but it is not a working hook — so do not
|
||||
# set core.hooksPath on a container older than the release that bakes it.
|
||||
#
|
||||
# NOTE ON SCOPE: this lints the WORKING TREE, not the exact commit range being
|
||||
# pushed. That is deliberate and matches what the CI gate does to the tagged
|
||||
# tree. It means a defect you have staged-but-not-committed is also reported,
|
||||
# which is noisy in the safe direction.
|
||||
set -euo pipefail
|
||||
|
||||
HOOK_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$HOOK_DIR/.." && pwd)"
|
||||
LINTER="$REPO_ROOT/scripts/lint-shell.sh"
|
||||
tag="[lint-shell]"
|
||||
|
||||
# Same rule the gate itself applies, applied one level up: a missing check is
|
||||
# not a pass. If the script is gone, the push is refused rather than waved
|
||||
# through on the assumption that CI will catch it.
|
||||
if [ ! -r "$LINTER" ]; then
|
||||
echo "$tag refusing the push: $LINTER is missing, so the gate cannot" >&2
|
||||
echo "$tag run. A gate that cannot run must not pass." >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Point the message at the actual remedy when the binary is absent, because the
|
||||
# linter's own message ("install it or run this in CI") is written for a CI
|
||||
# runner and is misleading inside a container the developer cannot apt-install
|
||||
# into persistently.
|
||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||
echo "$tag refusing the push: shellcheck is not installed, so the gate" >&2
|
||||
echo "$tag cannot run. A gate that cannot run must not pass." >&2
|
||||
echo "$tag" >&2
|
||||
echo "$tag This container predates the image that bakes shellcheck." >&2
|
||||
echo "$tag Either recreate onto an image that has it, or unset the hook:" >&2
|
||||
echo "$tag git config --unset core.hooksPath" >&2
|
||||
echo "$tag To push this once without the gate: git push --no-verify" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
exec bash "$LINTER" "$REPO_ROOT"
|
||||
@@ -0,0 +1,18 @@
|
||||
" pi-devbox — system-wide Neovim defaults.
|
||||
"
|
||||
" This is Neovim's *system vimrc*: it loads for every user before any personal
|
||||
" ~/.config/nvim, and personal configs can still override it.
|
||||
"
|
||||
" Enable 24-bit ("true") colour. Without it, Neovim's default theme is squeezed
|
||||
" into a 256-colour palette where strings/comments become a muddy, low-contrast
|
||||
" dark colour — a common complaint over ssh/kitty where COLORTERM often isn't
|
||||
" propagated into the container. Modern terminals (kitty, WezTerm, iTerm2,
|
||||
" Alacritty, ...) all support true colour; the bundled kitty-terminfo also lets
|
||||
" Neovim auto-detect it, but forcing it here guarantees readable colour
|
||||
" regardless of how the terminal type / COLORTERM reach the container.
|
||||
"
|
||||
" Opt out for a session: :set notermguicolors
|
||||
" Override permanently: set your own value in ~/.config/nvim/init.lua
|
||||
if has('termguicolors')
|
||||
set termguicolors
|
||||
endif
|
||||
@@ -0,0 +1,188 @@
|
||||
# opencode-devbox bash aliases and customizations
|
||||
# Sourced by the Debian-default ~/.bashrc on shell startup.
|
||||
# To override, bind-mount your host's ~/.bash_aliases over this file
|
||||
# via docker-compose.yml.
|
||||
|
||||
# ── Host-shared shell customizations (devbox-shell bridge) ───────────
|
||||
# If the host bind-mounts a directory at ~/.config/devbox-shell/ (the
|
||||
# recommended pattern for sharing aliases/PATH/utilities between host
|
||||
# and container), source the bash_aliases file from it. This survives
|
||||
# --force-recreate because it's baked into the image's skel, not the
|
||||
# container's writable layer. Hosts that don't use this pattern are
|
||||
# unaffected — the test silently skips if the file doesn't exist.
|
||||
[ -r "$HOME/.config/devbox-shell/bash_aliases" ] && . "$HOME/.config/devbox-shell/bash_aliases"
|
||||
|
||||
# ── History persistence and quality ──────────────────────────────────
|
||||
# The named volume devbox-shell-history is mounted at ~/.cache/bash
|
||||
# so history survives container recreation.
|
||||
export HISTFILE="${HOME}/.cache/bash/history"
|
||||
mkdir -p "$(dirname "$HISTFILE")" 2>/dev/null || true
|
||||
|
||||
# Large, time-stamped, deduplicated history. Append rather than overwrite.
|
||||
export HISTSIZE=100000
|
||||
export HISTFILESIZE=200000
|
||||
export HISTCONTROL=ignoreboth:erasedups
|
||||
export HISTTIMEFORMAT='%F %T '
|
||||
shopt -s histappend 2>/dev/null
|
||||
shopt -s cmdhist 2>/dev/null
|
||||
# Note: PROMPT_COMMAND="history -a" is installed LATER in this file,
|
||||
# after zoxide's init runs. Installing it here would create a
|
||||
# "history -a;;__zoxide_hook" chain because zoxide's init uses ';'
|
||||
# as its separator and prepends itself; two adjacent ';' breaks the
|
||||
# parser. See https://github.com/ajeetdsouza/zoxide/issues/722.
|
||||
|
||||
# ── Common aliases ───────────────────────────────────────────────────
|
||||
# Prefer eza (modern ls) when available
|
||||
if command -v eza >/dev/null 2>&1; then
|
||||
alias ls='eza --group-directories-first'
|
||||
alias ll='eza -lh --group-directories-first --git'
|
||||
alias la='eza -lha --group-directories-first --git'
|
||||
alias tree='eza --tree'
|
||||
else
|
||||
alias ll='ls -lh'
|
||||
alias la='ls -lha'
|
||||
fi
|
||||
|
||||
# Prefer bat (syntax-highlighted cat) when available
|
||||
if command -v bat >/dev/null 2>&1; then
|
||||
alias cat='bat --style=plain --paging=never'
|
||||
alias less='bat --paging=always'
|
||||
fi
|
||||
|
||||
# Git shortcuts
|
||||
alias gs='git status'
|
||||
alias gd='git diff'
|
||||
alias gl='git log --oneline --graph --decorate -20'
|
||||
|
||||
# ── Host SSH reachability check (once per container lifetime) ───────────────
|
||||
# Warns at first shell startup if the Mac host is not reachable via SSH.
|
||||
# Only runs inside a container, only if the jump key exists, and only once
|
||||
# per container lifetime (/tmp flag is cleared on recreate).
|
||||
_devbox_check_host_ssh() {
|
||||
[ -f "/.dockerenv" ] || return 0
|
||||
local ssh_cfg="$HOME/.ssh-local/config"
|
||||
[ -f "$ssh_cfg" ] || return 0
|
||||
local key_pub="$HOME/.ssh-local/devbox_jump_ed25519.pub"
|
||||
[ -f "$key_pub" ] || return 0
|
||||
local flag="/tmp/.devbox_host_ssh_ok"
|
||||
[ -f "$flag" ] && return 0
|
||||
if ssh -F "$ssh_cfg" \
|
||||
-o BatchMode=yes \
|
||||
-o ConnectTimeout=2 \
|
||||
-o StrictHostKeyChecking=accept-new \
|
||||
mac true 2>/dev/null; then
|
||||
touch "$flag"
|
||||
return 0
|
||||
fi
|
||||
local pub_key
|
||||
pub_key=$(cat "$key_pub")
|
||||
printf '\n\033[1;33m⚠ devbox: Mac host not reachable via SSH\033[0m\n'
|
||||
printf ' Some tools use SSH to run commands on the Mac host.\n'
|
||||
printf ' Fix (run both on the Mac):\n\n'
|
||||
printf ' \033[1mStep 1\033[0m System Settings → General → Sharing → Remote Login → ON\n\n'
|
||||
printf ' \033[1mStep 2\033[0m echo '"'"'%s'"'"' >> ~/.ssh/authorized_keys\n' "$pub_key"
|
||||
printf '\n Then open a new shell in the container to verify.\n\n'
|
||||
}
|
||||
_devbox_check_host_ssh
|
||||
unset -f _devbox_check_host_ssh
|
||||
|
||||
# ── LAN access via the host (dssh) ───────────────────────────────────
|
||||
# When running on a VM-backed host (macOS OrbStack / Docker Desktop), the
|
||||
# entrypoint's setup-lan-access.sh generates ~/.ssh-local/config so the host
|
||||
# can be used as an SSH jump to reach LAN peers. These aliases wrap `ssh -F`
|
||||
# / `scp -F` against that config. Guarded so they only appear when the config
|
||||
# was actually generated (no-op / absent on native Linux hosts).
|
||||
if [ -r "$HOME/.ssh-local/config" ]; then
|
||||
alias dssh='ssh -F "$HOME/.ssh-local/config"'
|
||||
alias dscp='scp -F "$HOME/.ssh-local/config"'
|
||||
fi
|
||||
|
||||
# Safety: confirm before destructive ops
|
||||
alias rm='rm -i'
|
||||
alias mv='mv -i'
|
||||
alias cp='cp -i'
|
||||
|
||||
# ── Shell integrations ───────────────────────────────────────────────
|
||||
# zoxide — smarter cd. Use 'z <fragment>' to jump to previously-visited dirs.
|
||||
if command -v zoxide >/dev/null 2>&1; then
|
||||
eval "$(zoxide init bash)"
|
||||
fi
|
||||
|
||||
# fzf — fuzzy finder key bindings (Ctrl-R for history, Ctrl-T for files).
|
||||
# We install fzf from GitHub releases (not apt), so sourcing from the
|
||||
# apt-path /usr/share/doc/fzf/examples/* would find nothing. Use the
|
||||
# binary's own --bash flag (available since fzf 0.48) for setup.
|
||||
if command -v fzf >/dev/null 2>&1; then
|
||||
eval "$(fzf --bash)" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# cli_utils — shell FUNCTIONS (fgit, fhist, fssh, portcheck, up, mkcd, extract,
|
||||
# agents-sync, …). This is the OTHER HALF of the cli_utils wiring, and until
|
||||
# v1.8.11 the image shipped only one half. entrypoint-user.sh symlinks the repo's
|
||||
# bin/ COMMANDS into ~/.local/bin, which is what makes them resolve in
|
||||
# NON-interactive shells (docker exec, agent tool shells, scripts). A symlink
|
||||
# cannot carry a shell function, and a function cannot be reached from a
|
||||
# non-interactive shell, so the two mechanisms are disjoint and both are
|
||||
# required. Nothing sourced the loader: measured 2026-08-30 on v1.8.11, all 14
|
||||
# functions were simply missing on a device whose $HOME has no zsh rc — which is
|
||||
# the normal case, since the container's interactive shell is bash and zsh is not
|
||||
# installed in the image. The image was already paying this layer's dependency
|
||||
# cost (fzf, bat, fd, rg, jq are all baked partly FOR these functions) while
|
||||
# delivering none of its benefit.
|
||||
#
|
||||
# Detection order deliberately mirrors the symlink block in entrypoint-user.sh so
|
||||
# that commands and functions can never come from two different clones.
|
||||
# CLI_UTILS_SOURCE=0 opts out. That is independent of CLI_UTILS_LINK=0 on purpose:
|
||||
# they disable independent mechanisms, and someone who wants PATH commands
|
||||
# without 14 extra functions in every prompt (or vice versa) should be able to
|
||||
# say so.
|
||||
#
|
||||
# THE LOADER IS BASH-SAFE, MEASURED, NOT ASSUMED: despite every function file
|
||||
# being named *.zsh, sourcing cli_utils.sh under `bash --noprofile --norc` exits
|
||||
# 0 with no errors and defines all 14, and they run (pathls, mkcd, up, extract,
|
||||
# agents-sync, fhist all verified). The single zsh-only construct in the tree
|
||||
# (`print -z` in fzf/fhist.zsh) is already guarded by [[ -n $ZSH_VERSION ]] with
|
||||
# a bash fallback, and the loader's own header states "bash & zsh compatible".
|
||||
# ACCEPTED RISK, stated plainly: /workspace/cli_utils is a HOST BIND MOUNT, so
|
||||
# unlike a pinned git ref this content floats outside the image's control. A
|
||||
# future cli_utils commit that adds a genuinely zsh-only file would surface as
|
||||
# parse errors at every prompt on every device. Errors are left VISIBLE rather
|
||||
# than sent to /dev/null so that failure is diagnosable instead of mysterious,
|
||||
# and CLI_UTILS_SOURCE=0 is the documented one-line escape hatch.
|
||||
if [ "${CLI_UTILS_SOURCE:-1}" != "0" ]; then
|
||||
for _cu in "${CLI_UTILS_CONTAINER_PATH:-}" /workspace/cli_utils "$HOME/cli_utils" /workspace/*/cli_utils; do
|
||||
[ -n "$_cu" ] || continue
|
||||
if [ -r "$_cu/cli_utils.sh" ]; then
|
||||
. "$_cu/cli_utils.sh" || true
|
||||
break
|
||||
fi
|
||||
done
|
||||
unset _cu
|
||||
fi
|
||||
|
||||
# ── PROMPT_COMMAND: flush history every prompt ───────────────────────
|
||||
# Installed AFTER zoxide init so zoxide's hook is already in place;
|
||||
# we append with a newline separator to avoid the ';;' parse error
|
||||
# described at the top of this file. Guarded so repeated sourcing
|
||||
# (e.g. `exec bash`) doesn't stack duplicates.
|
||||
#
|
||||
# The guard MUST stay shell-local (NOT exported): if it leaks into child
|
||||
# processes, every nested shell -- crucially each tmux pane, which inherits
|
||||
# the tmux server's env -- skips installing `history -a` and only persists
|
||||
# history on a clean exit. Abrupt termination (docker stop, tmux kill-server,
|
||||
# SIGKILL) then loses that shell's in-memory history. Keeping it unexported
|
||||
# means each new interactive shell re-installs its own per-prompt flush.
|
||||
if [ -z "${DEVBOX_HIST_SET:-}" ]; then
|
||||
PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND$'\n'}history -a"
|
||||
DEVBOX_HIST_SET=1
|
||||
fi
|
||||
|
||||
# ── Prompt: show [opencode-devbox] tag so it's obvious you're in the container
|
||||
# Preserves the default Debian PS1 logic but prefixes with a container marker.
|
||||
# We check for the literal '[devbox]' substring in PS1 rather than relying on
|
||||
# an exported guard variable — otherwise `exec bash` inherits the guard but
|
||||
# gets a fresh (prefix-less) PS1 from .bashrc, and the prefix would never be
|
||||
# re-added in the new shell.
|
||||
if [ -n "${PS1:-}" ] && [[ "$PS1" != *"[devbox]"* ]]; then
|
||||
PS1='\[\e[38;5;39m\][devbox]\[\e[0m\] '"${PS1}"
|
||||
fi
|
||||
@@ -0,0 +1,14 @@
|
||||
# Global gitignore — personal/tooling artifacts (applies to all repos in the container)
|
||||
# Seeded into $HOME/.gitignore_global by entrypoint-user.sh and wired via
|
||||
# `git config --global core.excludesFile`. Edit freely; it is yours after first boot.
|
||||
|
||||
# backup / editor / merge artifacts
|
||||
*.bak
|
||||
*.bak.*
|
||||
*~
|
||||
*.orig
|
||||
*.swp
|
||||
*.tmp
|
||||
|
||||
# AI/LLM tool local settings — machine-specific perms + credentials, never commit
|
||||
**/.claude/settings.local.json
|
||||
@@ -0,0 +1,27 @@
|
||||
# opencode-devbox readline defaults
|
||||
# To override, bind-mount your host's ~/.inputrc over this file
|
||||
# via docker-compose.yml.
|
||||
|
||||
# Inherit system-wide defaults (colour, 8-bit input, …) if present
|
||||
$include /etc/inputrc
|
||||
|
||||
# ── History search on Up/Down ────────────────────────────────────────
|
||||
# Type a prefix, press Up, and walk through previous commands starting
|
||||
# with that prefix. Ctrl-Up / Ctrl-Down keep the unconditional stepper.
|
||||
"\e[A": history-search-backward
|
||||
"\e[B": history-search-forward
|
||||
"\e[1;5A": previous-history
|
||||
"\e[1;5B": next-history
|
||||
|
||||
# ── Completion quality ───────────────────────────────────────────────
|
||||
set show-all-if-ambiguous on # single Tab shows matches on ambiguity
|
||||
set completion-ignore-case on # case-insensitive file/dir completion
|
||||
set colored-stats on # colour ls-style completion list entries
|
||||
set colored-completion-prefix on # highlight the matched prefix
|
||||
set visible-stats on # append /*@ type indicators in completion
|
||||
set mark-symlinked-directories on # add trailing / to symlinks to dirs
|
||||
set skip-completed-text on # don't re-insert already-typed text
|
||||
|
||||
# Treat hyphens and underscores as equivalent when completing (e.g.
|
||||
# typing `foo-` matches both `foo-bar` and `foo_bar`).
|
||||
set completion-map-case on
|
||||
Executable
+91
@@ -0,0 +1,91 @@
|
||||
#!/bin/sh
|
||||
# devbox-skill-reconcile — hand skillset-OWNED skills back to the live clone.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# ---------------
|
||||
# entrypoint-user.sh links the image-baked skills into ~/.agents/skills/ EARLY
|
||||
# (before pi-deploy), because the smoke readiness probe gates on markers that
|
||||
# only land later, and a link created after that gate produced a flaky
|
||||
# assertion. Those links are created with a `[ ! -e ]` guard — "only when
|
||||
# absent" — and the skillset deploy runs LAST, treating already-present links
|
||||
# as foreign and leaving them alone. Net effect through v1.8.4: the baked copy
|
||||
# always won, so an edit pushed to a skillset-owned skill was invisible in
|
||||
# every container until the next image build (measured on two hosts: live
|
||||
# skillset md5 129bcc4752 vs baked 5236024fef, the new section absent).
|
||||
#
|
||||
# The fix is NOT "the skillset always wins". Ownership is per-skill (see
|
||||
# rootfs/usr/local/share/pi-devbox/skills/VENDORED.md):
|
||||
#
|
||||
# pi-devbox-environment authored in pi-devbox → baked IS canonical
|
||||
# pi-extensions owned by the package repo, copied over the snapshot
|
||||
# at build time; skillset carries a DOWNSTREAM copy
|
||||
# that can lag → baked must keep winning
|
||||
# mempalace owned by the skillset repo; baked is a snapshot
|
||||
# fallback for containers with no skillset mounted
|
||||
# → the live clone must win when it is present
|
||||
#
|
||||
# So only skills listed in skills/skillset-owned.txt are handed over. Baked
|
||||
# links stay as the fallback (the early-link race fix is untouched), and a user
|
||||
# override always beats both: a real directory is never replaced, and neither is
|
||||
# a symlink that already points somewhere other than the baked tree.
|
||||
#
|
||||
# Usage: devbox-skill-reconcile <skillset-root> [skills-dir] [baked-src]
|
||||
# skillset-root the mounted skillset repo (contains skills/<name>/)
|
||||
# skills-dir default $HOME/.agents/skills
|
||||
# baked-src default /usr/local/share/pi-devbox/skills
|
||||
#
|
||||
# Idempotent, and silent unless it changes something. Exits 0 when there is
|
||||
# nothing to do (no skillset, no list) so the entrypoint never fails on it.
|
||||
set -eu
|
||||
|
||||
SKILLSET_ROOT="${1:-}"
|
||||
SKILLS_DIR="${2:-$HOME/.agents/skills}"
|
||||
BAKED_SRC="${3:-/usr/local/share/pi-devbox/skills}"
|
||||
BAKED_SRC="${BAKED_SRC%/}" # a trailing slash would make the prefix
|
||||
# match below ("$BAKED_SRC"/*) match nothing
|
||||
|
||||
[ -n "$SKILLSET_ROOT" ] || exit 0
|
||||
[ -d "$SKILLSET_ROOT/skills" ] || exit 0
|
||||
[ -d "$SKILLS_DIR" ] || exit 0
|
||||
|
||||
# Absolutise BOTH roots before they are used, because each has its own way of
|
||||
# failing silently when relative: a relative symlink TARGET is resolved against
|
||||
# the link's directory (~/.agents/skills), not $PWD, so it would dangle on
|
||||
# creation; and a relative BAKED_SRC would never prefix-match the absolute
|
||||
# target that `readlink` reports, so every skill would be skipped and the fix
|
||||
# would look like it had simply done nothing.
|
||||
SKILLSET_ROOT=$(CDPATH= cd -- "$SKILLSET_ROOT" 2>/dev/null && pwd) || exit 0
|
||||
BAKED_SRC=$(CDPATH= cd -- "$BAKED_SRC" 2>/dev/null && pwd) || exit 0
|
||||
OWNED_LIST="$BAKED_SRC/skillset-owned.txt"
|
||||
[ -f "$OWNED_LIST" ] || exit 0
|
||||
|
||||
while IFS= read -r _line || [ -n "$_line" ]; do
|
||||
# strip comments and surrounding whitespace; skip blanks
|
||||
_name=$(printf '%s\n' "$_line" | sed -e 's/#.*$//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
||||
[ -n "$_name" ] || continue
|
||||
# defensive: a list entry must be a plain skill name, never a path
|
||||
case "$_name" in */*|.*) continue ;; esac
|
||||
|
||||
_live="$SKILLSET_ROOT/skills/$_name"
|
||||
_link="$SKILLS_DIR/$_name"
|
||||
|
||||
# the skillset does not ship it → the baked fallback is all there is
|
||||
[ -d "$_live" ] || continue
|
||||
# a real directory is a user override → never touch
|
||||
[ -L "$_link" ] || continue
|
||||
|
||||
# only ever replace OUR OWN link. readlink is deliberate: `readlink -f`
|
||||
# would resolve a link that already points into the skillset clone and,
|
||||
# since both trees hold a same-named skill, could not tell them apart.
|
||||
_target=$(readlink "$_link" 2>/dev/null || true)
|
||||
case "$_target" in
|
||||
"$BAKED_SRC"/*|"$BAKED_SRC") ;; # baked link → ours to replace
|
||||
*) continue ;; # user/foreign target → leave alone
|
||||
esac
|
||||
|
||||
# -n so an existing symlink-to-directory is replaced rather than followed
|
||||
# (without it, ln would create $_link/$_name inside the baked tree).
|
||||
if ln -sfn "$_live" "$_link" 2>/dev/null; then
|
||||
printf 'skill %s: baked snapshot -> live skillset (%s)\n' "$_name" "$_live"
|
||||
fi
|
||||
done < "$OWNED_LIST"
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# dot-watch — auto-rerender a graphviz .dot file to PNG on every save.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# pi-studio renders mermaid natively but has no graphviz/DOT renderer.
|
||||
# Its markdown preview DOES render local image links (.png/.jpg/.gif/.webp),
|
||||
# and the editor offers "refresh from disk". This helper closes the loop:
|
||||
# edit a .dot file -> dot-watch regenerates <name>.png -> hit refresh in
|
||||
# Studio to see the update. Uses mtime polling (no inotify dependency,
|
||||
# which isn't in the trixie-slim base).
|
||||
#
|
||||
# USAGE
|
||||
# dot-watch <file.dot> [layout] [dpi]
|
||||
# layout: dot|neato|fdp|circo|twopi (default: dot)
|
||||
# dpi: output resolution (default: 150)
|
||||
# env: DOT_WATCH_INTERVAL=<seconds> poll interval (default: 1)
|
||||
#
|
||||
# EXAMPLES
|
||||
# dot-watch /workspace/graph.dot
|
||||
# dot-watch graph.dot neato 200
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SRC="${1:?usage: dot-watch <file.dot> [layout] [dpi]}"
|
||||
LAYOUT="${2:-dot}"
|
||||
DPI="${3:-150}"
|
||||
|
||||
[[ -f "$SRC" ]] || { echo "error: no such file: $SRC" >&2; exit 1; }
|
||||
command -v "$LAYOUT" >/dev/null || { echo "error: layout engine '$LAYOUT' not found" >&2; exit 1; }
|
||||
|
||||
OUT="${SRC%.dot}.png"
|
||||
INTERVAL="${DOT_WATCH_INTERVAL:-1}" # seconds between polls
|
||||
ERRLOG="$(mktemp -t dot-watch.XXXXXX.err)"
|
||||
trap 'rm -f "$ERRLOG"' EXIT
|
||||
|
||||
render() {
|
||||
if "$LAYOUT" -Tpng -Gdpi="$DPI" "$SRC" -o "$OUT" 2> "$ERRLOG"; then
|
||||
printf '[%s] rendered -> %s\n' "$(date +%H:%M:%S)" "$OUT"
|
||||
else
|
||||
printf '[%s] DOT error:\n' "$(date +%H:%M:%S)"
|
||||
sed 's/^/ /' "$ERRLOG"
|
||||
fi
|
||||
}
|
||||
|
||||
# portable mtime (GNU stat, fallback to BSD stat)
|
||||
mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null; }
|
||||
|
||||
echo "watching $SRC ($LAYOUT, ${DPI}dpi) -> $OUT [Ctrl-C to stop]"
|
||||
render
|
||||
last="$(mtime "$SRC")"
|
||||
while true; do
|
||||
sleep "$INTERVAL"
|
||||
[[ -f "$SRC" ]] || continue
|
||||
now="$(mtime "$SRC")"
|
||||
if [[ "$now" != "$last" ]]; then
|
||||
last="$now"
|
||||
render
|
||||
fi
|
||||
done
|
||||
Executable
+260
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env bash
|
||||
# pi-devbox-version — show which pi-devbox image build is running.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# The image bakes ground-truth build info into /etc/pi-devbox/build-manifest.json
|
||||
# at `docker build` time (see Dockerfile.variant): the release tag, build date,
|
||||
# source commit, live `pi --version` at build time, and the actual checked-out
|
||||
# commit of every /opt component clone. That answers "what image am I running?"
|
||||
# — but only if you know to go look for the file. This wraps it into one
|
||||
# command, prints it human-first at container start (see entrypoint-user.sh),
|
||||
# and stays available on demand for the rest of the session.
|
||||
#
|
||||
# USAGE
|
||||
# pi-devbox-version human-readable summary (default)
|
||||
# pi-devbox-version --json raw manifest JSON (for scripting)
|
||||
# pi-devbox-version --quiet one-line "release_tag (source_revision)" form
|
||||
# pi-devbox-version --no-skills skip the skill-source section (used at
|
||||
# container start, where it would be premature)
|
||||
#
|
||||
# EXIT STATUS
|
||||
# 0 on success. 1 if the manifest is missing (e.g. an image built before
|
||||
# this file existed, or a non-pi-devbox base) — prints a short notice
|
||||
# to stderr rather than failing silently.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
MANIFEST=/etc/pi-devbox/build-manifest.json
|
||||
MODE="human"
|
||||
SHOW_SKILLS="yes"
|
||||
|
||||
# A `case "${1:-}"` here only ever looked at the FIRST argument, so
|
||||
# `--no-skills --json` matched --no-skills, silently dropped --json, and
|
||||
# printed human text to a caller expecting JSON (a real failure: a jq
|
||||
# consumer piping that output gets a parse error, not a wrong-but-parseable
|
||||
# answer). Loop over every argument instead, and reject anything unknown
|
||||
# rather than silently ignoring it the same way.
|
||||
for _arg in "$@"; do
|
||||
case "$_arg" in
|
||||
--json) MODE="json" ;;
|
||||
--quiet|-q) MODE="quiet" ;;
|
||||
--no-skills) SHOW_SKILLS="no" ;;
|
||||
--help|-h)
|
||||
# Print the leading `#`-comment block verbatim, stopping at the first
|
||||
# non-comment line, rather than a hardcoded line range: `sed -n
|
||||
# '2,22p'` was silently truncating --help because this file has grown
|
||||
# usage lines since that range was written, and a fixed range will
|
||||
# drift again the next time a comment is added above it.
|
||||
awk 'NR==1{next} /^#/{sub(/^# ?/,""); print; next} {exit}' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "pi-devbox-version: unknown option: $_arg" >&2
|
||||
echo " try --help" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ ! -f "$MANIFEST" ]; then
|
||||
echo "pi-devbox-version: no build manifest at $MANIFEST" >&2
|
||||
echo " (image predates the manifest, or this isn't a pi-devbox image)" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v jq >/dev/null 2>&1; then
|
||||
echo "pi-devbox-version: jq not found; dumping raw manifest instead" >&2
|
||||
cat "$MANIFEST"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ "$MODE" = "json" ]; then
|
||||
cat "$MANIFEST"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
release_tag=$(jq -r '.release_tag' "$MANIFEST")
|
||||
build_date=$(jq -r '.build_date' "$MANIFEST")
|
||||
source_rev=$(jq -r '.source_revision' "$MANIFEST")
|
||||
pi_version_baked=$(jq -r '.pi_version' "$MANIFEST")
|
||||
# `// empty` matters: images built before v1.8.6 have no such field, and
|
||||
# `jq -r` renders a JSON null as the 4-char string "null" — which would
|
||||
# print as a bogus version rather than being treated as absent.
|
||||
mp_version_baked=$(jq -r '.mempalace_version // empty' "$MANIFEST")
|
||||
|
||||
if [ "$MODE" = "quiet" ]; then
|
||||
printf '%s (%s)\n' "$release_tag" "${source_rev:0:7}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Live drift check: has `pi` been upgraded since this container was built?
|
||||
# (image is immutable, but a volume-persisted ~/.pi could in theory shadow
|
||||
# the baked binary — this stays honest rather than trusting the manifest
|
||||
# blindly, same "ground truth over intent" spirit as how the manifest
|
||||
# itself is generated in Dockerfile.variant.)
|
||||
pi_version_live=""
|
||||
if command -v pi >/dev/null 2>&1; then
|
||||
pi_version_live=$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')
|
||||
fi
|
||||
|
||||
# Same check for the palace, which matters more than it looks: mempalace is
|
||||
# the one component that is BOTH client (here) and server (synlig runs this
|
||||
# same image), so a skew between the two is a real failure mode rather than
|
||||
# cosmetic. `mempalace --version` prints "MemPalace 3.8.0" — name-prefixed,
|
||||
# unlike pi's bare "0.84.3" — hence $NF rather than reading the whole line.
|
||||
mp_version_live=""
|
||||
if command -v mempalace >/dev/null 2>&1; then
|
||||
mp_version_live=$(mempalace --version 2>/dev/null | head -n1 | awk '{print $NF}' | tr -d '\r\n')
|
||||
fi
|
||||
|
||||
printf 'pi-devbox %s\n' "$release_tag"
|
||||
printf ' built: %s (source %s)\n' "$build_date" "${source_rev:0:12}"
|
||||
if [ -n "$pi_version_live" ] && [ "$pi_version_live" != "$pi_version_baked" ]; then
|
||||
printf ' pi: %s \033[33m(baked as %s — drift detected)\033[0m\n' "$pi_version_live" "$pi_version_baked"
|
||||
else
|
||||
printf ' pi: %s\n' "${pi_version_live:-$pi_version_baked}"
|
||||
fi
|
||||
|
||||
# Printed only when known, so this degrades quietly on pre-v1.8.6 images
|
||||
# instead of showing an empty or "null" palace line.
|
||||
if [ -n "$mp_version_live" ] || [ -n "$mp_version_baked" ]; then
|
||||
if [ -n "$mp_version_live" ] && [ -n "$mp_version_baked" ] && [ "$mp_version_live" != "$mp_version_baked" ]; then
|
||||
printf ' palace: %s \033[33m(baked as %s — drift detected)\033[0m\n' "$mp_version_live" "$mp_version_baked"
|
||||
else
|
||||
printf ' palace: %s\n' "${mp_version_live:-$mp_version_baked}"
|
||||
fi
|
||||
fi
|
||||
|
||||
printf ' components:\n'
|
||||
jq -r '.components | to_entries[] | select(.value != null) | " \(.key): \(.value[0:12])"' "$MANIFEST"
|
||||
|
||||
# ── Which copy of each vendored skill is actually being read? ─────────
|
||||
# The image bakes fallback skills under /usr/local/share/pi-devbox/skills/,
|
||||
# but for skills the skillset repo OWNS (skillset-owned.txt) a mounted live
|
||||
# clone takes over at container start via devbox-skill-reconcile. Nothing
|
||||
# reported which copy won, so a stale baked snapshot and a current live clone
|
||||
# looked identical from inside — and on this fleet the baked mempalace copy is
|
||||
# read by NOBODY (all four compose stacks mount a workspace containing the
|
||||
# skillset), which is exactly the sort of fact that should be visible rather
|
||||
# than reasoned about. Same "drift detected" shape as the pi/palace lines
|
||||
# above: what is live, annotated with what was baked, when they disagree.
|
||||
#
|
||||
# Skipped with --no-skills at container start (entrypoint-user.sh calls this
|
||||
# FIRST, before the baked links exist and long before the skillset deploy and
|
||||
# reconcile run last), because a section that is accurate only after boot
|
||||
# finishes is worse than no section at all.
|
||||
BAKED_SKILLS=/usr/local/share/pi-devbox/skills
|
||||
SKILLS_DIR="${HOME:-/home/developer}/.agents/skills"
|
||||
|
||||
if [ "$SHOW_SKILLS" = "yes" ] && [ -d "$BAKED_SKILLS" ] && [ -d "$SKILLS_DIR" ]; then
|
||||
# Recorded provenance of the vendored mempalace snapshot (absent on images
|
||||
# built before this existed — `// empty` so a JSON null never prints as the
|
||||
# 4-char string "null", the same trap noted for mempalace_version above).
|
||||
# `_tree_sha256`, not `_sha256`: it is a hash over every file in the
|
||||
# vendored skill DIRECTORY (see tree_sha256() below), not one file, because
|
||||
# a single-file hash reports "identical" against a live checkout that added
|
||||
# or edited a sibling file — pi-extensions already ships two files, so this
|
||||
# is not hypothetical.
|
||||
snap_ref=$(jq -r '.skillset_snapshot_ref // empty' "$MANIFEST")
|
||||
snap_sha=$(jq -r '.skillset_snapshot_tree_sha256 // empty' "$MANIFEST")
|
||||
# Which pi-extensions copy the BUILD baked. Distinct from everything else in
|
||||
# this section, which reports which copy is being READ at runtime: for
|
||||
# pi-extensions the baked tree is itself one of two possible copies, and that
|
||||
# choice was made at build time and is not recoverable by inspection.
|
||||
px_src=$(jq -r '.pi_extensions_skill_source // empty' "$MANIFEST")
|
||||
# Same pipeline Dockerfile.variant uses to measure the baked directory at
|
||||
# build time: relative paths in `find | sort` order, each hashed, the whole
|
||||
# listing folded into one sha256. Keep the two definitions identical — they
|
||||
# run in different processes (image build vs. this container) and are
|
||||
# meaningless to compare unless they agree byte-for-byte on the algorithm.
|
||||
tree_sha256() {
|
||||
( cd "$1" && find . -type f -print | LC_ALL=C sort | xargs -r sha256sum ) 2>/dev/null | sha256sum | cut -d' ' -f1
|
||||
}
|
||||
|
||||
# Iterate the baked tree rather than a hardcoded name list, so vendoring a
|
||||
# fourth skill needs no edit here. The header prints only if the tree is
|
||||
# non-empty, so this can never emit a dangling "skills:" label.
|
||||
_printed_header="no"
|
||||
for _dir in "$BAKED_SKILLS"/*/; do
|
||||
[ -d "$_dir" ] || continue
|
||||
if [ "$_printed_header" = "no" ]; then
|
||||
printf ' skills:\n'
|
||||
_printed_header="yes"
|
||||
fi
|
||||
_name=$(basename "$_dir")
|
||||
_link="$SKILLS_DIR/$_name"
|
||||
|
||||
if [ ! -e "$_link" ]; then
|
||||
printf ' %-22s not linked\n' "$_name"
|
||||
continue
|
||||
fi
|
||||
|
||||
_target=$(readlink -f "$_link" 2>/dev/null || echo "$_link")
|
||||
case "$_target" in
|
||||
"$BAKED_SKILLS"/*|"$BAKED_SKILLS")
|
||||
# "baked" alone used to be the whole story. For pi-extensions it is not:
|
||||
# the baked tree holds EITHER the package copy that Dockerfile.variant
|
||||
# lays over the snapshot, OR the vendored floor, when the clone had no
|
||||
# skill/ at that ref. The two are indistinguishable by inspection — same
|
||||
# path, same filenames, same permissions — so the build records which one
|
||||
# it used and this reports it. Without this line a six-week-stale
|
||||
# fallback skill looks exactly like a current one, which is precisely how
|
||||
# the floor went unnoticed from 2026-07-30 to 2026-09-10.
|
||||
if [ "$_name" = "pi-extensions" ] && [ -n "$px_src" ]; then
|
||||
case "$px_src" in
|
||||
package)
|
||||
printf ' %-22s baked (package copy)\n' "$_name" ;;
|
||||
vendored-floor)
|
||||
printf ' %-22s baked \033[33m(FALLBACK: vendored floor — clone had no skill/)\033[0m\n' "$_name" ;;
|
||||
divergent)
|
||||
printf ' %-22s baked \033[33m(MIXED: part package, part floor)\033[0m\n' "$_name" ;;
|
||||
*)
|
||||
printf ' %-22s baked\n' "$_name" ;;
|
||||
esac
|
||||
else
|
||||
printf ' %-22s baked\n' "$_name"
|
||||
fi
|
||||
continue
|
||||
;;
|
||||
esac
|
||||
|
||||
# Outside the baked tree: a mounted skillset clone, or a user override.
|
||||
# The link target is <repo>/skills/<name>, so the repo root is two up.
|
||||
# Everything here is guarded: this script runs on the container-start path
|
||||
# and must never fail, and `set -e` is in force.
|
||||
_root=$(cd "$_target/../.." 2>/dev/null && pwd) || _root=""
|
||||
_head=""
|
||||
if [ -n "$_root" ]; then
|
||||
_head=$(git -C "$_root" rev-parse HEAD 2>/dev/null || echo "")
|
||||
fi
|
||||
_where="live ${_root:-$_target}"
|
||||
[ -n "$_head" ] && _where="$_where @ ${_head:0:7}"
|
||||
|
||||
# For the one skill whose baked fingerprint we recorded, say plainly
|
||||
# whether the live copy differs from what shipped. This is the check CI
|
||||
# cannot perform (the skillset is private) and the container can, free.
|
||||
# Hash the whole live DIRECTORY with the same tree_sha256() used to
|
||||
# measure the baked one in Dockerfile.variant — a SKILL.md-only compare
|
||||
# would silently ignore a changed or added sibling file.
|
||||
_live_sha=""
|
||||
if [ -n "$snap_sha" ] && [ "$_name" = "mempalace" ] && [ -d "$_target" ]; then
|
||||
_live_sha=$(tree_sha256 "$_target")
|
||||
fi
|
||||
if [ -z "$_live_sha" ]; then
|
||||
printf ' %-22s %s\n' "$_name" "$_where"
|
||||
elif [ "$_live_sha" = "$snap_sha" ]; then
|
||||
printf ' %-22s %s (identical to baked snapshot)\n' "$_name" "$_where"
|
||||
elif [ -n "$_head" ] && [ "$_head" = "$snap_ref" ]; then
|
||||
# Same commit, different bytes — i.e. uncommitted edits in the live
|
||||
# checkout. Distinguished from plain drift because otherwise the line
|
||||
# reads as a self-contradiction ("@ c04cd15 ... baked snapshot c04cd15
|
||||
# — live copy differs") and a reader would suspect the tool, not the
|
||||
# working tree.
|
||||
printf ' %-22s %s \033[33m(baked snapshot %s + uncommitted edits)\033[0m\n' \
|
||||
"$_name" "$_where" "${snap_ref:0:7}"
|
||||
else
|
||||
printf ' %-22s %s \033[33m(baked snapshot %s — live copy differs)\033[0m\n' \
|
||||
"$_name" "$_where" "${snap_ref:0:7}"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# studio-expose — make a container-loopback pi-studio server reachable
|
||||
# through a published Docker port.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# pi-studio hard-binds its HTTP/WebSocket server to 127.0.0.1 inside the
|
||||
# container (index.ts: `.listen(port, "127.0.0.1")`) and there is no
|
||||
# --host / bind flag. A plain `docker run -p 8765:8765` forwards to the
|
||||
# container's EXTERNAL interface (eth0), not its loopback, so it cannot
|
||||
# reach Studio. This helper runs a socat TCP relay that listens on the
|
||||
# container's egress IP and forwards to 127.0.0.1:<port>, so a published
|
||||
# port (and an `ssh -L` tunnel from your laptop) can reach Studio.
|
||||
#
|
||||
# SECURITY
|
||||
# This intentionally exposes Studio beyond loopback — anything that can
|
||||
# reach the container's network interface (and the host port you publish)
|
||||
# can connect. Studio's tokenized URL is the only auth. Mitigate by
|
||||
# publishing the host port on localhost only:
|
||||
# ports: ["127.0.0.1:${STUDIO_PORT}:${STUDIO_PORT}"]
|
||||
# and use `ssh -L` for remote access. Bridge nothing you don't intend to.
|
||||
#
|
||||
# USAGE
|
||||
# studio-expose [PORT] # bridge PORT (default: $STUDIO_PORT or 8765)
|
||||
# studio-expose --help
|
||||
#
|
||||
# Typically: inside a pi session run `/studio --no-browser --port 8765`,
|
||||
# then in a container shell run `studio-expose` (or set STUDIO_EXPOSE=1 in
|
||||
# compose to auto-start it on container boot — see entrypoint-user.sh).
|
||||
#
|
||||
# Runs in the foreground; Ctrl-C to stop. The entrypoint auto-start path
|
||||
# runs it backgrounded.
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${1:-${STUDIO_PORT:-8765}}"
|
||||
|
||||
if [ "$PORT" = "--help" ] || [ "$PORT" = "-h" ]; then
|
||||
sed -n '2,31p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$PORT" in
|
||||
''|*[!0-9]*) echo "studio-expose: invalid port '$PORT'" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
if ! command -v socat >/dev/null 2>&1; then
|
||||
echo "studio-expose: socat not found in PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Container's primary egress IPv4. In Docker the container hostname resolves
|
||||
# to its eth0 address, so `hostname -I` lists it; we take the first
|
||||
# non-loopback IPv4. We must bind this specific address rather than 0.0.0.0
|
||||
# — binding 0.0.0.0 would collide with Studio's own 127.0.0.1:PORT listener
|
||||
# (0.0.0.0 includes loopback) and fail with EADDRINUSE. `ip route get` is a
|
||||
# fallback only when iproute2 happens to be present (not in the base image).
|
||||
BIND_IP="$(hostname -I 2>/dev/null | tr ' ' '\n' \
|
||||
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | grep -vE '^127\.' | head -n1)"
|
||||
if [ -z "${BIND_IP:-}" ] && command -v ip >/dev/null 2>&1; then
|
||||
BIND_IP="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')"
|
||||
fi
|
||||
[ -n "${BIND_IP:-}" ] || BIND_IP="$(hostname -i 2>/dev/null | awk '{print $1}')"
|
||||
if [ -z "${BIND_IP:-}" ]; then
|
||||
echo "studio-expose: could not determine container egress IP" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "studio-expose: bridging ${BIND_IP}:${PORT} -> 127.0.0.1:${PORT}"
|
||||
echo "studio-expose: open the tokenized URL pi-studio printed; if the host"
|
||||
echo "studio-expose: publishes ${PORT}, reach it at http://127.0.0.1:${PORT}/?token=..."
|
||||
echo "studio-expose: (remote host: ssh -L ${PORT}:127.0.0.1:${PORT} user@host)"
|
||||
|
||||
# fork: one child per connection (handles concurrent + long-lived WebSocket
|
||||
# connections). reuseaddr: survive quick restarts. Studio need not be up yet
|
||||
# — connections simply fail until `/studio --port ${PORT}` is running.
|
||||
exec socat "TCP-LISTEN:${PORT},bind=${BIND_IP},fork,reuseaddr" "TCP:127.0.0.1:${PORT}"
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup-lan-access.sh — generic, host-OS-agnostic LAN reachability helper.
|
||||
#
|
||||
# THE PROBLEM
|
||||
# On macOS (OrbStack / Docker Desktop) and Docker Desktop on Windows, the
|
||||
# container runs inside a Linux VM behind the host's network stack. The
|
||||
# host's *directly-attached* LAN peers (e.g. other boxes on 192.168.1.0/24)
|
||||
# are NOT bridged into the container by default — only the host itself and
|
||||
# *routed* subnets are reachable. On native Linux Docker the default bridge
|
||||
# already NATs container egress onto the host's LAN, so LAN peers are usually
|
||||
# reachable directly and no workaround is needed.
|
||||
#
|
||||
# THE APPROACH ("detect, and on a VM-backed host use the host as a jump")
|
||||
# The one thing reachable from a container on every OS is the host itself
|
||||
# (host.docker.internal). So on VM-backed hosts we generate a writable SSH
|
||||
# config that reaches the host and lets the user ProxyJump onward to LAN
|
||||
# peers the host can reach. On native Linux we render the same writable
|
||||
# config (for the ControlPath redirect + Include ~/.ssh/config) but emit no
|
||||
# jump block, since LAN peers are reachable directly there.
|
||||
#
|
||||
# We ship the MECHANISM (a generic `host` jump alias + writable config),
|
||||
# never the POLICY: the user's specific target hosts live in their own
|
||||
# bind-mounted ~/.ssh/config (add `ProxyJump host` to those entries) — which
|
||||
# is pulled in via the `Include ~/.ssh/config` line below.
|
||||
#
|
||||
# WHY A WRITABLE SIDECAR (~/.ssh-local)
|
||||
# The devbox typically bind-mounts the host's ~/.ssh READ-ONLY (so agents
|
||||
# can read keys for git but can't tamper with config/known_hosts/authorized_
|
||||
# keys). That means we cannot edit ~/.ssh/config or write ~/.ssh/known_hosts.
|
||||
# So everything generated here lives under the writable ~/.ssh-local, used
|
||||
# via `ssh -F ~/.ssh-local/config` (the `dssh`/`dscp` aliases wrap that).
|
||||
#
|
||||
# CONTROLS (env)
|
||||
# DEVBOX_LAN_ACCESS = auto (default) | jump | off
|
||||
# auto → set up the host jump only on VM-backed hosts. The writable
|
||||
# sidecar config (ControlPath redirect + Include) is always
|
||||
# rendered, on every OS.
|
||||
# jump → always set up (e.g. native Linux with extra_hosts host-gateway).
|
||||
# off → do nothing.
|
||||
# HOST_SSH_USER — the username to SSH into the host as. REQUIRED for the
|
||||
# jump to authenticate. If unset we still generate the config but print
|
||||
# a hint with the public key to authorize on the host.
|
||||
# DEVBOX_HOST_ALIAS — host hostname to reach (default host.docker.internal).
|
||||
# DEVBOX_LAN_AUTOJUMP_PRIVATE = 0 (default) | 1
|
||||
# 1 → also emit a catch-all that ProxyJumps *any* RFC1918 (private) IP
|
||||
# through the host. Lets bare `dssh user@<private-IP>` work on whatever
|
||||
# LAN the (roaming) host is currently joined to, without naming peers.
|
||||
# Matches by the address you TYPE, not the resolved HostName, so it never
|
||||
# overrides named hosts that already carry their own ProxyJump.
|
||||
#
|
||||
# HOST-OWNED PEER POLICY (portable; keeps this image generic)
|
||||
# Named LAN peers are facts about a *specific* host's network, not about the
|
||||
# image — a roaming laptop sees different LANs. So we never bake peer names
|
||||
# here. Instead, if the host bind-mounts ~/.config/devbox-shell/ssh-lan.conf
|
||||
# (the same devbox-shell bridge dir used for shared aliases), we Include it
|
||||
# *before* ~/.ssh/config. That file holds the host's own jump overrides, e.g.
|
||||
# Host pve pve-2 pbs-vm
|
||||
# ProxyJump host
|
||||
# First-value-wins means ProxyJump is taken from there while HostName/User/
|
||||
# IdentityFile are inherited from the matching block in ~/.ssh/config.
|
||||
#
|
||||
# SCOPING NOTE (important)
|
||||
# `Include` is scoped to the enclosing Host/Match block. So every Include
|
||||
# below is preceded by a bare `Host *` to reset the active context to
|
||||
# match-all — otherwise the included config would only apply when targeting
|
||||
# `host`/`mac` and named peers like `pve` would silently fall back to ssh
|
||||
# defaults.
|
||||
#
|
||||
# Idempotent: re-renders the config every run (cheap); never regenerates the
|
||||
# key. Always non-fatal — never blocks container startup.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
MODE="${DEVBOX_LAN_ACCESS:-auto}"
|
||||
[ "$MODE" = "off" ] && exit 0
|
||||
|
||||
HOST_ALIAS_HOSTNAME="${DEVBOX_HOST_ALIAS:-host.docker.internal}"
|
||||
SSH_LOCAL="${HOME}/.ssh-local"
|
||||
CONFIG="${SSH_LOCAL}/config"
|
||||
KEY="${SSH_LOCAL}/devbox_jump_ed25519"
|
||||
|
||||
# ── Detection: is this a VM-backed host (macOS / Docker Desktop)? ──────
|
||||
# host.docker.internal resolves on OrbStack and Docker Desktop (mac/win) but
|
||||
# NOT on native Linux Docker (unless the user added extra_hosts: host-gateway,
|
||||
# in which case the jump is still harmless / usable, and they can force it
|
||||
# with DEVBOX_LAN_ACCESS=jump).
|
||||
is_vm_backed() {
|
||||
getent hosts "$HOST_ALIAS_HOSTNAME" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
# ── Writable socket dir + sidecar (ALWAYS, every host OS) ─────────────
|
||||
# The ControlPath redirect in the generated config needs a writable directory
|
||||
# regardless of host OS or jump mode. ~/.ssh is typically read-only, so the
|
||||
# master socket lives under the writable ~/.ssh-local. We create it and render
|
||||
# the config UNCONDITIONALLY so the redirect (and `Include ~/.ssh/config`) works
|
||||
# even on native Linux — where we set up no host jump but a read-only ~/.ssh
|
||||
# would otherwise still break ControlMaster sockets.
|
||||
mkdir -p "${SSH_LOCAL}/cm" 2>/dev/null || true
|
||||
chmod 700 "${SSH_LOCAL}" "${SSH_LOCAL}/cm" 2>/dev/null || true
|
||||
|
||||
# ── Decide whether to set up the host jump ────────────────────────────
|
||||
# Jump = reach the container host (host.docker.internal) as an SSH ProxyJump
|
||||
# onward to the host's LAN peers. Needed on VM-backed hosts (macOS / Docker
|
||||
# Desktop) or when forced with DEVBOX_LAN_ACCESS=jump. On native Linux LAN
|
||||
# peers are reachable directly, so NEED_JUMP=0 and we emit no jump block — but
|
||||
# we still render the config for the ControlPath redirect + Include.
|
||||
NEED_JUMP=0
|
||||
if [ "$MODE" = "jump" ] || { [ "$MODE" = "auto" ] && is_vm_backed; }; then
|
||||
NEED_JUMP=1
|
||||
fi
|
||||
|
||||
# ── Jump key (only when a jump is needed; generated once, preserved) ──
|
||||
# Persisted via a named volume on ~/.ssh-local (see compose), so a fresh key
|
||||
# is generated only on the very first start (or if the volume is wiped). When
|
||||
# we DO generate one it must be (re-)authorized on the host, so we flag it and
|
||||
# print a copy-paste authorize line below.
|
||||
KEY_JUST_GENERATED=0
|
||||
if [ "$NEED_JUMP" = "1" ] && command -v ssh-keygen >/dev/null 2>&1 && [ ! -f "$KEY" ]; then
|
||||
if ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1; then
|
||||
chmod 600 "$KEY" 2>/dev/null || true
|
||||
KEY_JUST_GENERATED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Render the writable config ────────────────────────────────────────
|
||||
# Jump-specific blocks (the host alias, host-owned peer overrides, and the
|
||||
# optional RFC1918 catch-all) only make sense when a jump is set up; on native
|
||||
# Linux they are all empty and only the ControlPath redirect + Include remain.
|
||||
JUMP_BLOCK=""
|
||||
LAN_CONF_BLOCK=""
|
||||
AUTOJUMP_BLOCK=""
|
||||
if [ "$NEED_JUMP" = "1" ]; then
|
||||
USER_LINE=""
|
||||
if [ -n "${HOST_SSH_USER:-}" ]; then
|
||||
USER_LINE=" User ${HOST_SSH_USER}"
|
||||
fi
|
||||
JUMP_BLOCK=$(cat <<EOF
|
||||
|
||||
# The container host (OrbStack / Docker Desktop). 'host' and 'mac' are aliases.
|
||||
Host host mac
|
||||
HostName ${HOST_ALIAS_HOSTNAME}
|
||||
${USER_LINE}
|
||||
IdentityFile ~/.ssh-local/devbox_jump_ed25519
|
||||
IdentitiesOnly yes
|
||||
ControlMaster auto
|
||||
ControlPath ~/.ssh-local/cm/%r@%h:%p
|
||||
ControlPersist 4h
|
||||
ServerAliveInterval 30
|
||||
EOF
|
||||
)
|
||||
|
||||
# Optional host-owned named-peer jump overrides (portable: lives on the host,
|
||||
# not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins.
|
||||
SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf"
|
||||
if [ -r "$SSH_LAN_CONF" ]; then
|
||||
LAN_CONF_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# Host-owned named-peer jump overrides (bind-mounted; edit on the host).
|
||||
# Scope reset to match-all so the Include applies to every target host.
|
||||
Host *
|
||||
Include ~/.config/devbox-shell/ssh-lan.conf
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
|
||||
# Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the
|
||||
# host. Matches the typed address, never the resolved HostName, so named hosts
|
||||
# with their own ProxyJump are unaffected. Network-agnostic → roaming-safe.
|
||||
if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then
|
||||
AUTOJUMP_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# RFC1918 auto-jump (DEVBOX_LAN_AUTOJUMP_PRIVATE=1): reach any private IP on
|
||||
# the host's CURRENT LAN via bare `dssh user@<ip>`. Public IPs are unmatched
|
||||
# and go direct via the container's NAT egress. NOTE: also matches the
|
||||
# container's own bridge subnet and any private IP the host can't actually
|
||||
# reach — for non-LAN private hosts behind a different jump, use their named
|
||||
# entry (which matches first by name and keeps its own ProxyJump).
|
||||
Host 10.* 192.168.* 172.16.* 172.17.* 172.18.* 172.19.* 172.20.* 172.21.* 172.22.* 172.23.* 172.24.* 172.25.* 172.26.* 172.27.* 172.28.* 172.29.* 172.30.* 172.31.*
|
||||
ProxyJump host
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
fi
|
||||
|
||||
INCLUDE_BLOCK=""
|
||||
if [ -r "${HOME}/.ssh/config" ]; then
|
||||
INCLUDE_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# Your own target hosts. Scope reset to match-all so this Include applies to
|
||||
# every target (an Include is otherwise scoped to the enclosing Host block).
|
||||
# To make a LAN peer jump via the host, add 'ProxyJump host' to its entry in
|
||||
# the host-owned ~/.config/devbox-shell/ssh-lan.conf (Included above) — NOT
|
||||
# here in ~/.ssh/config, which is typically bind-mounted read-only.
|
||||
Host *
|
||||
Include ~/.ssh/config
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
|
||||
# ── Multiplexing default, deliberately LAST ───────────────────────────
|
||||
# Why this block exists: ControlPath above is forced, but ControlMaster is not
|
||||
# set anywhere for targets that come from the user's own ~/.ssh/config. A target
|
||||
# whose entry omits ControlMaster therefore opens a NEW TCP connection per ssh
|
||||
# call, and an agent doing a dozen calls in a few minutes can trip fail2ban or a
|
||||
# CGNAT flow-table cap on the far end — observed 2026-08-25: ~12 connections in
|
||||
# 15 min and port 22 stopped answering while HTTPS to the same estate stayed fine.
|
||||
#
|
||||
# WHY IT IS AT THE BOTTOM, and ControlPath is at the top. ssh_config is
|
||||
# first-value-wins, so position encodes intent:
|
||||
# * BEFORE the Include = an OVERRIDE. Correct for ControlPath, whose value in
|
||||
# the user's config points at read-only ~/.ssh and simply cannot work here.
|
||||
# * AFTER the Include = a DEFAULT. Correct for ControlMaster, because an
|
||||
# explicit per-host 'ControlMaster no' (or 'auto', or any value) in the
|
||||
# user's own config must keep winning. We are supplying an opinion only
|
||||
# where the user expressed none.
|
||||
# That asymmetry is the whole design: force what is broken, default what is
|
||||
# merely absent. It also means this needs no audit of anyone's ~/.ssh/config —
|
||||
# which matters because that file is per-machine, differs across the fleet, and
|
||||
# future machines' versions do not exist yet to be audited.
|
||||
#
|
||||
# Caveat worth knowing (and documented in the pi-devbox-environment skill): a
|
||||
# stale master socket — file present, daemon gone, e.g. after the host suspends
|
||||
# or changes network — makes every later ssh to that host hang. Recovery is
|
||||
# 'ssh -F ~/.ssh-local/config -O exit <host>'. ControlPersist is deliberately
|
||||
# short (10m idle, and each new session resets the idle timer) so an abandoned
|
||||
# socket ages out on its own rather than lingering for hours.
|
||||
MULTIPLEX_DEFAULT_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# Multiplexing DEFAULT — intentionally after the Include above, so any explicit
|
||||
# per-host ControlMaster in your own ~/.ssh/config still wins (first-value-wins).
|
||||
# Applies only to targets that never mentioned ControlMaster at all.
|
||||
# Stale socket after a suspend/network change? ssh -O exit <host>.
|
||||
Host *
|
||||
ControlMaster auto
|
||||
ControlPersist 10m
|
||||
EOF
|
||||
)
|
||||
|
||||
cat > "$CONFIG" <<EOF
|
||||
# AUTO-GENERATED by setup-lan-access.sh on every container start. Do not edit
|
||||
# by hand — edits are overwritten. Used via: ssh -F ~/.ssh-local/config <host>
|
||||
# (or the dssh / dscp aliases). See the script header for the full rationale.
|
||||
|
||||
# ~/.ssh is typically mounted read-only, so keep our own known_hosts here.
|
||||
# Also redirect ControlPath into the writable sidecar: the bind-mounted
|
||||
# ~/.ssh/config commonly sets 'ControlPath ~/.ssh/cm/...' for CGNAT multiplexing,
|
||||
# but ~/.ssh is read-only here so the master socket can't be created and those
|
||||
# hosts fail to connect. First-value-wins: setting it here (before the Include)
|
||||
# overrides the read-only path for every host. Harmless when ControlMaster is off.
|
||||
Host *
|
||||
UserKnownHostsFile ~/.ssh-local/known_hosts
|
||||
StrictHostKeyChecking accept-new
|
||||
ControlPath ~/.ssh-local/cm/%r@%h:%p
|
||||
${JUMP_BLOCK}
|
||||
${LAN_CONF_BLOCK}
|
||||
${AUTOJUMP_BLOCK}
|
||||
${INCLUDE_BLOCK}
|
||||
${MULTIPLEX_DEFAULT_BLOCK}
|
||||
EOF
|
||||
chmod 600 "$CONFIG" 2>/dev/null || true
|
||||
|
||||
# ── Authorize hints ───────────────────────────────────────────────────
|
||||
# Print the copy-paste authorize line whenever we either (a) can't yet
|
||||
# authenticate (HOST_SSH_USER unset) or (b) just generated a NEW key that the
|
||||
# host won't recognize. With ~/.ssh-local persisted via a named volume, case
|
||||
# (b) fires only on first-ever start (or after the volume is reset) — so this
|
||||
# is normally a one-time, one-line step per machine, with no file to locate.
|
||||
if [ "$NEED_JUMP" = "1" ]; then
|
||||
PUBKEY_TEXT="$(cat "${KEY}.pub" 2>/dev/null)"
|
||||
if [ -z "${HOST_SSH_USER:-}" ]; then
|
||||
cat <<EOF
|
||||
[devbox] LAN-access jump config generated at ~/.ssh-local/config, but
|
||||
HOST_SSH_USER is unset so it can't authenticate to the host yet.
|
||||
To enable container -> host -> LAN-peer access:
|
||||
1. Set HOST_SSH_USER=<your host username> in the container env.
|
||||
2. Authorize this key on the host (run ON THE HOST, once):
|
||||
echo '${PUBKEY_TEXT}' >> ~/.ssh/authorized_keys
|
||||
3. Ensure the host's SSH server (Remote Login) is enabled.
|
||||
Then: dssh host (or add 'ProxyJump host' to targets in ~/.ssh/config)
|
||||
EOF
|
||||
elif [ "$KEY_JUST_GENERATED" = "1" ]; then
|
||||
cat <<EOF
|
||||
[devbox] Generated a NEW LAN-jump key. Authorize it on the host (${HOST_SSH_USER}@host),
|
||||
then 'dssh host' and your LAN peers will work. Run this ONCE, ON THE HOST:
|
||||
echo '${PUBKEY_TEXT}' >> ~/.ssh/authorized_keys
|
||||
(Ensure the host's SSH server / Remote Login is enabled.)
|
||||
This key is persisted in the ~/.ssh-local volume, so you won't need to
|
||||
repeat this on container updates — only if that volume is reset.
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
@@ -0,0 +1,110 @@
|
||||
<!-- pi-devbox:managed-block — appended to the global AGENTS.md at image build
|
||||
time (Dockerfile.variant), after pi-toolkit is cloned. Keep this short:
|
||||
it is a pointer, the depth lives in the skill. -->
|
||||
|
||||
## Running inside pi-devbox
|
||||
|
||||
If the directory `/usr/local/lib/pi-devbox/` exists (or your shell prompt is
|
||||
prefixed `[devbox]`, or `~/.ssh-local/config` is present), you are in a
|
||||
**pi-devbox container** — a Docker environment whose persistence, networking,
|
||||
DNS, host/LAN reachability, tmux, and Python/REPL behaviour differ from a normal
|
||||
workstation. Before any task that touches **reaching the host or its LAN, SSH,
|
||||
DNS/name resolution, what survives container recreate, running Python/REPLs,
|
||||
tmux, or pi-studio**, read `~/.agents/skills/pi-devbox-environment/SKILL.md`.
|
||||
|
||||
Key reflex from that skill: **the deployment specifics are not universal** — the
|
||||
host OS, hostnames, internal domains, and nameservers vary per instance and must
|
||||
be discovered at runtime, never assumed. And interactive shell aliases
|
||||
(`dssh`, `dscp`, `cat`→`bat`) do **not** exist in your non-interactive bash
|
||||
tool, so spell out the underlying command (e.g.
|
||||
`ssh -F "$HOME/.ssh-local/config" mac …`).
|
||||
|
||||
## Browser automation is available (agent-browser)
|
||||
|
||||
This image bakes the **`agent-browser`** CLI plus a headless Chromium, so you can
|
||||
drive a real browser — open pages, click/fill/`eval`, snapshot the DOM, take
|
||||
screenshots — to **verify** front-end work (live DOM, WebGL, layout, popup
|
||||
positioning) instead of guessing. Reach for it whenever a task involves a web UI
|
||||
or checking how a page actually renders. `AGENT_BROWSER_EXECUTABLE_PATH` is
|
||||
preset to the baked browser, so `agent-browser open <url>` works out of the box
|
||||
(headless). Run `agent-browser skills get core --full` for the command set and
|
||||
workflow patterns (always version-matched to the CLI); the `agent-browser` skill
|
||||
under `~/.agents/skills/` mirrors it when the skillset is mounted.
|
||||
|
||||
## Session start: load the mempalace skill
|
||||
|
||||
If MemPalace MCP tools (e.g. `mempalace_search`, `mempalace_diary_write`) are in
|
||||
your tool list, **read `~/.agents/skills/mempalace/SKILL.md` before doing
|
||||
non-trivial work** and follow its protocol: search the palace before answering
|
||||
about past work, and write a diary entry before the session ends. This is
|
||||
especially load-bearing here — a pi-devbox container is frequently recreated, so
|
||||
the palace is your only memory across recreates. Without the habit it is just
|
||||
storage, not memory. (The skill is the consumer side; feeding the palace is the
|
||||
separate `opencode-mempalace-bridge` skill, if present.)
|
||||
|
||||
### If the palace is central, it is shared — three rules
|
||||
|
||||
If `MEMPALACE_REMOTE_URL` is set, the MCP tools write to a **central palace
|
||||
shared with other machines**, not to a local one. Your drawers are not the only
|
||||
ones in there, and most drawers' `source_file` paths do not exist on this host.
|
||||
The skill covers the orientation side (provenance, chronology, whose diary is
|
||||
whose); these three are here instead because getting them wrong does *damage*
|
||||
rather than merely confusing you:
|
||||
|
||||
- **Never run `mempalace sync` / `mempalace_sync` against a shared palace.** It
|
||||
prunes drawers whose source files look gitignored, deleted, or moved — and on
|
||||
a shared palace that describes most of the content, including every other
|
||||
machine's. Compounding it (RFC-001 §7.2): feeders now stage *inside* the
|
||||
palace root, so a scoped sync can delete the very drawers it just filed.
|
||||
`mempalace_delete_by_source` is exact-match rather than existence-based, but
|
||||
its blast radius is now the whole fleet's palace — leave it on its default
|
||||
`dry_run=true` and confirm the match count before committing.
|
||||
- **A timeout is not a failure.** The palace is single-writer, and one large
|
||||
mine can block every client for minutes, so a write or mine that exceeds the
|
||||
client's deadline has usually *completed* server-side. Verify with
|
||||
`mempalace_get_drawer` or `mempalace_search` before retrying — a blind retry
|
||||
files a duplicate. `[mempalace ext] feed (tick) failed: mine timed out after
|
||||
30000ms` is the common benign instance: the transcript is already in the
|
||||
server's inbox and the mine is idempotent, so nothing is lost either way.
|
||||
- **The `mempalace` CLI is not remote-aware.** It always opens a palace on
|
||||
local disk, so `mempalace search` can return older and different results than
|
||||
the MCP tools while both look correct. Use the MCP tools for the central
|
||||
palace; the CLI only for a local one.
|
||||
|
||||
## Before you file a finding: second measurement, different route
|
||||
|
||||
This is here rather than in a skill because it has to fire *without* a matching
|
||||
task description, and because the version of it that lived only in a skill was
|
||||
violated five times in one session by an agent that had the skill available.
|
||||
|
||||
**Any claim you are about to record as fact — in a drawer, a diary entry, a
|
||||
coordination event, or a report to the user — needs a second measurement taken
|
||||
by a different route.** Not a re-read of your reasoning: re-reading has caught
|
||||
zero of these. A disagreeing measurement has caught all of them.
|
||||
|
||||
The two shapes that get filed as fact and are not:
|
||||
|
||||
- **A negative result** (`401`, connection refused, zero rows, "not found") is
|
||||
first a claim about *your filter*, not about the world. Wrong host, wrong port,
|
||||
wrong table, capped output.
|
||||
- **A positive result** proves only what your command *actually asked*. An SSH
|
||||
handshake can succeed against the wrong host (`ssh -G` tells you which rule
|
||||
captured the name); a `401` can be a real answer from an issuer that never
|
||||
minted the credential.
|
||||
|
||||
Cheapest habit that works: **write the expected result next to each check before
|
||||
running it**, then diff. Expectations declared up front turn a silent wrong
|
||||
assumption into a visible mismatch. And if you cannot think of a second route to
|
||||
the same fact, you do not have a finding — you have a hypothesis, so label it as
|
||||
one.
|
||||
|
||||
## Handling an exposed credential
|
||||
|
||||
If a task touches a leaked secret, a token rotation, "is this credential still
|
||||
live?", whether to delete stored content, or which scopes a new token needs:
|
||||
**read `~/.agents/skills/credential-incident-response/SKILL.md` first.** One rule
|
||||
is load-bearing enough to state here: **probe the issuing provider before doing
|
||||
anything else** — most "exposed" credentials in a long-lived fleet are already
|
||||
dead, and the ones that are live are often far more privileged than assumed.
|
||||
Severity first, cleanup second, and prefer **revocation over deletion** for
|
||||
anything already replicated.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Vendored fallback skills
|
||||
|
||||
Most directories here are **image-baked skills** that `entrypoint-user.sh`
|
||||
symlinks into `~/.agents/skills/` on container start. They are the **fallback**
|
||||
layer: see *Runtime precedence* below for which copy actually wins when a
|
||||
`skillset` repo is mounted (through v1.8.4 the answer was "always the baked
|
||||
one", which was a bug).
|
||||
|
||||
| skill | owner | how it gets here |
|
||||
|-------|-------|------------------|
|
||||
| `pi-devbox-environment` | pi-devbox (this repo) | authored here; the canonical copy |
|
||||
| `credential-incident-response` | pi-devbox (this repo) | authored here; the canonical copy |
|
||||
| `pi-extensions` | the `pi-extensions` package repo (`skill/`) | **vendored fallback** + refreshed at build |
|
||||
| `mempalace` | the `skillset` repo | **vendored fallback** (snapshot only) |
|
||||
|
||||
## Why fallbacks exist
|
||||
|
||||
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 dangles in a container started **without** the
|
||||
private `skillset` repo mounted. Baking the skill closes that *availability*
|
||||
gap. `mempalace` is baked for the same reason (memory continuity); since
|
||||
nothing in pi-toolkit's `AGENTS.md` points to it, the pi-devbox managed block
|
||||
(`pi-global-AGENTS.append.md`) also adds the matching *proactive-load*
|
||||
directive ("load the mempalace skill at session start") so a new container
|
||||
actually picks it up rather than relying on description-matching.
|
||||
`pi-extensions`'s directive already ships in pi-toolkit's `AGENTS.md`, so only
|
||||
its skill file needed baking.
|
||||
|
||||
## Freshness model (layered — see Dockerfile.variant)
|
||||
|
||||
- **`pi-extensions`** — Option 1 + Option 2. The committed copy here is the
|
||||
*floor*; at build time `Dockerfile.variant` copies `/opt/pi-extensions/skill/`
|
||||
(the pinned, package-owned source) over it, so a normal build ships the fresh
|
||||
package copy and a stale-ref / mirror build still ships the snapshot. Keep
|
||||
`evaluate-extension-usage.py` alongside `SKILL.md` — the skill calls it via
|
||||
`./`.
|
||||
- **`mempalace`** — Option 2 only. The `mempalace` *consumer* skill lives only
|
||||
in the private `skillset` repo (the `mempalace-toolkit` repo ships a
|
||||
*different* skill, `opencode-mempalace-bridge`), so there is no public
|
||||
package source to copy from. This snapshot is refreshed manually per release.
|
||||
|
||||
**Refresh it with `scripts/vendor-mempalace-skill.sh <skillset-root>`, not
|
||||
`cp`.** Because the image cannot clone the private upstream, the snapshot used
|
||||
to be *anonymous* — nothing recorded which skillset commit the bytes came
|
||||
from, so the only staleness check possible was a hand-maintained phrase canary
|
||||
in `scripts/smoke-test.sh`, which by construction detects "older than the
|
||||
phrase I remembered to pin", never "older than skillset main". Two facts now
|
||||
travel with the file:
|
||||
|
||||
| Fact | Where | Kind |
|
||||
|---|---|---|
|
||||
| `ARG SKILLSET_SNAPSHOT_REF` in `Dockerfile.variant` | manifest `skillset_snapshot_ref` + OCI label `se.jordbo.pi-devbox.skillset-snapshot-ref` | a **claim** about which commit these bytes are |
|
||||
| `sha256sum` of this file, measured in the manifest layer | manifest `skillset_snapshot_sha256` | the bytes that **actually shipped** |
|
||||
|
||||
The script writes both together, refuses when the upstream file has
|
||||
uncommitted modifications (no commit describes those bytes), and
|
||||
`--check` verifies the claim against a real clone. Deliberately an `ARG`
|
||||
default rather than a CI-resolved value: no credential for a private repo, no
|
||||
change at any of the four `Dockerfile.variant` build call sites, and a local
|
||||
`docker build` records the same thing CI does.
|
||||
|
||||
Verifying "is this snapshot current?" is **not** a CI job and was deliberately
|
||||
not made one — see the Unreleased CHANGELOG entry for why (private repo;
|
||||
another repo's branch must not be able to fail this build; and the artefact it
|
||||
would guard is read by no host on this fleet). The check belongs where the
|
||||
skillset actually is: `vendor-mempalace-skill.sh --check` for a maintainer,
|
||||
and `pi-devbox-version`'s `skills:` section for an agent inside a container.
|
||||
|
||||
## Runtime precedence (v1.8.5+)
|
||||
|
||||
The baked links are created **early** in `entrypoint-user.sh` (before pi-deploy,
|
||||
to close a smoke readiness race) with a create-only-when-absent guard, and the
|
||||
skillset deploy runs **last** and treats them as foreign links. Through v1.8.4
|
||||
that combination meant the baked snapshot always won: an edit pushed to
|
||||
`skillset/skills/mempalace/SKILL.md` was invisible in every container until the
|
||||
next image build (measured on two hosts — live `md5 129bcc4752` vs baked
|
||||
`5236024fef`, new section absent). Editing those skills *appeared* to work.
|
||||
|
||||
`devbox-skill-reconcile` now runs immediately after the skillset deploy and
|
||||
repoints the links for skills the **skillset owns**, listed one per line in
|
||||
`skillset-owned.txt`. Precedence, highest first:
|
||||
|
||||
1. **user override** — a real directory, or a symlink pointing outside the baked
|
||||
tree; never touched by anything
|
||||
2. **live skillset clone** — but only for names in `skillset-owned.txt`
|
||||
3. **baked snapshot** — everything else, and every skill when no skillset is
|
||||
mounted
|
||||
|
||||
**Which one won is now reportable from inside the container:**
|
||||
`pi-devbox-version` prints a `skills:` section naming, per vendored skill,
|
||||
`baked` or `live <repo> @ <sha>` — and for `mempalace` whether that live copy is
|
||||
identical to the baked fingerprint, at the same commit but with uncommitted
|
||||
edits, or genuinely divergent. Before that, a stale baked snapshot and a current
|
||||
live clone were indistinguishable from inside, which is how the freshness of
|
||||
this file went unexamined for three releases. The section is suppressed with
|
||||
`--no-skills` on the container-start banner, because `entrypoint-user.sh` prints
|
||||
the version *before* the links exist and long before the reconcile below runs.
|
||||
|
||||
On this fleet, precedence 2 wins for `mempalace` on **every** host — all four
|
||||
compose stacks mount a workspace containing the skillset — so the baked copy is
|
||||
exercised only by CI and by a hypothetical no-mount container. Worth
|
||||
remembering before spending effort on its freshness.
|
||||
|
||||
Ownership is per-skill on purpose: `pi-extensions`' authoritative source is the
|
||||
package repo (copied over the snapshot at build), and `skillset` carries a
|
||||
downstream copy that can lag, so handing it to the clone would *regress* the
|
||||
skill. Only `mempalace` is skillset-owned today.
|
||||
|
||||
Verify with `readlink -f ~/.agents/skills/<skill>` — not by reading the
|
||||
entrypoint. Smoke covers both directions (baked resolution with no skillset
|
||||
mounted, plus a fabricated-skillset run of the reconciler).
|
||||
|
||||
## Refreshing the snapshots
|
||||
|
||||
cp <pi-extensions-pkg>/skill/SKILL.md pi-extensions/SKILL.md
|
||||
cp <pi-extensions-pkg>/skill/evaluate-extension-usage.py pi-extensions/
|
||||
|
||||
Copy `pi-extensions` **from its owner in the table above** — the package
|
||||
repo's `skill/` (since `a7f3044` co-located it there; `skillset` also carries a
|
||||
copy, but it is a downstream duplicate and can lag). Copying `pi-extensions`
|
||||
from `skillset` would regress the snapshot to whatever that repo last mirrored.
|
||||
|
||||
`mempalace` is **not** refreshed by `cp` — see the *Freshness model* section
|
||||
above: `scripts/vendor-mempalace-skill.sh <skillset-root>` is the only thing
|
||||
that should ever touch that snapshot, because a bare copy can update the bytes
|
||||
without updating the ref that claims to describe them, which produces a
|
||||
manifest that confidently lies.
|
||||
|
||||
Neither vendored skill has a hand-maintained "last refreshed at" line here on
|
||||
purpose — one previously existed (skillset `670f7f1`, pi-extensions pkg
|
||||
`e73cb9f`) and went stale within hours, because nothing forced it to move
|
||||
when the ARGs did. `670f7f1` is now a cautionary example rather than a fact
|
||||
worth recording: it is the commit that told agents to hand-stamp `added_by`,
|
||||
which a later skillset commit (and the pi-devbox edge stamper) withdrew — so a
|
||||
reader trusting that line would have been pointed at superseded guidance.
|
||||
Both facts it tried to capture now live somewhere that cannot drift by hand:
|
||||
|
||||
| Fact | Where |
|
||||
|---|---|
|
||||
| which skillset commit `mempalace`'s bytes came from | `ARG SKILLSET_SNAPSHOT_REF` (Dockerfile.variant) + `skillset_snapshot_ref` in `build-manifest.json`, written *only* by `vendor-mempalace-skill.sh` |
|
||||
| which pi-extensions package commit was vendored | `ARG PI_EXTENSIONS_REF` (Dockerfile.variant, CI-resolved to a 40-hex commit) → OCI label `se.jordbo.pi-devbox.pi-extensions-ref` and `build-manifest.json`'s `components.pi-extensions`, both read from the actual `/opt/pi-extensions` checkout, not from intent |
|
||||
|
||||
When you refresh the `mempalace` snapshot, also update the phrase asserted by
|
||||
the "mempalace skill snapshot is current" smoke test — it deliberately pins the
|
||||
**newest** section, because the previous canary grepped a phrase that survived
|
||||
the very edit that made the snapshot stale, and so passed on stale content.
|
||||
@@ -0,0 +1,271 @@
|
||||
---
|
||||
name: credential-incident-response
|
||||
description: >-
|
||||
Respond correctly when a live credential is found where it should not be —
|
||||
in a chat transcript, a MemPalace drawer, a log, a git-tracked config, or an
|
||||
agent-authored note. Load this whenever a task involves a leaked/exposed
|
||||
secret, a token rotation, a "is this credential still live?" question, deciding
|
||||
whether to delete or scrub stored content, proving a corpus is clean, or
|
||||
choosing scopes for a new API token. Covers the mandatory order of operations
|
||||
(probe the issuer FIRST — severity before cleanliness), leak-free identity via
|
||||
sha256[:8] fingerprints and when publishing one is safe,
|
||||
why revocation beats deletion for anything already replicated, scopes derived
|
||||
from measured consumers, the three places a secret hides in a Chroma palace, how to prove ABSENCE rather than assume it (instrument strength,
|
||||
census vs class passes, the tokenisation trap where quoting decides detectability, why git filters never run on symlinks, self-tests that abort),
|
||||
where this fleet's secrets live, and what rotation does NOT fix.
|
||||
---
|
||||
|
||||
# Credential incident response
|
||||
|
||||
A leaked credential is a **severity** question before it is a cleanliness
|
||||
question. Two days of scrubbing, redaction plumbing and deletion planning were
|
||||
once spent on a set of 13 credentials of which **11 were already dead at the
|
||||
provider** — a fact that cost five HTTP requests to establish and was never
|
||||
checked. Meanwhile the two live ones turned out to be instance-owner **admin**
|
||||
tokens, which nobody had looked at either.
|
||||
|
||||
## 1. Order of operations — do not reorder this
|
||||
|
||||
1. **Is it still accepted?** Probe the issuing provider. Dead credential →
|
||||
hygiene item, stop panicking. Live → incident, continue.
|
||||
2. **What can it do?** Read the identity back. `is_admin`, `id=1`, scopes,
|
||||
which account. A read-only repo token and an instance-owner admin token are
|
||||
not the same finding.
|
||||
3. **What consumes it?** Grep for real consumers before assuming breakage.
|
||||
4. **Where does it live?** Enumerate copies (store, palace, transcripts, git).
|
||||
5. **Then** rotate/revoke, and only then consider cleanup.
|
||||
|
||||
Doing 4→3→1 in reverse produces confident, wrong severity calls and wasted
|
||||
cleanup. If you only have time for one step, do step 1.
|
||||
|
||||
## 2. Leak-free identity: fingerprint, never the value
|
||||
|
||||
Publishing an 8-hex fingerprint lets you compare a credential across machines,
|
||||
files, drawers and peers without ever materialising the secret. Same formula as
|
||||
`mempalace_redact.py`:
|
||||
|
||||
```sh
|
||||
printf '%s' "$SECRET" | sha256sum | cut -c1-8 # printf, NOT echo (no newline)
|
||||
printf '%s' 'test' | sha256sum | cut -c1-8 # self-test -> 9f86d081
|
||||
```
|
||||
|
||||
Report as `(variable, fp, length)`. Equal fingerprints across hosts prove a
|
||||
shared credential; that is usually the important part. **Never** paste a live
|
||||
value into a search query, a palace drawer, an event body, or a chat message —
|
||||
in an agent context your own tool output is itself captured and re-filed.
|
||||
|
||||
**Precondition — only fingerprint what an adversary cannot enumerate.** An 8-hex
|
||||
fingerprint is 32 bits over its *input space*, so publishing `fp8(x)` hands
|
||||
anyone a **membership oracle**: they can test `x == v` for every candidate `v`
|
||||
they can generate. For a 40-char random token that space is unreachable. For a
|
||||
hostname, username, e-mail, port, path, commit SHA or weak password it is a
|
||||
wordlist. **If you can imagine writing the wordlist, you cannot publish the
|
||||
fingerprint** — reference those by name and location instead. "High entropy" is
|
||||
the usual *sufficient condition*, not the test: a commit SHA is 160-bit and still
|
||||
fully enumerable from the repo. `sha256("")` = `e3b0c442` is the degenerate case,
|
||||
recognisable on sight precisely because its input space has one member.
|
||||
|
||||
**Candidate fingerprints are working memory, never output.** A scanner that hashes
|
||||
every token in a file also hashes hostnames, paths and e-mails. Print only
|
||||
fingerprints that *matched* a known entry — the tempting debug step when a scan
|
||||
returns zero ("print what it saw") publishes low-entropy fingerprints wholesale.
|
||||
|
||||
And say plainly what a fingerprint register *is*, so nobody rediscovers it later
|
||||
as an alarm: even for an unguessable secret, a published fingerprint is a
|
||||
**confirmation oracle** for anyone who already holds a candidate corpus. That is
|
||||
exactly how a long-retired token gets identified in old transcripts — and it works
|
||||
identically for someone else holding those same files. Net positive, since they
|
||||
would already hold the value; state it rather than leaving it implicit.
|
||||
|
||||
## 3. Liveness probes, and the trap that scoping creates
|
||||
|
||||
```sh
|
||||
# Gitea
|
||||
curl -sS -m 10 -o /dev/null -w '%{http_code}\n' -H "Authorization: token $T" \
|
||||
"$GITEA_HOST/api/v1/repos/<owner>/<repo>/actions/runs?limit=1"
|
||||
# GitHub
|
||||
curl -sS -m 10 -o /dev/null -w '%{http_code}\n' -H "Authorization: token $T" \
|
||||
https://api.github.com/user
|
||||
```
|
||||
|
||||
- `200` live · `401` revoked/invalid · **`403` = wrong question, not a dead token**
|
||||
- **Probe the issuer that minted it.** A 401 from an unrelated instance says
|
||||
nothing. Resolve the host from config (`GITEA_EGL_HOST` etc.), do not assume.
|
||||
- **Under scoped tokens, `/api/v1/user` returns 403 for a perfectly live token**
|
||||
unless `user` scope was granted. So it cannot distinguish *revoked* from
|
||||
*merely scoped*. Use a **repository route the token is authorised for**.
|
||||
- Verify **both directions** after a rotation: old → 401, new → 200. The second
|
||||
check is what catches "deleted the wrong token".
|
||||
- Port/scheme come from config, not habit: one instance here is
|
||||
`http://gitea.egl.lan:3000` — plain HTTP, with 443 refused.
|
||||
|
||||
## 4. Revocation beats deletion — the load-bearing rule
|
||||
|
||||
Once revoked, stored copies are **inert**; you may leave them. Deleting them is
|
||||
best-effort over an *unbounded* copy set: FTS shadow rows, feed inbox `.jsonl`
|
||||
files on every host, sqlite free pages after the delete, mesh replicas that
|
||||
already synced, and backups. **Revocation invalidates every copy everywhere at
|
||||
once, including copies nobody enumerated.**
|
||||
|
||||
So: **rotate + revoke first.** Treat drawer deletion as optional hygiene, never
|
||||
as the remedy. Then record the retired fingerprints as *known-dead* so the next
|
||||
census recognises them instead of reopening the investigation.
|
||||
|
||||
Corollary: never reach for `mempalace_sync` or a bulk `delete_by_source` on a
|
||||
shared palace as incident response. High blast radius, low actual benefit.
|
||||
|
||||
## 5. Finding a secret in a Chroma palace — three targets, in this order
|
||||
|
||||
1. `embedding_fulltext_search_content.c0` — document text
|
||||
2. `embedding_metadata.string_value` — metadata fields, **and a second copy of
|
||||
the document text** under key `chroma:document`
|
||||
3. raw byte scan of every `*.sqlite3` — backstop, covers FTS pages and free space
|
||||
|
||||
**Correction, measured on chroma 1.5.9 with a sentinel drawer:** one row in (1)
|
||||
AND one row in (2) for the same drawer, so **(2) is not structurally
|
||||
content-blind** — an earlier version of this section said it held "metadata
|
||||
fields only", and that was wrong. Scan (1) and (3) regardless: (1) is the direct
|
||||
target. But if a `string_value` query returns zero for a value you know is in a
|
||||
drawer, the cause is a key filter, a query shape or escaping — *not* structural
|
||||
absence, and the difference matters because the false explanation is what makes
|
||||
the zero feel safe. See §6: do not explain a zero with a mechanism you have not
|
||||
read from source.
|
||||
|
||||
Semantic search proves nothing about absence — it returns top-k. For
|
||||
completeness, enumerate by filing window (`list_drawers(since=T, before=T+1m)`),
|
||||
since one mine shares a minute.
|
||||
|
||||
Value-agnostic sweeps (uuid / 40-hex / `NAME=VALUE`) drown in false positives at
|
||||
fleet scale — 608 candidates, mostly session UUIDs and git SHAs. Name-anchoring
|
||||
plus entropy plus provenance, applied to **document text**, is what works.
|
||||
|
||||
## 6. Proving absence: instrument strength, and four ways a scan lies clean
|
||||
|
||||
Section 5's warning is about false *positives* — name-anchoring and provenance are
|
||||
what stop a triage sweep drowning in session UUIDs. **A gate is the opposite job.**
|
||||
Triage optimises precision; proving absence optimises recall. Every failure below
|
||||
reported a reassuring zero over a secret that was really there.
|
||||
|
||||
**Rank the instrument, and state which one produced your zero.**
|
||||
|
||||
| Instrument | Needs | Blind to |
|
||||
|---|---|---|
|
||||
| exact-byte value search | you hold the value | nothing — no tokeniser to fool |
|
||||
| class/structure pass | a header pattern | anything without a recognisable shape |
|
||||
| fingerprint census | a fingerprint list | any secret not listed; tokenisation |
|
||||
|
||||
A census is deliberately value-free, so it must *extract candidates and hash them*
|
||||
— which makes its sensitivity a property of the tokeniser, not of the corpus. If
|
||||
you hold the value, search the bytes instead, and search the value's JSON-escaped
|
||||
rendering too when the corpus is `.jsonl`.
|
||||
|
||||
**1. Census and class answer different questions; neither substitutes.** A census
|
||||
answers *"has a KNOWN secret leaked?"*, a class pass *"is there secret-SHAPED
|
||||
material here?"* Both failure modes were measured on this fleet: a class-only
|
||||
pre-commit hook passed plaintext UUID API credentials to a shared repo twice,
|
||||
because a UUID carries no key header — while a census-only gate reported 0 hits
|
||||
with freshly-synced SSH private keys and an age identity in the tree, because no
|
||||
key is in the census. Run both passes.
|
||||
|
||||
**2. Tokenisation — quoting alone can decide detectability.** Maximal-run
|
||||
extraction swallows the value of an *unquoted* assignment:
|
||||
|
||||
```
|
||||
PROXMOX_SECRET=<uuid> # ONE run; the uuid is never hashed alone -> MISS
|
||||
export SECRET="<uuid>" # the quote ends the run; bare uuid hashed -> HIT
|
||||
```
|
||||
|
||||
Take the **union** of three strategies, because each fails in a different
|
||||
direction — (2) is the one that recovers the unquoted case:
|
||||
|
||||
~~~python
|
||||
runs = re.findall(r'[^\s"\'`]{12,}', text) # 1. maximal runs
|
||||
split = [p for r in runs for p in re.split(r'[=!,;:@|()\[\]{}<>]', r) if len(p) >= 12]
|
||||
shape = re.findall(UUID_RE, text) + re.findall(r'[0-9a-f]{32,64}', text)
|
||||
candidates = set(runs) | set(split) | set(shape)
|
||||
~~~
|
||||
|
||||
**3. Scan the index or the pushed tree, never the working tree.** The working tree
|
||||
is not what gets published. And for an rsync-published mirror a repo-only fix is
|
||||
not weaker, it is *temporary*: the next sync re-publishes the live disk. Fix the
|
||||
live file first, verify it clean **by fingerprint**, then sync. Read blobs with
|
||||
`git ls-tree -r <sha>` plus one `git cat-file --batch` (thousands of `git show`
|
||||
calls is the slow way).
|
||||
|
||||
**4. Git filters never run on symlinks — and `check-attr` will not tell you.** A
|
||||
symlink's blob is the *target path*, so `filter=git-crypt` can never encrypt it,
|
||||
yet `git check-attr filter` cheerfully answers `git-crypt` for that path. **A
|
||||
symlinked secret stays plaintext no matter what `.gitattributes` says.** Join the
|
||||
attribute against the **file mode** (`git ls-files -s`, mode `120000`) and verify
|
||||
the index blob really begins `\0GITCRYPT\0`. Report encrypted / symlinked /
|
||||
scanned as three separate numbers and assert they sum — encrypted and symlinked
|
||||
blobs are *skipped*, not certified clean.
|
||||
|
||||
**Self-test two-sided, and abort if it cannot discriminate.** Require a synthetic
|
||||
positive to fire AND a negative to stay silent before trusting any zero. Keep the
|
||||
fixtures in *structurally separate buffers*: put a quoted and an unquoted probe in
|
||||
one buffer and the quote terminates the run, handing the bare token to the weak
|
||||
extractor and making it look as strong as the union — a self-test artifact that
|
||||
has already fooled an agent here. And never gate on `$?` when the tool has a
|
||||
lock-skip or no-op path that also exits 0; judge the reported line.
|
||||
|
||||
**Row-gone is not bytes-gone.** Measured, same sentinel drawer: after
|
||||
`delete_by_source` the row count went 1 -> 0 in *both* the FTS content table and
|
||||
`embedding_metadata`, while the raw byte count stayed 4 -> 4 — sqlite does not
|
||||
zero freed pages, so the payload sits in free space until `VACUUM`. Deletion
|
||||
effectiveness is therefore *two* numbers, and each direction has a trap: one
|
||||
aggregate figure reported as "erased" has only measured "unretrievable", while a
|
||||
raw byte scan used as the acceptance gate reads a CORRECT, complete deletion as a
|
||||
failure. (Note how this was measured: the blocker was never a better instrument,
|
||||
it was the subject — file your own disposable sentinel and delete that, instead
|
||||
of testing deletion on real data.)
|
||||
|
||||
## 7. Choosing scopes: derive them from measured consumers
|
||||
|
||||
Before creating a replacement token, find out what actually uses it:
|
||||
|
||||
```sh
|
||||
git -C <repo> remote get-url origin # ssh:// ? then git needs NO token
|
||||
git config --global --list | grep -iE 'credential|insteadof' # and no helper?
|
||||
grep -rhoE 'api/v1/[A-Za-z0-9/{}$_.-]+' <consumers> | sort -u # exact routes
|
||||
grep -rhoE '\-X [A-Z]+' <consumers> # any writes?
|
||||
```
|
||||
|
||||
Real outcome here: git used SSH keys throughout, and the token's only consumer
|
||||
read three CI-run routes with `GET`. So `repository: Read` and nothing else
|
||||
replaced two admin tokens. **Scoping shrinks the blast radius of the next leak
|
||||
far more than any redaction pipeline does** — a read-only token in a transcript
|
||||
is a hygiene event, not an instance compromise.
|
||||
|
||||
Then prove the scope with an acceptance suite that declares expectations first:
|
||||
must-work routes → `200`; `/admin/*`, `/user`, `/user/repos` → `403`.
|
||||
|
||||
## 8. What rotation does *not* fix
|
||||
|
||||
- **A cleartext channel.** If the endpoint is `http://`, the *new* token is
|
||||
exposed identically from first use. Raise TLS separately.
|
||||
- **Git history.** A secret committed and pushed cannot be fixed by any store or
|
||||
palace operation — it needs rotation *and* history surgery.
|
||||
- **Agent-authored content.** Stage-write redactors see transcripts only, never
|
||||
`add_drawer` / `checkpoint` / `diary_write` output. Never type a secret into
|
||||
the palace yourself; nothing downstream will catch it.
|
||||
- **Plaintext/encrypted drift.** Gitignored plaintext `.env` files go stale while
|
||||
`.env.age` moves on, so old values linger on disk (and in backups) long after
|
||||
rotation. They are a common source of "mystery" fingerprints in a census.
|
||||
|
||||
## 9. This fleet's secret store (verify, do not assume)
|
||||
|
||||
- All `*.env.age` live in **one** repo: `joakimp/docker-compose-repo`. `myconfigs`
|
||||
has none.
|
||||
- Every `.age` file has **one X25519 recipient** — a single key tracked in
|
||||
`myconfigs` under git-crypt. Unlocking git-crypt therefore decrypts the entire
|
||||
fleet's secrets, including hosts you have no access to. The age layer adds no
|
||||
isolation beyond git-crypt.
|
||||
- Flow: `./fetch-secrets.sh <host>` (decrypt → `.env`) → edit → `./encrypt-secrets.sh <host>`
|
||||
→ commit → push → `docker compose up -d --force-recreate`.
|
||||
- **Always pass the host argument** to `encrypt-secrets.sh`. Bare, it walks the
|
||||
whole tree and re-encrypts every `.env` it finds, re-nonced, including stale
|
||||
ones — silently rolling back other hosts' secrets.
|
||||
- After any re-encrypt, check the header still shows exactly **one X25519
|
||||
recipient**; a hand-rolled `age -r` locks the rest of the fleet out, and the
|
||||
failure only appears on another machine, later.
|
||||
@@ -0,0 +1,705 @@
|
||||
---
|
||||
name: mempalace
|
||||
description: MemPalace agent memory protocol. Use on every session to maintain continuity across conversations — search before answering about past work, write diary entries before session ends, and mine new projects into the palace. Load this skill at session start.
|
||||
---
|
||||
|
||||
# MemPalace Agent Memory Protocol
|
||||
|
||||
## Overview
|
||||
|
||||
MemPalace gives you persistent memory across sessions via an MCP server. It stores project knowledge (mined from files), conversation summaries (diary entries), and entity relationships (knowledge graph). Without this protocol, you have tools but no habits — and memory without habits is just storage.
|
||||
|
||||
**Core principle:** Storage is not memory. Storage + protocol = memory.
|
||||
|
||||
## When to Load This Skill
|
||||
|
||||
- At the **start of every session** (proactively, before the user asks)
|
||||
- When the user mentions **past conversations, decisions, or work**
|
||||
- When working on a **new project or repository** for the first time
|
||||
- When the user asks about **people, projects, or relationships**
|
||||
|
||||
## Session Lifecycle
|
||||
|
||||
### Phase 1: Wake Up (session start)
|
||||
|
||||
Run these immediately when a session begins, before responding to the user:
|
||||
|
||||
1. **Load palace overview:**
|
||||
```
|
||||
mempalace_status
|
||||
```
|
||||
This returns wing/room counts, the AAAK spec, and the memory protocol reminder.
|
||||
|
||||
2. **Read your recent diary:**
|
||||
```
|
||||
mempalace_diary_read(agent_name="<your_agent_name>", last_n=5)
|
||||
```
|
||||
Scan for context about recent sessions — what was worked on, what matters, what's pending.
|
||||
|
||||
3. **Check the knowledge graph** for the user or active project if relevant:
|
||||
```
|
||||
mempalace_kg_query(entity="<project_or_person>")
|
||||
```
|
||||
|
||||
4. **Check your mailbox.** Just run it — an empty result is a fine answer and
|
||||
costs one call. Do not try to decide first whether coordination "applies to
|
||||
you"; that test is what used to be wrong here (see *Cross-Machine
|
||||
Coordination* below):
|
||||
```
|
||||
mempalace_event_list(to_agent="<harness>@<device>", status="open")
|
||||
```
|
||||
This is a candidate list, not a to-do list — `status` never changes after an
|
||||
event is written, so finished asks keep matching. Subtract the ones you have
|
||||
already answered using the rule in *What you actually owe*, below.
|
||||
Another machine may have asked you something, or corrected something you are
|
||||
about to rely on. This costs one call and is the only way you will find out:
|
||||
nothing pushes an event into your session unless your bridge delivers it for
|
||||
you, and if it does you will already have seen it before reading this.
|
||||
|
||||
Do NOT announce this to the user. Just do it silently to orient yourself.
|
||||
|
||||
### Temporal grounding — compute time deltas, don't guess
|
||||
|
||||
Diary entries and drawers carry real timestamps (`timestamp`, `created_at`).
|
||||
Before describing *when* something happened — "yesterday", "earlier today",
|
||||
"last week", "a while back" — **establish the current date/time first and
|
||||
compute the delta against the actual timestamp.** Get "now" from the injected
|
||||
session date or by running `date` in a shell; never infer it.
|
||||
|
||||
**A container recreate or a fresh session is NOT a day boundary.** A devbox
|
||||
container (pi-devbox or opencode-devbox) is frequently restarted — often several
|
||||
times within the *same* day — and each restart begins a new session with a fresh
|
||||
wake-up. Do not reason "new session ⇒ last session was yesterday": two diary
|
||||
entries 90 minutes apart can straddle a container recreate. The only
|
||||
authoritative clock is the timestamp on the memory, not the session/container
|
||||
boundary.
|
||||
|
||||
**Practical rule:** prefer explicit, checkable phrasing — e.g. "earlier today,
|
||||
~8h ago (both 2026-06-25)" — over a vague relative term. If you catch yourself
|
||||
about to write "yesterday" / "last week", subtract `now − entry.timestamp` and
|
||||
state the computed result. (Remember timestamps may be UTC while the wall clock
|
||||
is local — reconcile the offset before computing the delta.) Note too that
|
||||
session feeders can lag up to a week (see *Multi-harness palace*), so a recent
|
||||
absence in `wing_conversations` is not proof nothing happened.
|
||||
|
||||
### Phase 2: Active Session (during work)
|
||||
|
||||
#### Search Before You Speak
|
||||
|
||||
Before answering questions about past work, decisions, people, or projects:
|
||||
|
||||
```
|
||||
mempalace_search(query="<keywords>", wing="<project>")
|
||||
```
|
||||
|
||||
**Never guess about facts that might be in the palace.** Wrong is worse than slow. Say "let me check" and query.
|
||||
|
||||
#### Search Before You *Probe*
|
||||
|
||||
The rule above covers **questions**. This one covers **actions** — and it is the one
|
||||
that actually gets skipped, because mid-task the impulse is to go and *look* rather
|
||||
than to remember. The palace is a **fleet** record: another machine's agent has
|
||||
usually already paid the cost of discovering how this environment is wired, and its
|
||||
notes include the corrections that came afterwards, which a fresh probe cannot show
|
||||
you.
|
||||
|
||||
**Before you SSH somewhere to find out how it is set up, enumerate infrastructure,
|
||||
or derive a deployment — search.** Concrete triggers, all meaning *search first*:
|
||||
|
||||
- about to run `ssh <host> …`, `docker ps`, `systemctl list-units`, `ip addr` to
|
||||
discover how something is deployed or connected
|
||||
- about to establish topology: which hosts/runners/services exist, where they live,
|
||||
which of them can reach which
|
||||
- about to conclude "this isn't documented anywhere" or "there's no way to know"
|
||||
- about to assert an environment fact you learned **earlier in this same session**
|
||||
|
||||
**That last trigger is the sharp edge.** A compacted session summary is lossy by
|
||||
design, and a belief you formed 40 turns ago may already be *retracted* in the
|
||||
palace by another machine. Trusting your own context over the shared record is how a
|
||||
withdrawn claim gets re-published as fact.
|
||||
|
||||
Search broadly before narrowing — fleet knowledge often sits in another machine's
|
||||
wing, or inside a mined conversation, not where you would file it yourself:
|
||||
|
||||
```
|
||||
mempalace_search(query="<topic> <host> <mechanism>") # no wing filter first
|
||||
mempalace_search(query="…", wing="<likely-wing>") # then narrow
|
||||
```
|
||||
|
||||
Two or three searches cost seconds. Re-deriving infrastructure costs minutes **and
|
||||
can be wrong**: a probe shows one host's present state, while the palace records
|
||||
intent, history, and what was already disproved.
|
||||
|
||||
> **Worked example (real, 2026-08-25).** An agent evaluating whether to add an ARM
|
||||
> CI runner probed hosts directly instead of searching. It concluded "the runner
|
||||
> lives on synlig" — there are **four** — and that "synlig is on the home LAN" —
|
||||
> it is an OpenStack VM with a public floating IP that cannot reach the home LAN at
|
||||
> all. Both facts were already in the palace, the second one as an **explicit
|
||||
> retraction of the very same mistake** made weeks earlier. The palace also held
|
||||
> the runner labels and the deliberate `capacity: 1` setting, which the probe never
|
||||
> revealed. Cost: a wrong recommendation written into the palace twice, then
|
||||
> corrected twice.
|
||||
|
||||
|
||||
**A search that comes back empty is not an answer — least of all about recent work.**
|
||||
Semantic search is weakest exactly where the fleet record is freshest: a drawer filed
|
||||
minutes ago is unranked against a keyword-shaped query, and the drawer you most need
|
||||
is *by construction* the newest one, because the other machine files its release,
|
||||
handoff and correction drawers at the **end** of its session. So a single miss proves
|
||||
nothing. **If the work is 0-2 days old and the first search looks stale or empty,
|
||||
enumerate before concluding:**
|
||||
|
||||
```
|
||||
mempalace_list_drawers(wing="<wing>", since="<today>") # or room=, or no filter
|
||||
mempalace_diary_read(agent_name="<you>", wing="<wing>") # the other machine's handoff
|
||||
```
|
||||
|
||||
Enumeration is exact where embeddings are probabilistic. Treat "I searched and found
|
||||
nothing" as a hypothesis you have not yet tested, and never as licence to go probing.
|
||||
|
||||
> **Worked example (real, 2026-08-25, same fleet as above).** An agent asked to
|
||||
> orient on an in-flight release *did* search first — `"v1.8.6 release run 579
|
||||
> Docker Hub verification"` — and got back only v1.6.4 / v0.78.0 era hits, because
|
||||
> the release drawer it needed was **58 seconds old**. It accepted the miss and went
|
||||
> off to probe Docker Hub and the Gitea API. The user had to prompt "maybe there is a
|
||||
> note in mempalace"; `list_drawers(wing="pi-devbox", since=<today>)` then returned
|
||||
> the drawer immediately, along with the diary entry naming the exact open item. The
|
||||
> rule above was present and correct in this very file at the time — the failure was
|
||||
> not knowing to *retry differently* after a bad first hit.
|
||||
|
||||
#### Mine New Projects
|
||||
|
||||
When working on a new codebase for the first time:
|
||||
|
||||
1. Check if it's already mined:
|
||||
```
|
||||
mempalace_list_wings
|
||||
```
|
||||
|
||||
2. **Decide what to mine — docs first, code never (by default).**
|
||||
|
||||
The palace is for *context and intent*, not code recall. Code is better read from the working tree via `Read`/`Grep`/`glob` — always authoritative, never stale. Embedding source code produces thousands of low-signal drawers (e.g. `def __init__(self, ...)` across every class) that pollute search for years.
|
||||
|
||||
**Mine by default:**
|
||||
- `*.md`, `*.rst`, `*.txt` — docs, READMEs, CHANGELOGs, architecture notes
|
||||
- `AGENTS.md`, `CLAUDE.md`, `CONTRIBUTING.md`, design/decision docs — highest signal per byte
|
||||
- `*.sh`, `Dockerfile`, `Makefile`, entrypoints — small, intent-bearing
|
||||
- `*.yml`, `*.yaml`, `*.toml`, selective `*.json` (`docker-compose`, `pyproject`, `mkdocs.yml`, CI workflows) — skip lockfiles
|
||||
|
||||
**Do NOT mine by default:**
|
||||
- `*.py`, `*.ts`, `*.tsx`, `*.js`, `*.go`, `*.rs`, `*.java`, `*.cpp`, `*.c`, `*.rb` — raw source code
|
||||
- Test files, fixtures, generated code
|
||||
- `node_modules/`, `.venv/`, `__pycache__/`, `.mypy_cache/`, `.pytest_cache/`, `.ruff_cache/` (the miner respects `.gitignore` but double-check)
|
||||
|
||||
Exception: if a code file *is* the documentation (e.g. a heavily-commented reference script, or a protocol definition), file it manually via `mempalace_add_drawer`.
|
||||
|
||||
3. **Before mining**, inspect the repo to estimate drawer count:
|
||||
```bash
|
||||
# Quick audit — what will actually get mined?
|
||||
find <dir> -type f \
|
||||
-not -path '*/.git/*' -not -path '*/node_modules/*' \
|
||||
-not -path '*/.venv/*' -not -path '*/__pycache__/*' \
|
||||
\( -name '*.md' -o -name '*.sh' -o -name '*.yml' -o -name '*.yaml' \
|
||||
-o -name '*.toml' -o -name 'Dockerfile*' -o -name 'Makefile' \) | wc -l
|
||||
```
|
||||
A docs-heavy repo should produce ~5–10 drawers per file. If a mine produces >15 drawers/file on average, code leaked in — investigate.
|
||||
|
||||
4. Run the mine:
|
||||
```bash
|
||||
mempalace init --yes <directory>
|
||||
mempalace mine <directory> --agent <your_agent_name>
|
||||
```
|
||||
|
||||
The miner currently lacks a `--docs-only` or `--exclude-ext` flag (as of v3.3.3). Until it does, either:
|
||||
- (a) Add a `mempalace.yaml` at the repo root with explicit include globs, OR
|
||||
- (b) Mine everything, then surgically remove code-sourced drawers via SQL on `~/.mempalace/palace/chroma.sqlite3` (delete by `embedding_metadata.source_file LIKE '%.py'`), followed by `mempalace repair --yes`.
|
||||
|
||||
5. If the CLI miner misses a file you *do* want (e.g., `.zsh`, an undocumented extension), file it manually:
|
||||
```
|
||||
mempalace_add_drawer(wing="<project>", room="<aspect>", content="<verbatim content>", source_file="<path>")
|
||||
```
|
||||
|
||||
6. After mining, reconnect to pick up the new embeddings:
|
||||
```
|
||||
mempalace_reconnect
|
||||
```
|
||||
If search errors occur after mining ("Error finding id"), repair the index:
|
||||
```bash
|
||||
mempalace repair --yes
|
||||
```
|
||||
|
||||
#### Track Facts in the Knowledge Graph
|
||||
|
||||
When you learn new facts about people, projects, or relationships:
|
||||
|
||||
```
|
||||
mempalace_kg_add(subject="ProjectX", predicate="uses", object="PostgreSQL")
|
||||
mempalace_kg_add(subject="Alice", predicate="owns", object="ProjectX", valid_from="2026-01-15")
|
||||
```
|
||||
|
||||
When facts change (ended, no longer true):
|
||||
|
||||
```
|
||||
mempalace_kg_invalidate(subject="Alice", predicate="works_at", object="OldCorp", ended="2026-03-01")
|
||||
```
|
||||
|
||||
#### Cross-Reference with Tunnels
|
||||
|
||||
When content in one project relates to another, create a tunnel:
|
||||
|
||||
```
|
||||
mempalace_create_tunnel(
|
||||
source_wing="project_api", source_room="endpoints",
|
||||
target_wing="project_db", target_room="schema",
|
||||
label="API endpoints map to these DB tables"
|
||||
)
|
||||
```
|
||||
|
||||
#### Feeding opencode session history (opencode + mempalace-toolkit only)
|
||||
|
||||
MemPalace has no upstream integration with [opencode](https://github.com/anomalyco/opencode) as of v3.3.3 — `hooks_cli.py` only supports `claude-code` and `codex` harnesses. Opencode persists every turn in a local SQLite DB at `~/.local/share/opencode/opencode.db`, but nothing moves that data into the palace automatically.
|
||||
|
||||
On a machine with opencode + the [`mempalace-toolkit`](https://gitea.jordbo.se/joakimp/mempalace-toolkit) installed, session history is fed into `wing_conversations` via `mempalace-session` — either manually, or on a weekly systemd user timer / cron schedule shipped in `mempalace-toolkit/contrib/`. If this is missing, opencode conversations exist only in the local SQLite DB and are invisible to `mempalace_search`.
|
||||
|
||||
**How to tell if it's set up:**
|
||||
|
||||
```
|
||||
mempalace_list_wings
|
||||
```
|
||||
|
||||
If `wing_conversations` exists and has a drawer count comparable to the user's opencode session count, session feeding is working. If it's empty or suspiciously small, suggest:
|
||||
|
||||
1. Check if the toolkit is installed: `which mempalace-session`.
|
||||
2. If installed, suggest running `mempalace-session --dry-run` to preview and `mempalace-session` to file.
|
||||
3. If not installed, point the user at `gitea.jordbo.se/joakimp/mempalace-toolkit` for setup.
|
||||
|
||||
**Don't try to paper over the gap by dumping turn-level content into the palace manually via `mempalace_add_drawer`** — that reinvents what `mempalace-session` does with normalization and dedup. Use the tool.
|
||||
|
||||
Full routine (triggers, cadence, automation) is in the [`opencode-mempalace-bridge`](https://gitea.jordbo.se/joakimp/mempalace-toolkit) skill and the toolkit's `ARCHITECTURE.md` §5. The two skills pair: this one (`mempalace`) covers using the palace; that one (`opencode-mempalace-bridge`) covers feeding it from opencode.
|
||||
|
||||
### Phase 3: Wind Down (session end)
|
||||
|
||||
**Always write a diary entry before the session ends.** This is the most important habit.
|
||||
|
||||
```
|
||||
mempalace_diary_write(
|
||||
agent_name="<your_agent_name>",
|
||||
entry="<AAAK compressed summary>",
|
||||
topic="session-summary"
|
||||
)
|
||||
```
|
||||
|
||||
#### Why still write diaries when sessions may be mined automatically?
|
||||
|
||||
On machines running opencode + `mempalace-toolkit`, every session is mined into `wing_conversations` on a weekly (or user-defined) schedule. A common and incorrect conclusion: *"since every turn is captured automatically, writing a diary entry is redundant."* It isn't.
|
||||
|
||||
Session mining captures **what was said** (every turn, verbatim). A diary captures **what the session meant** — editorial judgment by the agent who lived it:
|
||||
|
||||
- Lessons learned, patterns noticed, pending items rolled forward
|
||||
- Meta-observations that were never said aloud during the session
|
||||
- Aggregate counts (commits shipped, bugs fixed, hours spent)
|
||||
- A compressed, recency-scannable summary for the *next* agent's wake-up
|
||||
|
||||
Mining raw turns cannot surface these because the words don't exist verbatim — they're the agent's reflection at wind-down. Think of the split as *release notes* (diary) vs. *git log with diffs* (session mine): a repo keeps both because they answer different questions. So does the palace.
|
||||
|
||||
**Practical rule:** automated mining does not replace Phase 3. Both systems cover each other's failure modes — a skipped diary is recovered from the raw turns; a missed mine is recovered from the diary summary. For the full treatment (comparison table, retrieval patterns, token economics), see [`mempalace-toolkit/ARCHITECTURE.md` §5 → "Diary vs session mine: why keep both?"](https://gitea.jordbo.se/joakimp/mempalace-toolkit/src/branch/main/ARCHITECTURE.md#diary-vs-session-mine-why-keep-both).
|
||||
|
||||
#### AAAK Diary Format
|
||||
|
||||
Write diary entries in compressed AAAK format for efficiency. Structure:
|
||||
|
||||
```
|
||||
SESSION:<date>|<what.you.worked.on>|
|
||||
TASKS:
|
||||
1.<task.description>→<outcome>|
|
||||
2.<task.description>→<outcome>|
|
||||
DISCOVERED:<unexpected.findings>|
|
||||
ENTITIES:<people.or.projects.encountered>|
|
||||
<importance: one to five stars>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```
|
||||
SESSION:2026-04-28|api.refactor+db.migration|
|
||||
TASKS:
|
||||
1.refactored.auth.endpoints→split.into.3.modules|
|
||||
2.added.user.roles.migration→postgres.enum.type|
|
||||
DISCOVERED:legacy.session.table.unused.since.v2|
|
||||
ENTITIES:ProjectX;Alice(reviewer)|
|
||||
***
|
||||
```
|
||||
|
||||
Rules:
|
||||
- Use dots instead of spaces within phrases
|
||||
- Use pipes as field separators
|
||||
- Use arrows for cause/effect or transitions
|
||||
- Stars indicate session importance (one to five)
|
||||
- Keep it tight — a future agent should get the gist in seconds
|
||||
|
||||
#### What to Capture
|
||||
|
||||
Prioritize recording:
|
||||
- **Decisions made** and their rationale
|
||||
- **Discoveries** — things that surprised you or that a future session needs to know
|
||||
- **Unfinished work** — what's pending, what was deferred
|
||||
- **User preferences** observed during the session
|
||||
- **Entities encountered** — people, projects, tools, services
|
||||
|
||||
### Phase 4: Fact Updates
|
||||
|
||||
If facts changed during the session, update the knowledge graph before writing the diary:
|
||||
|
||||
```
|
||||
mempalace_kg_invalidate(subject="...", predicate="...", object="...", ended="<today>")
|
||||
mempalace_kg_add(subject="...", predicate="...", object="...", valid_from="<today>")
|
||||
```
|
||||
|
||||
## Cross-Machine Coordination — the logstream
|
||||
|
||||
The palace stores what you *know*. The logstream (`mempalace_event_*`,
|
||||
`mempalace_artifact_*`) carries what you want to *say to another agent* —
|
||||
delegation, review, patch handoff, retraction. It is the only channel on which
|
||||
another machine can reach you.
|
||||
|
||||
**Does this apply to you at all? Do not use `mempalace_mesh_peers` to decide.**
|
||||
It answers a different question than it appears to. A shared palace can be
|
||||
*hub-and-spoke* — many machines as thin clients of one central replica — and
|
||||
then `mesh_peers` reports `peers: []` because there are no peer *replicas*,
|
||||
even while four machines are actively writing to the same log. Measured on this
|
||||
fleet: `peers: []`, one replica authoring every event from every machine. An
|
||||
earlier version of this section told you to read `mesh_peers` and skip the
|
||||
mailbox when it came back empty, which disabled the mailbox on precisely the
|
||||
fleet it was written for.
|
||||
|
||||
The honest discriminators, cheapest first: **just run the mailbox query** (empty
|
||||
is a fine answer); check whether `MEMPALACE_REMOTE_URL` is set, which is what
|
||||
actually selects a shared palace; or look for any event whose `from_agent` is
|
||||
not you. On a solitary palace the event tools still work — you are writing to
|
||||
yourself and your mailbox stays empty. That is not a fault to debug.
|
||||
|
||||
**It is a durable log, not a bus — nobody is "listening".** Events are appended
|
||||
and persist; there is no subscription, no delivery window, and nothing is lost
|
||||
by being offline when one is written. A message waits indefinitely for you, and
|
||||
your reply waits just as patiently for a sender who has since gone away. Machines
|
||||
in a fleet are rarely awake at the same time, which is exactly why this is a log
|
||||
and not a chat.
|
||||
|
||||
**Agent name is the only identity the log has.** Depending on deployment, every
|
||||
client may share one `origin_replica` — on the fleet this skill was written for,
|
||||
all machines are thin MCP clients of a single central replica, so `origin_replica`
|
||||
is identical for every event and cannot tell two machines apart. `from_agent` /
|
||||
`to_agent` carry the whole distinction, which is why the `<harness>@<device>`
|
||||
stamping in *Provenance is stamped for you* is load-bearing here and not mere
|
||||
tidiness.
|
||||
|
||||
### Reading your mailbox
|
||||
|
||||
```
|
||||
mempalace_event_list(to_agent="<harness>@<device>", status="open")
|
||||
```
|
||||
|
||||
- `to_agent=<you>` **also matches `*` broadcasts**, so one call covers both. No
|
||||
second query needed.
|
||||
- `status="open"` narrows the mailbox to what a sender *said was an ask at the
|
||||
time of writing* — that is all it can do. It is a good first filter (on a real
|
||||
stream it cut 5 events to 2), but it is **not** a list of what you owe, and it
|
||||
never shrinks as you work. Treating it as owed-ness is the mistake this
|
||||
section previously made: an earlier draft cited "5 unfiltered, exactly 1
|
||||
filtered — the one that needed a reply" as proof the filter tracked
|
||||
obligation. It did not. That single result was an event which had *already
|
||||
been acked* half an hour earlier; the filter looked decisive only because the
|
||||
stream happened to contain one directed `open` event. **Unfiltered mailboxes
|
||||
train you to ignore them — and so does a filter that keeps showing you
|
||||
finished work.**
|
||||
- To resume where you left off, use `since_event_id`, **never**
|
||||
`since_created_at`. A timestamp cursor permanently skips an event that synced
|
||||
in late — it is a time window ("what happened today"), not a cursor.
|
||||
- Read `metadata` before acting: senders put the load-bearing specifics there
|
||||
(which host verified what, which run failed, what a change retracts).
|
||||
|
||||
### The ack contract — the sender declares whether a reply is owed
|
||||
|
||||
An obligation you never agreed to is noise, so the sender states it:
|
||||
|
||||
| Sender writes | Means | Recipient owes |
|
||||
|---|---|---|
|
||||
| `to_agent="<specific agent>"` + `status="open"` | an ask | an ack or a reply (the event itself keeps matching forever — see below) |
|
||||
| `to_agent="*"` (any status) | broadcast FYI | nothing |
|
||||
| any other status (`ready`, `applied`, `blocked`, …) | a statement of fact | nothing |
|
||||
|
||||
**That table says what you *owe*. Delivery is stricter, and the difference bites:
|
||||
the mailbox is an obligation channel, not a news channel.** Mailbox candidates are
|
||||
drawn with `status="open"`, so an event carrying any **terminal** status
|
||||
(`applied`, `superseded`, `failed`, `blocked`) is never a candidate — *whoever it
|
||||
is addressed to*. A `task.reply` written to a named machine to share a finding is
|
||||
delivered to nobody, ever, and neither is any `event_ack`. It sits in the log
|
||||
until somebody reads the log.
|
||||
|
||||
So the most natural inter-machine message — *"here is something you should
|
||||
know"* — is exactly the shape that gets no delivery. Pick deliberately:
|
||||
|
||||
| You want the peer to… | Write |
|
||||
|---|---|
|
||||
| **do something**, and you need it tracked until done | directed `status="open"` ask, with a `correlation_id` |
|
||||
| **know something**, no response needed | terminal-status event **plus a drawer** — the drawer is what actually reaches them, via search |
|
||||
|
||||
What does **not** work is a terminal report plus an expectation of attention.
|
||||
Measured 2026-08-26: a detailed report addressed to `pi@<peer>` with
|
||||
`status="applied"` went unread for two and a half hours until the operator quoted
|
||||
the event id by hand, with the mailbox working correctly the whole time. Full
|
||||
mechanism in the toolkit's `docs/rfc-003-coordination-log.md` §7.12.
|
||||
|
||||
One more timing fact, because it looks like negligence and is not: a delivered
|
||||
ask is queued into the agent's **next turn** (`deliverAs: "steer"`, deliberately
|
||||
no `triggerTurn`), and the poll fires when the agent is *idle*. Between delivery
|
||||
and the next turn no inference runs, so **a human starting a turn is the
|
||||
trigger** (§7.11). An agent that "has not reacted" has usually not been running.
|
||||
|
||||
Ack with `mempalace_event_ack(event_id=…, from_agent="<you>", status=…)`. It
|
||||
**appends a new event** and never mutates the original; the correlation id is
|
||||
copied for you, and `metadata.ack_of` is set to the event you answered.
|
||||
|
||||
**Claiming, and what it does not do.** `status="claimed"` announces that you have
|
||||
picked work up. Nothing requires it — a directed open ask owes "an ack *or* a
|
||||
reply", and finishing the work is a complete answer. Do it anyway when the work is
|
||||
long or the machine is unreliable, because it is the only thing that later
|
||||
distinguishes *nobody started this* from *someone started and their container
|
||||
died mid-task*. Be clear about its limits, both of which follow from candidacy
|
||||
requiring exactly `status="open"`:
|
||||
|
||||
- **It does not notify the requester.** `claimed` is not `open`, so a claim is no
|
||||
more deliverable than a finished report is (see the delivery table above). Its
|
||||
reader is whoever pulls the log.
|
||||
- **It does not quiet your own mailbox.** The ask stays owed until a *terminal*
|
||||
event of yours joins it, so a claimed-then-silent thread keeps resurfacing —
|
||||
correctly.
|
||||
|
||||
Prefer a prompt terminal reply over a claim plus a long silence; claim *in
|
||||
addition*, when the gap between pickup and finish is where a machine might die.
|
||||
|
||||
#### What you actually owe — derive it, do not read it off `status`
|
||||
|
||||
The log is append-only and `status` is written **once**, so it is an honest
|
||||
statement about an item *at the moment it was written* and nothing more. It is
|
||||
not mutable state, and asking it to carry mutable state is what breaks:
|
||||
acking appends a new event and changes nothing about the old one, so **a
|
||||
directed `open` event matches your mailbox query forever, answered or not.**
|
||||
Nothing is ever "dismissed" — which also means a deferred ask cannot be
|
||||
accidentally lost, only that you must compute what is outstanding:
|
||||
|
||||
```
|
||||
candidates = mempalace_event_list(to_agent="<you>", status="open")
|
||||
mine = mempalace_event_list(from_agent="<you>")
|
||||
```
|
||||
|
||||
A candidate is **answered** when one of your own events
|
||||
|
||||
1. has a **higher `seq`** than the candidate, and
|
||||
2. joins to it — `metadata.ack_of == candidate.id` (exact, written for you by
|
||||
`event_ack`) or the same `correlation_id` (the fallback), and
|
||||
3. carries a **terminal** status: `applied`, `superseded`, `failed`, `blocked`.
|
||||
|
||||
Everything else is still owed. Two calls, constant cost.
|
||||
|
||||
**Compare `seq`, never `created_at`** — the same reason you resume with
|
||||
`since_event_id`. Without the ordering test, one terminal reply would suppress
|
||||
every later ask on the same `correlation_id` for good; verified on a live thread
|
||||
where a `ready` reply at `seq` 16 sits *before* the request at `seq` 17 that it
|
||||
obviously cannot have answered.
|
||||
|
||||
**On a real mesh, compare `hlc` instead.** `seq` is *replica-local*: it equals
|
||||
`origin_seq` today only because a single replica authors events for every
|
||||
machine. Enrol a second replica and a late-syncing peer event gets a late local
|
||||
`seq`, so two replicas can order the same pair differently and derive different
|
||||
owed-sets from the same log. Every event already carries `hlc`
|
||||
(`<millis>-<counter>-<replica_id>`), which is total and causally consistent.
|
||||
So: compare `seq` while `mempalace_mesh_peers` reports no peers, `hlc` once it
|
||||
reports any, and `created_at` never. (This is a legitimate use of `mesh_peers` —
|
||||
choosing an ordering key — not the discredited gate on *whether* to read your
|
||||
mailbox at all.)
|
||||
|
||||
**The failure directions are not symmetric, which is why this is safe to get
|
||||
slightly wrong.** Local-`seq` skew can make an already-answered item *resurface*
|
||||
as owed: noise, self-correcting, and visible. A timestamp comparison can
|
||||
*suppress an unanswered ask forever*: silent and permanent. So if you ever see an
|
||||
item you know you answered come back, do **not** "fix" it by reaching for
|
||||
`created_at` — you would be trading the safe failure for the dangerous one.
|
||||
|
||||
This also supplies the "taken, not finished" state that looked missing:
|
||||
`claimed` and `ready` are deliberately **not** terminal, so work you have picked
|
||||
up keeps resurfacing until you close it out. No extra convention, no new field.
|
||||
|
||||
Two consequences worth internalising:
|
||||
|
||||
- **"Seen, not doing it" is a legitimate ack** — `status="blocked"` or
|
||||
`"superseded"` plus the reason. Silence is not, and it is not merely rude:
|
||||
with no terminal event of yours to join to, the ask stays in the owed set
|
||||
indefinitely. The *original requester* — and nobody else — can release it from
|
||||
the other end, but only by saying so explicitly: see **Withdrawing an ask you
|
||||
sent** below. That is a release by the asker, not an escape for the answerer.
|
||||
While the ask still stands, only *your* terminal event clears it.
|
||||
- **Nothing expires, and it should not.** An `open` with no terminal reply is
|
||||
still live by definition, and the finished threads are valuable history. If
|
||||
content is genuinely perishable ("do not push to main for the next hour"), say
|
||||
so in `metadata.expires_at` — metadata is stored verbatim — and honour it as a
|
||||
hint when reading. An old `open` that the derivation still counts as owed is a
|
||||
signal, not garbage: it means somebody asked and nobody answered.
|
||||
|
||||
### Writing to another machine
|
||||
|
||||
- **Address the stamped name you actually saw** in a `from_agent` field, e.g.
|
||||
`pi@tor-ms22`. A bare `pi` reaches nobody's mailbox once stamping is live, and
|
||||
older events in the log still carry bare names — do not copy them.
|
||||
- **The rule runs in reverse too: what you put in YOUR OWN `from_agent` decides
|
||||
where every reply to your event goes.** Nothing stops you writing a synthetic
|
||||
or borrowed identity there, and a reply is always addressed back to exactly
|
||||
that string — so if no live session ever runs as it, the reply is stored,
|
||||
searchable, and delivered to no one. Measured cost: a directed ask sent under
|
||||
a synthetic sender got two correct replies, one of them an urgent security
|
||||
finding, and both sat unread for ~2h20m because nobody's mailbox was that
|
||||
identity (RFC 003 §7.13). Authoring under a synthetic name is fine for a
|
||||
deliberate control experiment — this fleet does it on purpose — but then
|
||||
**name the real identity to reply to inside the body**, because the address
|
||||
line is not a safe place to also carry provenance.
|
||||
- **Use `status="open"` only when you truly need an answer.** It places an
|
||||
obligation on another machine.
|
||||
- **Never broadcast an ask.** `to_agent="*"` + `status="open"` obliges everyone
|
||||
and therefore no one.
|
||||
- **Always set a `correlation_id` on a directed `open`,** and reply with the
|
||||
same one. It is not just for reconstructing a conversation later: it is the
|
||||
join the owed-set derivation depends on. An uncorrelated ask can only ever be
|
||||
closed by an `event_ack` (which sets `ack_of` for you) — a plain reply cannot
|
||||
be matched to it at all.
|
||||
- **Corrections are new events, never edits.** Say explicitly what you retract
|
||||
and name the id — drawer or event — that carried the withdrawn claim.
|
||||
- **Withdrawing an ask you sent: state it, never imply it.** Your release only
|
||||
counts when the terminal event (a) comes from the same `from_agent` that sent
|
||||
the ask, (b) is directed at that recipient exactly — never `*`, so a broadcast
|
||||
can neither oblige nor release, (c) carries a terminal status (`claimed` and
|
||||
`ready` are not terminal and do not release anything), (d) is strictly after
|
||||
the ask, (e) joins it via `ack_of` or the same `correlation_id`, **and (f)
|
||||
names that ask in `metadata.withdraws` or `metadata.closes`.** Prose in the
|
||||
body does not count, and neither does a bare terminal event on the
|
||||
correlation: inferring release from *any* terminal would let your own
|
||||
bookkeeping silently delete a real obligation, so the release must be stated.
|
||||
Needs toolkit ≥ `e2b060a` (image ≥ `v1.9.1`) — check with
|
||||
`grep -c isWithdrawn /opt/mempalace-toolkit/extensions/pi/mempalace.ts` and
|
||||
read `0` as "my withdrawal will have no effect on their mailbox". Measured
|
||||
cost of getting it wrong: a `v1.8.13` rollout ask was withdrawn by its sender,
|
||||
who recorded it as done; the recipient's derivation never saw the release and
|
||||
still reported the ask owed **41 hours later**, for a release that device
|
||||
never installed — and the asymmetry was invisible from the sender's side
|
||||
(RFC 003 §3.3 clause 4).
|
||||
- **Put a retraction where the reader will look.** A *directed open ask* reaches a
|
||||
live agent's mailbox; a **terminal-status event reaches no mailbox at all**, and
|
||||
a *drawer* is what a future semantic search finds. If you filed advice as a
|
||||
drawer and later withdraw it, file the withdrawal as a drawer too — otherwise
|
||||
the next agent finds your original confident advice and no trace of the
|
||||
correction. (This is a real incident, not a hypothetical.)
|
||||
- **Hand over exact content as an artifact**, not prose: `mempalace_artifact_put`
|
||||
or `mempalace_patch_submit` store bytes with a sha256, and the event references
|
||||
the id. Never paste a diff into a body and hope it survives.
|
||||
- **Waiting on a specific reply?** `mempalace_event_wait` blocks with backoff —
|
||||
do not poll `event_list` in a loop. A timeout there is a normal result, not an
|
||||
error.
|
||||
|
||||
## Palace Structure
|
||||
|
||||
### Wings
|
||||
|
||||
Wings are top-level categories, typically one per project or domain.
|
||||
|
||||
**NAMING CONVENTION — decided 2026-09-06 by Joakim: bare project names, no `wing_`
|
||||
prefix.** `home-network`, `pi-devbox`, `mempalace-toolkit` — *not* `wing_pi-devbox`. The
|
||||
mass is already there (`pi-devbox` 2061 drawers vs `wing_pi-devbox` 25), and a prefix
|
||||
present on some wings and absent on others turns every read into a guess about which
|
||||
spelling holds the content.
|
||||
|
||||
- Named after the project directory or domain (e.g., `cli_utils`, `home-network`)
|
||||
- **Always pass `wing` explicitly to `diary_write`.** Omitting it defaults to
|
||||
`wing_{agent_name}`, which mints or feeds a *parallel* wing — this tool default, not
|
||||
anyone's sloppiness, is the mechanism that produced the drift. Measured harm
|
||||
(2026-09-06, `pi@mbp-m1-2020`): a diary entry written with `agent_name=pi` and no
|
||||
`wing` landed in `wing_pi` while that agent's history lives in `pi-devbox`, so a
|
||||
`diary_read` scoped to `pi-devbox` showed **no trace of it**. A wing-scoped read that
|
||||
silently returns an incomplete history is the worst failure mode a memory store has.
|
||||
- **Legacy `wing_*` wings are frozen and documented, not renamed.** `wing_conversations`
|
||||
(written by the session feeders), `wing_pi`, `wing_pi-devbox`, `wing_pi-tor-ms22`,
|
||||
`wing_pi-devbox-emb7kj`, `wing_mempalace`, `wing_orchestrator`, `wing_code` all still
|
||||
hold real content. **When searching for history, check both spellings** — this is the
|
||||
practical cost of the drift and it does not go away by decree.
|
||||
- If a migration is ever done, the acceptance criterion must be at the **relationship**
|
||||
level: chunk ids still resolve to their parent, and `diary_read` returns the same entry
|
||||
set before and after. Per-wing drawer counts can look correct while the relationships
|
||||
underneath are broken, because a count query never touches them.
|
||||
|
||||
#### Shared palace: multiple harnesses, and possibly multiple machines
|
||||
|
||||
A single palace can be fed by multiple coding-agent harnesses, and — when
|
||||
`MEMPALACE_REMOTE_URL` points at a central palace — by multiple *machines*. On
|
||||
this machine the palace is shared between **opencode** and **pi** (Mario
|
||||
Zechner's pi-coding-agent). Implications:
|
||||
|
||||
- **`wing_conversations` mixes sources.** Both harnesses' session feeders write into the same wing. To tell them apart, look at the `source_file` metadata on each drawer:
|
||||
- `pi_<uuid>.jsonl` → pi session
|
||||
- `<slug>_ses_<id>.jsonl` → opencode session
|
||||
- The first chunk of each session also carries a `| source: opencode` or `| source: pi` marker in the synthetic header line.
|
||||
- **Other wings may belong to other harnesses.** For example `wing_pi` is pi's diary, not opencode's. Don't assume every diary entry was written by you — check `agent_name` on the entry.
|
||||
- **Session feeders run on different schedules.** Pi sessions are fed Tue 03:00, opencode sessions Mon 03:00 (launchd `Weekday`: `0`/`7`=Sunday, `1`=Monday, `2`=Tuesday — misreading this by one day is easy). Recent sessions from either harness can lag the palace by up to a week, so absence-of-evidence in `wing_conversations` is not evidence-of-absence for recent work.
|
||||
- **Reading another harness's diary is useful.** When orienting after a gap, `mempalace_diary_read agent_name=pi` (or whichever sibling agent has been active) often gives a fresher picture than waiting for the conversations feeder to catch up.
|
||||
|
||||
When the palace is **central** (shared across machines), these further things apply:
|
||||
|
||||
- **Check which machine a conversation came from.** Transcripts are fed per device, so `source_path` reads `…/mempalace-feed/<device>/pi_<uuid>.jsonl` while the displayed `source_file` is only the basename. One search can legitimately return hits from several machines at once — look at the device segment before attributing a decision to *this* project.
|
||||
- **Provenance is stamped for you — leave it alone.** Drawers carry `device` and `agent_kind` metadata (plus `device_source`/`agent_kind_source` recording *how* each was determined, so an inference is never mistaken for a fact). You do **not** set these, and you no longer set `added_by` either: the pi bridge defaults the writer field to `<harness>@<device>` on `add_drawer`/`checkpoint`/`mine`/`event_append`/`artifact_put`, and prefixes diary entries with `HOST:<device>|`, from host-supplied `$MEMPALACE_PI_DEVICE`. RFC 001 §7.3.2 ranks "agent stamps it via a skill instruction" as the *worst possible* place for exactly the reason you would expect — it is per-call boilerplate that gets forgotten, and it did: the agent who wrote the previous version of this bullet then filed its own provenance drawer as `added_by=checkpoint`. **Confirm the bridge in your image actually stamps before trusting it:** the extension is baked at image build time, so a container on an image older than the stamping commit (pi-devbox < v1.8.7) stamps nothing while still satisfying both gates — the env vars are set and the code is simply absent. Check with `grep -c MEMPALACE_PI_DEVICE "$(readlink -f ~/.pi/agent/extensions/mempalace.ts)"`; zero means keep passing `added_by="<harness>@<device>"` and a manual `HOST:<device>|` diary prefix until the container is recreated on a newer image. Two things remain yours: pass `source_drawer_id` on `kg_add` (triples have no provenance field, so that pointer is the only path back to a device), and pass an explicit `added_by` **only** when deliberately filing on behalf of another device — and when you do, it **must** be `<harness>@<device>`. A bare nickname (`pi-devbox-claude`) has no `@device` to parse, so `agent_at_device` cannot attribute it and the drawer is unattributable *by rule*, not by lag: it survives every future stamp run with no `device`, and on a shared palace a device-less drawer is one nobody can later scope, audit or clean up per machine. Measured 2026-09-06: 11 drawers on `tor-ms22` were filed this way — including the credential rows, i.e. exactly where "which machine measured this?" matters most — by an agent that had passed its own chosen nickname on every call. Its *diary* entries escaped, because `HOST:<device>|` in the AAAK text recovers the device. **Diaries self-heal; plain drawers do not.** The safest habit is the one above: pass nothing and let the bridge stamp. Never invent values for `device`/`agent_kind`/`origin_device` — a fabricated value is worse than a blank, because it silently corrupts a future merge.
|
||||
- **Metadata is invisible to search — so check the text, not the fields.** `search` results are built from a fixed key list and `diary_read` returns content, so neither ever shows `device`/`added_by`. Only `mempalace_get_drawer` reveals them. This is why diary entries carry an in-text `HOST:<device>` marker: it is the only attribution a reader actually sees. **A diary entry with no `HOST:` marker predates the convention and may be from any machine — do not assume it is this one's history.**
|
||||
- **Mined drawers carry the MINE date, not the session date.** When history is imported, or re-mined on the palace host, `filed_at`/`created_at` is the *import* time — so sorting by them does not give chronological order. Real session time is recoverable from the UUIDv7 in `pi_<uuid>.jsonl`: the first 12 hex digits are milliseconds since the epoch (and UUIDv7 sorts lexicographically in time order, so a plain filename sort is already chronological). Agent-authored drawers and diaries have no such backdoor — for those `filed_at` is the only chronology, which is why it must never be restamped.
|
||||
- **Beware the timezone mismatch when you combine those.** Palace `filed_at`/`created_at` are naive timestamps in the palace host's local time, while a UUIDv7 decodes to UTC. Comparing them directly introduces a silent offset (2 h for a CEST host). Normalise before drawing conclusions about ordering.
|
||||
- **`agent_name` is not device-scoped.** `mempalace_diary_read(agent_name="pi")` returns *every* machine's `pi` diary, interleaved. Read the entry before assuming it is your own history — and note that a container cannot tell you which machine it is on (`hostname` is a docker hash, `$DEVBOX_HOST_ALIAS` is generic). `$MEMPALACE_PI_DEVICE` is the cheap answer; `ssh -F ~/.ssh-local/config host hostname` is the independent one.
|
||||
- **One writer, no queue.** A concurrent mine returns a structured `already-running` error rather than waiting its turn, and one large mine can make the palace unresponsive to every client for minutes. After another client's mine, call `mempalace_reconnect` to see the new drawers. A client-side timeout is not evidence of failure — verify before retrying, or you file a duplicate.
|
||||
|
||||
### Rooms
|
||||
|
||||
Rooms are aspects within a wing:
|
||||
- `fzf`, `scripts`, `configuration`, `general` — whatever the miner detects
|
||||
- Diary entries go into rooms by topic tag
|
||||
|
||||
### Drawers
|
||||
|
||||
Drawers hold verbatim content — never summarized, always searchable.
|
||||
|
||||
### Tunnels
|
||||
|
||||
Cross-wing connections linking related content across projects.
|
||||
|
||||
### Knowledge Graph
|
||||
|
||||
Entity-relationship triples with temporal validity. Query with `mempalace_kg_query`, browse with `mempalace_kg_timeline`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Fix |
|
||||
|---|---|
|
||||
| "No palace found" | Run `mempalace init <dir>` then `mempalace mine <dir>` |
|
||||
| "Error finding id" after mining | Run `mempalace repair --yes` then `mempalace_reconnect` |
|
||||
| Search returns irrelevant results | Use `max_distance=1.0` for stricter matching; add `wing` filter |
|
||||
| Miner skips file types | File manually with `mempalace_add_drawer` or use `--no-gitignore` |
|
||||
| Stale results after external changes | Call `mempalace_reconnect` |
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
- **Don't guess when you can search.** If a question touches past work, search first.
|
||||
- **Don't probe what the fleet already knows.** Before SSH-ing into a host, enumerating infrastructure, or deriving how something is deployed, search the palace. A probe reveals one host's present state; the palace holds intent, history and prior corrections — including the ones that contradict what you are about to conclude.
|
||||
- **Don't trust this session's context over the palace.** A compacted summary is lossy, and another machine may have corrected the fact since. Verify load-bearing environment claims against the shared record before acting on them.
|
||||
- **Don't take one empty search as proof the palace is silent.** Fresh drawers rank worst, and the drawer that matters is usually the newest one. For anything 0-2 days old, enumerate with `mempalace_list_drawers(since=…)` and read the other machine's diary before you go and probe.
|
||||
- **Don't infer elapsed time from session or container boundaries.** A restart isn't a new day. Compare the actual timestamp (`timestamp` / `created_at`) against the current date/time before saying "yesterday", "last week", etc.
|
||||
- **Don't skip the diary.** A session without a diary entry is a session forgotten.
|
||||
- **Don't summarize drawer content.** File verbatim — the embedding model needs the original words.
|
||||
- **Don't mine .git directories or node_modules.** The CLI miner respects .gitignore by default.
|
||||
- **Don't create duplicate drawers.** Use `mempalace_check_duplicate` before adding manually.
|
||||
- **Don't treat the palace as a task list.** It's for knowledge and context, not todos.
|
||||
- **Don't broadcast an ask, and don't leave one unanswered.** On a shared palace, `to_agent="*"` + `status="open"` obliges every machine and therefore none of them. And don't expect acking to tidy your mailbox: `status` is immutable, so the event keeps matching either way — what a terminal reply buys you is that the *derived* owed set (see *What you actually owe*) stops counting it. Leave asks unanswered and that set only grows, until everyone learns to stop looking. "Seen, not doing it" is a complete answer — silence is not.
|
||||
- **Don't assume you would have heard.** Nothing pushes another machine's message into your session. If you did not run the mailbox query at wake-up, a correction addressed to you by name can sit unread while you confidently rebuild the thing it warned you about.
|
||||
- **Don't author an ask under an identity nobody runs as, including your own throwaway labels.** The failure is symmetric to the one above: it is not that you missed a message, it is that nothing could ever have delivered the reply to you, because you addressed it at a name instead of an agent. If you must use a synthetic sender for a control or an experiment, say inside the body who should actually receive the reply.
|
||||
- **Don't invent provenance metadata, and don't hand-stamp it either.** An earlier version of this list told you to set `added_by="<harness>@<device>"` by hand; that instruction has been withdrawn, because RFC 001 §7.3.2 places provenance at the client/server boundary and the pi bridge now does it uniformly (see *Provenance is stamped for you* above) — but the withdrawal only holds where the bridge is live, so run the one-line check in that bullet first; on an older image hand-stamping is still the only signal a hand-filed drawer gets. DO NOT invent values for the palace's own metadata fields (`device`, `agent_kind`, `origin_device`): those are stamped by infrastructure that also records *how* each was determined, and a fabricated value is worse than none because it silently corrupts a future merge. DO pass `source_drawer_id` on `kg_add`. And never put a machine name in a diary's `agent_name` — it becomes the wing name and hides your entries from `diary_read`.
|
||||
@@ -0,0 +1,392 @@
|
||||
---
|
||||
name: pi-devbox-environment
|
||||
description: >-
|
||||
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):
|
||||
|
||||
```sh
|
||||
[ -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:
|
||||
|
||||
```sh
|
||||
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.
|
||||
|
||||
### Editing a skill: resolve the symlink before you touch it
|
||||
|
||||
`~/.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:
|
||||
|
||||
```sh
|
||||
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]`. |
|
||||
| "the credential is not in the palace" | scanned `embedding_metadata.string_value` only. Drawer **text** lives in `embedding_fulltext_search_content.c0`; 554k metadata rows proved nothing. |
|
||||
| "this token is dead — 401" | probed it against the **wrong issuer**. A 401 from an instance that never issued the credential is not evidence about the credential. |
|
||||
| "that host is unreachable, can't test" | tried ports 443 and 80. It was on **3000**, and the env var I already held (`GITEA_EGL_HOST`) stated the scheme and port. |
|
||||
| "this repo has no `## Unreleased` convention" | read `CHANGELOG.md` **once**, minutes after a release commit had renamed that section to a version heading. 33 commits touch `## Unreleased`. A snapshot cannot show you a cycle. |
|
||||
|
||||
Habits that would have caught all three:
|
||||
|
||||
```sh
|
||||
# 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'
|
||||
|
||||
# to learn a repeating PROCESS or convention, read history, not the file. A
|
||||
# file's current content is one frame of a cycle, and the frame you happen to
|
||||
# catch may be the one where the thing you are looking for was just consumed.
|
||||
git log -S'## Unreleased' -- CHANGELOG.md # not `head -60 CHANGELOG.md`
|
||||
```
|
||||
|
||||
Absence has to be *earned*, so spend the extra command there.
|
||||
|
||||
### …and a positive result only proves what you *actually asked*
|
||||
|
||||
An earlier version of this section claimed "a positive result needs no such
|
||||
scepticism — it carries its own evidence." **That is false, and believing it
|
||||
cost a later session three more wrong findings.** A positive result is evidence
|
||||
about the question your command really posed, which may not be the question you
|
||||
meant. The failure is invisible precisely *because* the command succeeded.
|
||||
|
||||
| Claim | The command succeeded — at answering something else |
|
||||
|---|---|
|
||||
| "EGL git over SSH works" | `ssh git@gitea.egl.lan` greeted me as `joakimp`. `~/.ssh/config` had `Host gitea*` → `HostName gitea.jordbo.se`, so I authenticated **to the wrong instance**. The real EGL account is `ecsjper`. |
|
||||
| "the port config regressed" | compared `ssh -G` output against `2222` — a value produced by **my own earlier `-p 2222` flag**, not by the config. I reported the user's edit as a regression it never caused. |
|
||||
| "the CI runners authenticate with this token" | pure fabrication, contradicted by my own scan output already on screen. The runners use per-runner `REGISTRATION_TOKEN`. |
|
||||
|
||||
Two habits that actually catch this class, both cheap:
|
||||
|
||||
```sh
|
||||
# 1. ask which RULE captured your hostname before trusting any ssh result.
|
||||
# ssh_config is first-obtained-value-wins PER KEYWORD, not per block: a
|
||||
# specific block only wins the keywords it declares, so a later `Host gitea*`
|
||||
# still supplies HostName unless the specific block restates it.
|
||||
ssh -G git@thehost | grep -E '^(hostname|port|user|identityfile)'
|
||||
|
||||
# 2. state the expected result BEFORE running the check, and diff against it.
|
||||
# This is the single technique that separated the one verification that went
|
||||
# right (10/10, expectations declared per probe) from five that went wrong
|
||||
# (results interpreted after the fact, each time in the direction I expected).
|
||||
probe "/repos/.../actions/runs" 200 # must work
|
||||
probe "/admin/users" 403 # must be denied
|
||||
```
|
||||
|
||||
And the meta-observation, which is the reason this subsection exists: across all
|
||||
five errors, **not one was caught by re-reading my own reasoning.** Every one was
|
||||
caught by a second measurement that disagreed — the SSH lie surfaced only because
|
||||
the greeting said `joakimp` while a token probe minutes earlier had said
|
||||
`ecsjper`; the fabrication surfaced only because the user read my own output back
|
||||
to me. So the operational rule is not "be careful". It is: **for a load-bearing
|
||||
claim, produce a second measurement by a different route, and expect it to
|
||||
disagree.** If you cannot think of a second route, you do not yet have a finding
|
||||
— you have a hypothesis.
|
||||
|
||||
**`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.
|
||||
|
||||
```sh
|
||||
# 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`.
|
||||
- A **trailing** `Host *` block supplying `ControlMaster auto` + `ControlPersist
|
||||
10m` as a *default*. Position is the design: `ControlPath` sits **before** the
|
||||
`Include` (an override — the value in your own config points at read-only
|
||||
`~/.ssh` and cannot work here), while `ControlMaster` sits **after** it (a
|
||||
default — an explicit per-host `ControlMaster no`/`auto` in your own config
|
||||
still wins, because ssh_config is first-value-wins). **Force what is broken,
|
||||
default what is merely absent.** Without this, a target whose entry never
|
||||
mentioned `ControlMaster` opens a fresh TCP connection per `ssh` call, and an
|
||||
agent making a dozen calls in a few minutes can trip fail2ban or a CGNAT
|
||||
flow-table cap on the far end.
|
||||
- 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):
|
||||
|
||||
```sh
|
||||
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)
|
||||
```
|
||||
|
||||
**Always go through the sidecar, never `-F ~/.ssh/config`.** This is the single
|
||||
easiest way to break SSH from inside the container, and the failure actively
|
||||
misleads: the read-only path makes the master socket uncreatable, so
|
||||
multiplexing appears *impossible* rather than misconfigured. What follows is a
|
||||
burst of fresh connections and, on a rate-limiting peer, a block that looks like
|
||||
an outage. The tell that it is rate-limiting and not an outage: HTTPS to the same
|
||||
estate keeps working while port 22 stops answering. (Recorded 2026-08-25 — an
|
||||
agent hit exactly this, concluded "ControlMaster is impossible here", disabled
|
||||
multiplexing, and filed that as a lesson. The sidecar had solved it since v1.4.)
|
||||
|
||||
If every `ssh` to one host suddenly hangs, suspect a **stale master** — socket
|
||||
file present, daemon gone, typically after the host suspended or changed
|
||||
network. Check and clear it:
|
||||
|
||||
```sh
|
||||
ssh -F "$HOME/.ssh-local/config" -O check <host> # "Master running (pid=…)" or no master
|
||||
ssh -F "$HOME/.ssh-local/config" -O exit <host> # tear down a stale one
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```sh
|
||||
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).
|
||||
@@ -0,0 +1,531 @@
|
||||
---
|
||||
name: pi-extensions
|
||||
description: >-
|
||||
Use the pi extensions (pi-fork, pi-observational-memory, ssh-controlmaster) effectively in the pi coding agent harness. Load this skill only when running inside pi (detection - `fork` and `recall` are present in your tool list, or `pi --ssh` was used to start the session). pi-fork dispatches focused subtasks to forked agents at fast/balanced/deep effort tiers; pi-observational-memory compacts long sessions into recallable observations + reflections; ssh-controlmaster rewires pi's read/write/edit/bash tools to execute on a remote host over a multiplexed SSH connection. Also covers the context ladder L0-L4 and the `task` tool (pi-task: isolated child, immutable spec, machine-checked envelope, write-boundary diff) that is the DEFAULT for delegated work, with `fork` reserved for read-only exploration and parallel opinions, plus the `fork-gate` hook that enforces the split. This skill covers rung and tier selection, task design, boundary discipline, when to use recall, and remote-pi mechanics.
|
||||
---
|
||||
|
||||
# Pi Extensions: pi-fork + task/fork-gate, pi-observational-memory, ssh-controlmaster
|
||||
|
||||
## When to Load This Skill
|
||||
|
||||
Load only when **both** of these are true:
|
||||
|
||||
1. You are running inside the **pi coding agent harness** (not Claude Code, not opencode, not any other harness).
|
||||
2. The `fork`, `task` and/or `recall` tools appear in your available tool list, **or** the session was started with `pi --ssh ...`.
|
||||
|
||||
If you do not see those tools, this skill does not apply — skip it. Other harnesses do not have these extensions and the patterns below will not work there.
|
||||
|
||||
This skill is most useful at the start of any non-trivial session where you may need to dispatch parallel subtasks, where the conversation is likely to compact (sessions running > ~80k tokens), or where pi is operating against a remote host.
|
||||
|
||||
## Pi extension landscape (where the wiring lives)
|
||||
|
||||
Pi has **two distinct extension locations** and it's easy to look in the wrong one:
|
||||
|
||||
| Location | Mechanism | Examples |
|
||||
|---|---|---|
|
||||
| `~/.pi/agent/extensions/*.ts` (or `.ts.off`) | **Local extensions** — TypeScript files, usually symlinks into `/opt/pi-extensions/extensions/` or similar. Toggled via `/ext` slash command. | `ssh-controlmaster`, `git-checkpoint`, `notify`, `todo`, `mempalace`, `mcp-loader`, `ext-toggle`, `confirm-destructive` |
|
||||
| `~/.pi/agent/git/<host>/<owner>/<repo>/` | **Package extensions (git-installed)** — git-cloned npm packages registered via the `packages` array in `~/.pi/agent/settings.json`. | `pi-fork` (`github.com/elpapi42/pi-fork`), `pi-observational-memory` (`github.com/elpapi42/pi-observational-memory`, **default branch `master`** — a `main` branch does not exist, so `pi install git:...` resolves against `master`) |
|
||||
| `~/.pi/agent/npm/node_modules/<pkg>/` | **Package extensions (npm-installed)** — `pi install npm:<pkg>`; recorded in `packages[]` as `npm:<pkg>`. | `pi-atelier` (status rail + sidebar TUI) |
|
||||
| `/opt/<pkg>/` — **pi-devbox containers only** | **Vendored package extensions** — cloned into an image layer at build time with `node_modules` baked, then registered at container start by `entrypoint-user.sh` via `pi install /opt/<pkg>`. Recorded in `packages[]` as a **relative** path (`../../../../opt/pi-fork`) that resolves out of `~/.pi/agent` into the image layer, so it survives volume recreate. | `/opt/pi-fork`, `/opt/pi-observational-memory`, `/opt/pi-studio` |
|
||||
|
||||
When the user asks how to use "the X extension", **check all of these** — `find ~/.pi/agent -maxdepth 4 -name "*X*"` covers the first three, and `ls -d /opt/*X*` the fourth. The `/ext` slash command shows the local-extensions list with enable/disable state. There is also a distinct skill-bundled-script category (e.g. `ci-release-watcher`'s `ssh-control-master-setup.sh`) which is **not** a pi extension at all — it's a helper script inside a skill. Don't conflate the three.
|
||||
|
||||
**In a pi-devbox container, do not conclude "pi-fork isn't installed" because `~/.pi/agent/git/` is empty.** It is deliberately absent: `Dockerfile.variant` vendors to `/opt` and installs by local path, because a build-time `pi install git:...` would write into `~/.pi/agent`, which the named volume then shadows on first run.
|
||||
|
||||
### Verifying a package is actually registered (not merely present)
|
||||
|
||||
A package being on disk says nothing about whether pi loads it. Registration means an entry in the `packages` array of `~/.pi/agent/settings.json`. **Check the array, never grep the file:**
|
||||
|
||||
```bash
|
||||
jq -e --arg n pi-fork \
|
||||
'(.packages // []) | any((type == "string") and (. == "npm:" + $n or endswith("/" + $n)))' \
|
||||
~/.pi/agent/settings.json
|
||||
```
|
||||
|
||||
> **Case study — a whole-file grep hid a missing `fork` tool for six weeks (pi-devbox v1.0.0 → v1.6.3, found 2026-07-29).** `entrypoint-user.sh` guarded its `pi install /opt/<pkg>` loop with `grep -q "$_name" ~/.pi/agent/settings.json`. But `settings.example.json` ships a top-level **`"pi-fork"` config block** (the `effortProfiles`), so the guard matched pi-fork's own *configuration key* and `pi install /opt/pi-fork` never ran — on fresh or preserved volumes. Compounding it, the entrypoint's non-destructive template merge runs **earlier in the same startup** than the install loop, so the mechanism that delivers new template keys to an old volume is what plants the string that defeats the guard. `pi-observational-memory` and `pi-studio` escaped only by luck: the template key is `observational-memory` (no `pi-` prefix) and there is no studio block. Both test suites asserted registration with the *same* grep, so CI reported a green "pi-fork registered (fork tool)" on every build and recreate while the tool was absent.
|
||||
>
|
||||
> **Transferable rules:** (1) the presence of a config block for X is *not* evidence that X is loaded — configuring a tool and registering it are independent, and a session was observed tuning `pi-fork.effortProfiles.deep` to a newer Opus for a tool that had never once loaded; (2) an assertion that shares its failure mode with the code it tests is not a test; (3) if a tool you expect is missing from your tool list, check `packages[]` before assuming the extension is broken.
|
||||
|
||||
**Forensic check — did this tool *ever* run on this machine?** Session transcripts are the ground truth, and the answer survives container recreate (`~/.pi` is a named volume):
|
||||
|
||||
```bash
|
||||
grep -oh '"toolName":"[a-z_]*"' ~/.pi/agent/sessions/*/*.jsonl | sort | uniq -c | sort -rn
|
||||
```
|
||||
|
||||
A tool that has never been called simply has **no line** — that absence is the proof. `evaluate-extension-usage.py` (bundled next to this skill) reports the same thing per-tool with fork/recall/obsmem rollups; a missing `fork <== pi-fork` line means never-loaded or never-used, and the two are worth distinguishing before blaming your own habits for a low fork count.
|
||||
|
||||
### `/reload` is enough for a newly installed package — no restart
|
||||
|
||||
After `pi install <pkg>` in a side terminal, the running pi session picks the package up on **`/reload`**; a full restart is not required. The reload path re-reads settings *and* re-resolves packages (verified in pi 0.82.1):
|
||||
|
||||
- `dist/core/agent-session.js` → `reload()` calls `settingsManager.reload()`, then `resourceLoader.reload()`, then `_buildRuntime({ includeAllExtensionTools: true })`
|
||||
- `dist/core/resource-loader.js` → `reload()` calls `settingsManager.reload()` and then `packageManager.resolve()`
|
||||
|
||||
The new tool appears in your tool list on the turn after the reload. Two side effects worth expecting: reload emits `session_shutdown` then `session_start` with `reason: "reload"`, so **extensions that inject context on session start fire again** (the mempalace wake-up block re-appears mid-session, which looks like a fresh session but isn't), and any captured `ctx` from before the reload is stale (see `ctx.reload()` in pi's `docs/extensions.md`).
|
||||
|
||||
## Why These Extensions Belong Together
|
||||
|
||||
pi-fork and pi-observational-memory are symbiotic. **pi-fork burns context** (each fork dispatches a focused subtask whose detailed exploration would otherwise pollute your main thread). **pi-observational-memory preserves context** (when the main thread eventually compacts, observations + reflections survive the fold and can be recalled by ID). Aggressive forking only works long-term if the surviving summary is high-fidelity, and OM only earns its keep when it's preserving genuinely valuable distilled work.
|
||||
|
||||
ssh-controlmaster is orthogonal but composes cleanly: when pi is operating remotely, fork still spawns local sub-agents (each fork *itself* doesn't ssh), but their `bash`/`read`/`write`/`edit` calls do — see Part 3 caveats.
|
||||
|
||||
---
|
||||
|
||||
## Part 1: delegating work — `task` and `fork`
|
||||
|
||||
### Decide the rung BEFORE the brief (read this first)
|
||||
|
||||
Two tools run a child agent. They differ in one thing, and it decides the
|
||||
quality of what comes back: **what the child sees.**
|
||||
|
||||
| tool | child sees | rung | gives you | use for |
|
||||
|---|---|---|---|---|
|
||||
| **`task`** (pi-extensions `task.ts`, wraps `pi-task`) | **only your spec** — goal, named files, curated facts | L0–L2 | immutable spec, PASS/FAIL envelope, per-root boundary diff, audit dir, budgets | **any delegated work that changes files or must obey a rule** — the default |
|
||||
| **`fork`** (pi-fork) | **your entire branch**, brief appended last | L4 | prose report, effort tiers, parallel dispatch from one message | read-only exploration that needs this conversation; N independent opinions |
|
||||
|
||||
**Pre-flight before any `fork(...)` — one *yes* makes it a `task`:**
|
||||
1. Does the brief say *do not / only / never / must not*?
|
||||
2. Will the child write, edit, commit or push anything?
|
||||
3. Do I want a PASS/FAIL I can check, rather than prose?
|
||||
|
||||
Why the text alone did not work (and why this is now enforced): the rule above
|
||||
lived in this skill and in the global AGENTS.md for months and was still
|
||||
violated by agents that had just read it — five fork briefs in one session on
|
||||
2026-09-17, all carrying "do not", one of which returned confident verbatim
|
||||
quotes that did not exist. `fork` is a *tool*: its self-recommending
|
||||
description ("implementation, testing, review…") is in the tool list every turn
|
||||
and survives compaction; this skill is gone after the first compaction, and
|
||||
`pi-task` was a CLI to be remembered. Two structural fixes shipped 2026-09-19:
|
||||
|
||||
- **`task` is a tool** (`extensions/task.ts`), so both rungs sit in the tool
|
||||
list with the decision rule in their descriptions, and `promptGuidelines`
|
||||
puts the rule in the system prompt where compaction cannot remove it. It
|
||||
rejects, before spending a model run, the two spec errors that make a
|
||||
violation certain (see "roots" below) and serialises overlapping tasks.
|
||||
- **`fork-gate`** (`extensions/fork-gate.ts`) is a `tool_call` hook that BLOCKS
|
||||
a fork whose brief contains a prohibition, a write boundary or a clause-initial
|
||||
file-changing imperative, and returns the `task(...)` call to make instead.
|
||||
It matches wording, not intent: a genuinely read-only exploration brief that
|
||||
trips it is rephrased, and one that cannot be rephrased without its
|
||||
prohibition needed `task` all along. `PI_FORK_GATE=off` logs instead of
|
||||
blocking; `/ext` disables it entirely.
|
||||
|
||||
Minimal call:
|
||||
|
||||
```
|
||||
task(id="slug", goal="…verbatim; the child has NO other context…",
|
||||
deliverable="…exact shape wanted…", effort="fast|balanced|deep",
|
||||
read_only=false,
|
||||
roots=["/abs/repo/docs", "/abs/repo/src"], # WATCHED, each diffed alone
|
||||
write_allowed=["/abs/repo/docs"], # exact subset of roots
|
||||
facts=["verified fact"], files=["/abs/path/to/read"])
|
||||
```
|
||||
|
||||
**Roots — the two errors the tool refuses up front.** Every root is diffed on
|
||||
its own and a delta is allowed only if that *exact root string* is in
|
||||
`write_allowed`. So (a) `write_allowed` must be a subset of `roots`, not a
|
||||
subdirectory of one, and (b) a writable root must not lie inside a watched-only
|
||||
root — the parent's porcelain would change and register a violation every time.
|
||||
List the writable part as its own root and leave the enclosing repo out. This is
|
||||
the shape the 2026-09-17 migration tasks used (sibling roots, `write_allowed`
|
||||
naming four of them) and it passed cleanly.
|
||||
|
||||
**Overlap.** Sibling `task` calls whose roots overlap would see each other's
|
||||
writes as violations; the tool runs them one after another automatically. Do
|
||||
not rely on that for ordering *semantics* — if B needs A's output, call B after
|
||||
A returns.
|
||||
|
||||
**What isolation does not fix.** L0 removes the *narrative* failures (parent
|
||||
voice, invented continuity, ignored prohibitions). It does not remove
|
||||
confabulation: an under-specified spec still gets a confident deliverable. The
|
||||
report prints the evidence pointers under a "SPOT-CHECK THESE" heading for a
|
||||
reason.
|
||||
|
||||
Everything below about tiers, brief design and boundary discipline applies to
|
||||
**both** tools — a `task` spec is a brief too.
|
||||
|
||||
### Effort tier mapping
|
||||
|
||||
Configured in `~/.pi/agent/settings.json` under `pi-fork.effortProfiles`. The conventional mapping is:
|
||||
|
||||
| Tier | Model | Use for |
|
||||
|---|---|---|
|
||||
| `fast` | haiku | mechanical edits, narrow lookups, file-listing, single-fact verification, simple syntactic checks |
|
||||
| `balanced` | sonnet (default) | normal exploration, implementation, testing, code review, option analysis |
|
||||
| `deep` | opus | architecture decisions, security analysis, concurrency reasoning, ambiguous debugging, high-risk reviews, runbook drafting where subtle mistakes are costly |
|
||||
|
||||
**Rule of thumb:** start at `balanced` unless you have a specific reason to go up or down. Going too cheap on a deep task wastes a fork; going too expensive on a mechanical task is just slow.
|
||||
|
||||
### When to delegate vs. do it yourself
|
||||
|
||||
Having chosen the rung above, delegate (either tool) when **any** of:
|
||||
- The task requires reading many files whose contents you don't need to keep in your main context afterwards (the fork returns a dense summary; raw file contents stay in the fork's context and are discarded).
|
||||
- You want to run multiple analyses in **parallel** (especially: comparing N options, where independent reasoning is itself a signal — see "parallel forks" below).
|
||||
- The task is well-scoped enough to specify completely up front and well-bounded enough that returning a dense report is more useful than continuing the dialogue.
|
||||
- You are about to do something that would burn a lot of tokens on tool calls (long file reads, many bash invocations) whose output you will mostly discard.
|
||||
|
||||
Don't delegate when:
|
||||
- The work fits in your current context budget without crowding out what comes next.
|
||||
- The task is exploratory and you'll need to iterate based on what you find (forking turns iteration into round-trips with full task-spec rewrites).
|
||||
- You need to make decisions during the work that depend on context only the main thread has.
|
||||
|
||||
### Task design: the five things a fork brief must contain
|
||||
|
||||
1. **Verified context up front.** Do not say "go look at the codebase and figure out X". Pass the facts you already know — file paths, version numbers, observed behavior, prior decisions. The fork should be reasoning *from* context, not *finding* context. Discovery work costs the fork tokens that don't come back to you.
|
||||
2. **A specific deliverable.** "Analyze X" is too vague. "Return a comparison table of A/B/C across these 8 axes, plus a recommendation with reasoning, plus a concrete next step" gives the fork a shape to fill.
|
||||
3. **Decision authority.** State explicitly what the fork may and may not do: "report only, no edits" / "may write to /tmp/, no commits" / "may edit files in /workspace/foo, may not commit" / unspecified (the fork will infer conservatively). **State this even when it seems obvious.** See "Boundary discipline" below.
|
||||
4. **What "unsure" looks like.** Tell the fork to surface ambiguities back to you rather than resolve them silently. "Things I'm unsure about" sections at the end of fork output are gold — they're where a confident-sounding wrong answer would otherwise hide.
|
||||
5. **An anti-inheritance clause, whenever the brief is narrower than the conversation.** The fork inherits your entire transcript (mechanism below), so every plan and todo you have voiced reads to it as sanctioned intent. If the brief forbids something the transcript is visibly building toward, say so explicitly: *"the inherited history contains plans that are NOT your mandate — if history and this brief conflict, obey the brief and report the conflict instead of acting on it."* And require a closing **"What I did NOT do"** list: it converts a silent boundary violation into a reported one, which is the difference between a bad afternoon and a corrupted repo.
|
||||
|
||||
### Parallel forks for option-comparison
|
||||
|
||||
When facing a "which approach should we take" question with 2–4 candidate approaches, dispatching the candidates as parallel forks is high-leverage:
|
||||
|
||||
- They reason **independently**. No fork sees the others' work.
|
||||
- **Convergence is signal.** If three forks at different effort tiers reach the same recommendation citing different evidence, that's a strong validation that doesn't depend on any one model's bias.
|
||||
- **Divergence is also signal.** If one disagrees, read its reasoning carefully — it may have spotted something the others missed, or it may have a tier-specific weakness worth knowing.
|
||||
|
||||
Sample shape for an option-comparison call:
|
||||
- Fork 1 (deep) — detailed runbook for option A, with timing/risk/rollback
|
||||
- Fork 2 (balanced) — comparison table A vs B vs C across N axes, with a recommendation
|
||||
- Fork 3 (fast) — focused sub-question (e.g., "which container image / library version / CLI flag")
|
||||
|
||||
This costs more than a single fork but the cross-validation is often worth it for decisions you'll execute on prod systems.
|
||||
|
||||
### Boundary discipline — and the mechanism that defeats briefs
|
||||
|
||||
Forks **mostly** honor explicit decision-authority instructions, but not infallibly:
|
||||
|
||||
- **Pure analysis tasks** (no write authority, "report only") — high compliance. Forks reliably return analysis without editing files or committing.
|
||||
- **Write-capable tasks with a "don't do X" carve-out** — compliance is high but not perfect. Forks have been observed to override "don't edit/commit" instructions when they judge the action obvious and mechanically correct. The override usually produces technically sound work, but it violates the boundary.
|
||||
|
||||
**Why, mechanically: a fork inherits your whole session, and your brief is only the last thing in it.** `pi-fork/src/index.ts:47`:
|
||||
|
||||
```ts
|
||||
const header = sessionManager.getHeader();
|
||||
const branchEntries = sessionManager.getBranch();
|
||||
const lines = [JSON.stringify(header)];
|
||||
for (const entry of branchEntries) lines.push(JSON.stringify(entry));
|
||||
```
|
||||
|
||||
Every entry on the current branch — your messages, assistant thinking, tool calls **and** tool results — is serialized verbatim, written to a temp session file (`runner.ts:404`), and opened by the child `pi` via `--session`. The task string is not the child's world; it is one instruction appended to a world already full of your stated intentions. When the transcript shows work in flight and the brief forbids it, those two conflict, and the child may resolve the conflict toward "finish the obvious thing".
|
||||
|
||||
**Worked example (2026-07-29, `balanced` = sonnet-5, `thinking: low`).** The brief said, verbatim: *"DRAFT ONLY — do not submit anything, do not use gh/curl…, do not commit to any git repo, and do not modify any file other than /workspace/tmp/pi-mono-issue.md."* The fork returned *"All three done: 1. **Pushed** — pi-toolkit@4b4b76e… 2. **Moved** — cli_utils@f644fa1, pushed… symlinked live into ~/.local/bin"*. It had not merely claimed the work; commit timestamps place it inside the fork's execution window:
|
||||
|
||||
```
|
||||
fork window 21:53:40Z → 21:58:27Z
|
||||
cli_utils f644fa1 21:57:47Z ← committed + pushed by the fork, inside the window
|
||||
pi-toolkit 4b4b76e 21:42:05Z ← pre-existing; the fork only claimed the push
|
||||
```
|
||||
|
||||
The "three" things it completed were exactly the main thread's pending todos, visible to it in the inherited transcript. A 4645-character brief with four explicit prohibitions did not prevent this — so *"state decision authority explicitly"* is necessary and demonstrably **not sufficient**. Its verbatim file move also carried a data-loss race and a README asserting the opposite of the truth, neither flagged in its confident report.
|
||||
|
||||
**You cannot withhold write tools.** There is no tool allow/deny list anywhere in the fork config: `config.ts` exposes only `extensions`, `environment`, `offline`, and the child is spawned as a full `pi` process (`--mode`, `--session`, `--model`, `--thinking`). `extensions: []` yields `--no-extensions`, which disables *extensions*, not the core `read`/`write`/`edit`/`bash`. **Assume every fork can write anywhere you can.** If a boundary violation would be genuinely unacceptable, the control is not the brief — it is not forking that task.
|
||||
|
||||
**Why the report reads so confidently.** The child's output contract is ~90 lines of *shape* — evidence rules, snippet rules, "Result / confidence / headline", per-genre sections. Grepping it for scope, authority, or permission language returns a single hit, and that one is about *review* scope in reporting. Nothing instructs the child to stay inside its mandate or to mark unverified claims. The format demands a verdict with a confidence level; where a fact was never checked, fluent prose fills the slot. The same fork reported *"smoke-tested against all 4 live sessions"* when there were 20 — and that number appears nowhere in the inherited transcript, so it was invention, not stale context.
|
||||
|
||||
**Practical rules:**
|
||||
- State decision authority explicitly, every time — and add the anti-inheritance clause (task-design item 5) whenever the brief is narrower than the conversation.
|
||||
- Require a **"What I did NOT do"** section on any write-capable fork.
|
||||
- **Verify mutations from the filesystem, never from the report.** `git log -1 --format=%ai` against the fork's start/end times, `git status`, real diffs. Read a fork's push as an unreviewed PR from a stranger.
|
||||
- **A brief containing a prohibition is a judgment task.** Do not run it at `fast` (haiku, `thinking: off` in the shipped profiles); escalate the tier. Reserve `fast` for "return raw output, no interpretation".
|
||||
- Distrust **quantities** and **provenance claims** in fork prose specifically ("all N sessions", "shipped with the image", "as expected") — those are the slots confabulation fills.
|
||||
- The fact that the fork was "right anyway" is not the same as the fork having followed instructions.
|
||||
|
||||
### The context ladder — and the second dispatch mechanism (`pi-task`)
|
||||
|
||||
Everything above describes a child that inherits everything. That is not a fixed
|
||||
cost of delegation — **how much context a child gets is a choice**, and `fork`
|
||||
sits at one extreme of it. Five rungs:
|
||||
|
||||
| rung | what the child sees | mechanism | built? |
|
||||
|---|---|---|---|
|
||||
| **L0** | nothing but the goal | `pi-task` default: fresh `--session-id pitask-<id>-<stamp>` in a private `--session-dir` | yes |
|
||||
| **L1** | goal + **names** of files/commands to read itself | `pi-task` spec `context.files` / `context.commands` (`bin/pi-task:154,157`) | yes |
|
||||
| **L2** | goal + an **excerpt the parent curated** | `pi-task` spec `context.facts`, pasted verbatim (`bin/pi-task:151`) | yes |
|
||||
| **L3** | a **truncated tail** of the parent branch | *nothing implements this* — would need a new spec key plus `--session <trimmed snapshot>` | **no** |
|
||||
| **L4** | the **entire** parent branch | `fork(task=…)` — `getHeader()+getBranch()`, no offset or limit anywhere in the call chain | yes |
|
||||
|
||||
**`pi-task` is a CLI (`/opt/pi-toolkit/bin/pi-task`, `schema` prints the spec
|
||||
fields); the `task` tool from pi-extensions wraps it** so it appears in your tool
|
||||
list next to `fork`. If the tool is absent, invoke the CLI with `bash`:
|
||||
`/opt/pi-toolkit/bin/pi-task run <spec.json>`. Either way it reads an immutable
|
||||
JSON spec, and "inherit the session" is not expressible in that schema — the
|
||||
isolation is structural, not a request.
|
||||
|
||||
**Choose the lowest rung that can do the job:**
|
||||
|
||||
- **`fork` (L4)** when the subtask only makes sense against this conversation,
|
||||
when you want several independent opinions in parallel from one message, or for
|
||||
read-only exploration whose detail you will discard. Everything in "Boundary
|
||||
discipline" above applies in full.
|
||||
- **`pi-task` (L0–L2)** when the brief contains a **prohibition** (the inherited
|
||||
transcript is exactly what overrides those), when you want a **pass/fail**
|
||||
result instead of prose, when you need an **audit trail**, or when writes
|
||||
outside an authorised set must be caught.
|
||||
- **Neither** for trivial work, iterative work (both are one-shot), or judgement
|
||||
that needs context only you have.
|
||||
|
||||
**What `pi-task` gets you that no brief can.** The envelope must parse or the run
|
||||
FAILED, however fluent the prose. `roots[]` is the WATCHED set and
|
||||
`write_allowed` the CHANGEABLE subset, diffed before and after with git
|
||||
`--porcelain --ignored`. That `--ignored` flag is load-bearing: in the T4 test the
|
||||
child obeyed its brief perfectly and still tripped the detector, because
|
||||
`py_compile` wrote `__pycache__` into a watched-but-not-writable root — a
|
||||
gitignored path that plain `--porcelain` reports as clean. Note the structural
|
||||
point that test exposed: under `read_only: true` a write is *defiance*, so a
|
||||
well-behaved child never produces a delta and the detector is never exercised.
|
||||
Splitting WATCHED from WRITABLE is what lets an **obedient** child reveal a
|
||||
violation, which is the realistic hazard.
|
||||
|
||||
**What it does not fix.** `--no-extensions` removes extensions, not the core
|
||||
`read`/`write`/`edit`/`bash` tools — exactly as described above — so the boundary
|
||||
diff is post-hoc **detection, not prevention**. And a fresh L0 context removes the
|
||||
*narrative* failures (parent voice, invented continuity) without removing
|
||||
confabulation: given an under-specified spec built on a false premise, the child
|
||||
still filled the `deliverable` slot with a confident shape. The envelope's own
|
||||
structure creates that pressure. Verify decisive claims from the filesystem
|
||||
regardless of which rung you used.
|
||||
|
||||
**Trap — the capability floor is inverted from intuition.** `runner.ts:188` reads
|
||||
`if (extensions !== null) args.push("--no-extensions")`. So `pi-fork.extensions:
|
||||
[]` passes the flag and the floor is **on**; setting it to `null` — documented in
|
||||
`settings.json` as the way to "restore normal extension loading" — passes nothing
|
||||
and the floor is **off**, restoring palace writes inside every fork child.
|
||||
Changing `[]` to `null` as a tidy-up re-arms what was deliberately disarmed.
|
||||
`pi-task` hardcodes the flag and cannot drift this way.
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- **Forking trivial work.** A fork has overhead. If the task takes < 30 seconds in your main thread, just do it.
|
||||
- **Vague briefs.** "Look into the database thing" returns vague output. The fork is not telepathic.
|
||||
- **Forking iterative work.** Forks are one-shot. If you need to iterate, you'll re-spec the task each time — usually worse than doing it yourself.
|
||||
- **Recursive forking** (forks spawning forks). Disabled by default and should stay disabled unless you have a specific batch-fanout use case.
|
||||
- **Treating fork output as ground truth without verification.** Especially for cited code/commit hashes/URLs — forks can hallucinate these like any LLM. Spot-check decisive evidence.
|
||||
|
||||
**Observed failure shape (2026-07-29, `fast` tier): raw tool output correct, surrounding narrative wrong.** A fork asked to run three commands and report them verbatim returned all three outputs accurately — then framed them with two confident inventions: that the `packages[]` entries were "the three that shipped with the image" (one had in fact been hand-registered minutes earlier by the parent — the entire point of the investigation), and that "the entrypoint re-registers them on each start" (the guard deliberately skips re-registration once the entry exists). Neither claim was in the command output; both were plausible glue.
|
||||
|
||||
**Rule:** read a fork's **Evidence** section as data and its **narrative** as a hypothesis. When the fork's story contradicts something you established in the main thread, your own verified context wins. Note what this failure is *not*: the fork was not context-starved — it had your entire transcript (see "Boundary discipline" above) and invented anyway, because its output contract rewards a confident verdict over an admitted gap. Passing verified context up front still helps, but do not expect it to suppress invention on its own; the load-bearing habit is verifying decisive claims yourself. Being right about the evidence is not the same as being right.
|
||||
|
||||
---
|
||||
|
||||
## Part 2: pi-observational-memory
|
||||
|
||||
### How it actually works
|
||||
|
||||
Observational memory (OM v3, "session-ledger" architecture) runs an **observer agent** in the background as your conversation grows. When token thresholds are crossed (defaults: observe at 10k, reflect at 20k, compact at 81k), the observer distills the recent transcript into:
|
||||
|
||||
- **Observations** — timestamped events, each with a 12-character hex ID like `[3682ebfad7af]`. Compact one-liners describing what happened in the conversation.
|
||||
- **Reflections** — durable, long-lived facts about the user, project, decisions, and constraints. Some reflections include observation IDs as evidence pointers.
|
||||
|
||||
When compaction fires, the raw transcript is folded away and replaced with a structured summary block containing the observations + reflections. **You — the next turn of the same agent — receive that summary block as your starting context.** That's the recovery mechanism.
|
||||
|
||||
**Storage is in-transcript, not on disk.** Do not grep for `observations.jsonl` or similar files; you will not find them. The artifact lives in the model's input context window.
|
||||
|
||||
Configuration lives in `~/.pi/agent/settings.json` under `observational-memory`. Tune `observeAfterTokens`, `reflectAfterTokens`, `compactAfterTokens`, and `observationsPoolMaxTokens` if observations feel sparse or noisy. The default 81k compaction threshold is well-calibrated for typical multi-task sessions.
|
||||
|
||||
### The `recall` tool
|
||||
|
||||
`recall(<12-char-hex-id>)` resolves a specific observation or reflection ID back to the original source context — the exact bash output, file contents, tool call results, commit message, or transcript fragment that the observation was distilled from.
|
||||
|
||||
**Use recall when:**
|
||||
- You are about to make a decision that depends materially on a compacted observation or reflection whose details are unclear.
|
||||
- You need exact wording, paths, commands, errors, commits, or user constraints behind a remembered claim.
|
||||
- A broad reflection is relevant but you need its supporting observations to act safely.
|
||||
- The user asks "why do you believe X" or "what supports that memory".
|
||||
|
||||
**Do not use recall for:**
|
||||
- Semantic search (it's keyed by ID, not topic — you must already have a specific 12-char hex ID).
|
||||
- Browsing the transcript out of curiosity.
|
||||
- Preemptive lookup of every ID in your context "just in case".
|
||||
|
||||
Recall costs tokens. Use it when exact source context will materially change your next action.
|
||||
|
||||
> **Calibration note (from a real ~1-month trial, 2026-05/06):** across 20 logged container sessions, `recall` was invoked **0 times** while obsmem passively carried 529 observations across 6 compactions. Zero recall is a *warning sign*, not a badge of efficiency — it means decisions after a compaction were made on the distilled one-liner alone, without ever re-checking the source. The injected summary is **lossy by design**. Default habit to adopt: when you are about to **edit code, ship a change, or assert a fact** that rests on a `[high]`/`[critical]` observation or a reflection you did not produce *this* turn, `recall` its ID **first**. One recall before a load-bearing action is cheap; redoing finished work or contradicting a prior correction is not.
|
||||
|
||||
### Reading the compaction summary
|
||||
|
||||
When you see a block like `The conversation history before this point was compacted into the following summary:` at the start of a session or turn, that's OM output. Standard structure:
|
||||
|
||||
- **Reflections** at the top: stable facts. Some have IDs in brackets.
|
||||
- **Observations** below, chronological: timestamped events with IDs in brackets and importance markers (`[high]`, `[critical]`, etc.).
|
||||
|
||||
When entries conflict, **the most recent observation reflects the latest known state.** Work that prior observations describe as completed should not be redone unless the user explicitly asks to revisit it.
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- **Treating compacted memory as definitive without recall** when stakes are high. Compaction is lossy; the observation may have lost a constraint that was on the line above it in the original transcript.
|
||||
- **Recalling every ID preemptively.** Wasteful. Recall on demand.
|
||||
- **Assuming the disk holds OM artifacts.** It doesn't. Don't waste time looking.
|
||||
- **Ignoring the summary block** when starting a session. It's there because the prior session was real work — read it before answering questions about past work.
|
||||
|
||||
---
|
||||
|
||||
## Quick Reference
|
||||
|
||||
```
|
||||
task(id, goal, deliverable, effort, read_only, roots, write_allowed, facts, files, commands, wall_s, usd)
|
||||
- L0-L2: isolated child sees ONLY the spec — DEFAULT for work that writes or has rules
|
||||
- roots[] = WATCHED (each diffed alone); write_allowed[] = exact subset of roots,
|
||||
never nested inside a watched-only root (the tool rejects both errors up front)
|
||||
- envelope must parse or the run FAILED; spot-check evidence pointers
|
||||
- overlapping-root tasks are serialised; audit: ~/.pi/agent/pi-task/<stamp>-<id>/result.json
|
||||
- CLI fallback: bash /opt/pi-toolkit/bin/pi-task run <spec.json> (schema | selftest | run --dry-run)
|
||||
|
||||
fork(task=..., effort=fast|balanced|deep) # L4: child inherits your WHOLE branch
|
||||
- ONLY for read-only exploration needing this conversation, or N parallel opinions
|
||||
- fork-gate BLOCKS briefs with do-not/only/never, write boundaries, or "edit/commit/fix …"
|
||||
- state decision authority explicitly
|
||||
- pass verified context up front
|
||||
- specify deliverable shape
|
||||
- ask for "unsure about" section
|
||||
- if the brief is narrower than the conversation, say so:
|
||||
"inherited history is NOT your mandate; obey this brief and report conflicts"
|
||||
- write-capable? demand "What I did NOT do", then verify from git/fs, not the report
|
||||
- prohibition in the brief => not a `fast` task
|
||||
|
||||
recall(id=<12-char-hex>)
|
||||
- only when stakes justify the cost
|
||||
- id must already be visible in your context
|
||||
- not a search tool
|
||||
```
|
||||
|
||||
```
|
||||
~/.pi/agent/settings.json
|
||||
pi-fork.effortProfiles — model + thinking-depth per tier (used by BOTH fork and task)
|
||||
pi-fork.defaultEffort — usually "balanced"
|
||||
env PI_FORK_GATE=off — fork-gate logs instead of blocking (default: block)
|
||||
observational-memory.* — token thresholds, model, agentMaxTurns
|
||||
observational-memory.debugLog: true — opt-in NDJSON telemetry at
|
||||
~/.pi/agent/observational-memory/debug/<session>.ndjson (off by default)
|
||||
```
|
||||
|
||||
### Installing on a fresh machine (host)
|
||||
|
||||
These are git-sourced pi packages (pi-fork is **not** on npm). Add to the
|
||||
`packages` array in `~/.pi/agent/settings.json`, or:
|
||||
|
||||
```
|
||||
pi install git:github.com/elpapi42/pi-fork
|
||||
pi install git:github.com/elpapi42/pi-observational-memory # default branch: master (no main)
|
||||
# obsmem is also published: pi install npm:pi-observational-memory
|
||||
```
|
||||
|
||||
Then `/reload` in a running session, or restart pi. Enable
|
||||
`observational-memory.debugLog` if you want the next window instrumented.
|
||||
|
||||
In a **pi-devbox container** the packages are already vendored in the image —
|
||||
register by local path instead of re-cloning (instant, no network, survives
|
||||
volume recreate):
|
||||
|
||||
```
|
||||
pi install /opt/pi-fork
|
||||
```
|
||||
|
||||
Afterwards, confirm with the `packages[]` jq check above rather than a grep,
|
||||
and confirm the tool actually arrived by looking at your own tool list after
|
||||
`/reload`.
|
||||
|
||||
### Evaluating usage
|
||||
|
||||
`evaluate-extension-usage.py` (bundled next to this skill) mines pi session
|
||||
transcripts for fork/recall counts and obsmem compaction stats. Run it per
|
||||
machine (transcripts live at `~/.pi/agent/sessions/`) for a combined
|
||||
host+container picture:
|
||||
|
||||
```
|
||||
./evaluate-extension-usage.py # ~/.pi/agent/sessions
|
||||
./evaluate-extension-usage.py /path/a /path/b # multiple roots
|
||||
```
|
||||
|
||||
Read a **zero** carefully before treating it as a habit problem: a missing
|
||||
`fork <== pi-fork` line means the tool was never *called*, which can equally
|
||||
mean it was never *registered* (see the `packages[]` case study above). Check
|
||||
registration first, then blame habits.
|
||||
|
||||
---
|
||||
|
||||
## Part 3: ssh-controlmaster
|
||||
|
||||
### What it does
|
||||
|
||||
When pi is launched with `--ssh`, this extension **rewires pi's `read`, `write`, `edit`, and `bash` tools to execute on the remote machine**, multiplexed over a single SSH ControlMaster socket. Pi is still running locally — the LLM, the UI, the MCP servers, the fork dispatcher all live on your local box — but anything those tools touch on the filesystem is the *remote's* filesystem.
|
||||
|
||||
This is fundamentally different from running pi locally and using `bash` to ssh inside it: with `--ssh`, the tool layer itself is remoted, so the LLM thinks it's working in the remote's `cwd` (the system prompt is rewritten to say so).
|
||||
|
||||
### Usage
|
||||
|
||||
```bash
|
||||
# Key-based auth (preferred), remote cwd defaults to remote $HOME
|
||||
pi --ssh lagret
|
||||
|
||||
# Pin to a specific remote directory
|
||||
pi --ssh lagret:/volume1/docker/portainer/compose/119
|
||||
|
||||
# Password auth (input is NOT masked when typing)
|
||||
pi --ssh user@host --ssh-ask-pass
|
||||
```
|
||||
|
||||
The `lagret` form requires a `Host lagret` block in `~/.ssh/config` or a resolvable hostname. The status bar shows `SSH ⚡ own master <host>:<cwd>` or `SSH ⚡ system master <host>:<cwd>` once connected.
|
||||
|
||||
### How it cooperates with system SSH config
|
||||
|
||||
It reads `ssh -G <host>` to learn the effective config, then:
|
||||
|
||||
| `~/.ssh/config` for the host | Behavior |
|
||||
|---|---|
|
||||
| `ControlMaster auto` or `yes` with a `ControlPath` | Reuses the system master socket. Does **not** tear it down on pi exit ("it was the system's to manage before pi arrived"). |
|
||||
| No ControlMaster configured (or explicitly `no`) | Creates its own master at `/tmp/pi-cm-<pid>.sock` with `ControlPersist=yes`. Tears it down on pi `session_shutdown`. |
|
||||
|
||||
This means it composes cleanly with the system-wide `ssh-control-master-setup.sh` helper from the `ci-release-watcher` skill: if that script has already configured `~/.ssh/config` for the host, `pi --ssh` rides on the existing master rather than opening a parallel connection.
|
||||
|
||||
### Caveats and edge cases
|
||||
|
||||
- **Local vs remote tool boundary.** Only `read`/`write`/`edit`/`bash` are remoted. **MCP servers are still local** — `mempalace` files drawers and diary entries against the local palace even when your shell work happens remotely. Same for `fork`, `recall`, `todo`, and any other custom tool. This is usually what you want (palace memory survives across remote sessions) but worth knowing.
|
||||
- **fork over ssh.** Forks spawn locally and inherit the same `--ssh` mode by virtue of the parent's tool wiring; the fork's bash calls hit the same ControlMaster. Forks burn the same SSH socket, not a parallel one — multiplexing wins again.
|
||||
- **macOS Unix socket path limit.** The own-master socket lives at `/tmp/pi-cm-<pid>.sock` to stay under macOS's ~104-char limit. If you have a non-default `TMPDIR` long enough to blow this, ssh will fail to start the master.
|
||||
- **Password auth password visibility.** From the source: *"input is NOT masked — the password is visible while typing."* The password is written to a chmod-700 SSH_ASKPASS script in `/tmp` and deleted after the master establishes; not persisted, but on-screen during entry.
|
||||
- **Remote bash environment.** The remote shell is whatever `ssh user@host '<cmd>'` invokes — typically a non-login non-interactive bash. Don't expect `~/.bashrc` aliases or PATH manipulations from `~/.profile`. Pin tool paths or invoke via `bash -lc '...'` if you need login-shell behavior.
|
||||
- **Path translation is naive.** The extension does `path.replace(localCwd, remoteCwd)` to translate paths in tool calls. If the LLM emits an absolute remote path that doesn't share the local-cwd prefix, the path is passed through unchanged — usually fine but pathological for paths that happen to contain the local-cwd substring.
|
||||
|
||||
### When to use it
|
||||
|
||||
- Editing configs on a NAS / homelab host without scp ping-pong (`pi --ssh lagret:/volume1/...`)
|
||||
- Operating against a host whose tools/data you need but whose disk is too slow to mount via SSHFS
|
||||
- Investigating runner state, container configs, etc., on a remote host as if local
|
||||
- Multi-step remote work where opening a fresh ssh connection per step would burn your CGNAT flow budget
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- **Using `pi --ssh` for one-off shell work.** Just `ssh` directly. The extension shines when there are dozens of tool calls per session.
|
||||
- **Filing palace drawers expecting them on the remote.** They go to the local palace. If you want palace artifacts on the remote host, ssh into the remote and run pi *there* against its local palace.
|
||||
- **Forgetting `--ssh` in followup sessions.** Status bar is the canary — if you don't see `SSH ⚡` you're operating locally despite intending remote. Easy mistake on a fresh terminal.
|
||||
|
||||
### Reaching the devbox host from inside the container (`dssh` / `dscp`)
|
||||
|
||||
Distinct from `pi --ssh` above. When the **pi-devbox container** runs under OrbStack / Docker Desktop on macOS, it can SSH back to its own host. The entrypoint's `setup-lan-access.sh` regenerates `~/.ssh-local/config` on **every container start** (the in-container `~/.ssh` is mounted read-only, so a sidecar config + `known_hosts` + `ControlPath` under `~/.ssh-local/` is used instead).
|
||||
|
||||
```bash
|
||||
# Interactive shells get aliases (from ~/.bash_aliases):
|
||||
dssh host 'cmd' # = ssh -F ~/.ssh-local/config host
|
||||
dscp file host:/path # = scp -F ~/.ssh-local/config ...
|
||||
```
|
||||
|
||||
**The agent's `bash` tool is non-interactive — those aliases are NOT loaded.** Use the explicit form:
|
||||
|
||||
```bash
|
||||
ssh -F ~/.ssh-local/config host 'cmd'
|
||||
scp -F ~/.ssh-local/config <src> host:<dst>
|
||||
```
|
||||
|
||||
- Host aliases `host` and `mac` both resolve to `host.docker.internal` (user varies per host machine — check `~/.ssh-local/config` for the active `User` value, key `~/.ssh-local/devbox_jump_ed25519`, `ControlMaster auto` / `ControlPersist 4h`).
|
||||
- The config chains `Include ~/.config/devbox-shell/ssh-lan.conf` then `Include ~/.ssh/config`, so LAN targets are reachable too (add `ProxyJump host` to those entries).
|
||||
- **Use it for:** enabling/inspecting the host's pi config (`~/.pi/agent/settings.json`), running `evaluate-extension-usage.py` against the host's `~/.pi/agent/sessions/` for a combined host+container metric, or copying host transcripts into the container. The host's pi runs natively there; its palace, sessions, and extensions are separate from the container's.
|
||||
|
||||
---
|
||||
|
||||
## Cross-Skill Notes
|
||||
|
||||
- **mempalace** is for cross-session persistent memory (diary, knowledge graph, drawer storage). OM is for **within-session** context survival across compaction. They complement each other: write a diary entry at session end *and* let OM compact your work-in-progress mid-session.
|
||||
- **systematic-debugging** and **test-driven-development** skills pair well with deep-tier forks: a deep fork can carry out a focused debugging investigation or write a failing test suite without polluting your main context.
|
||||
- **ci-release-watcher** ships a `scripts/ssh-control-master-setup.sh` helper that configures system-wide SSH ControlMaster in `~/.ssh/config`. That's a separate mechanism from the `ssh-controlmaster` pi extension — they compose, they don't overlap. Use the script for persistent host-wide multiplexing, the extension for per-pi-session remote operation.
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Evaluate pi-fork / pi-observational-memory usage from pi session transcripts.
|
||||
|
||||
Mines pi's session .jsonl transcripts and reports:
|
||||
- per-tool call counts (highlighting `fork` and `recall`)
|
||||
- per-session fork/recall breakdown
|
||||
- obsmem passive activity: compaction events, observations carried,
|
||||
relevance-tier distribution, tokensBefore
|
||||
|
||||
Works on any machine. Point it at one or more session roots; by default it
|
||||
scans ~/.pi/agent/sessions (the standard pi location, host or container).
|
||||
|
||||
Usage:
|
||||
./evaluate-extension-usage.py # ~/.pi/agent/sessions
|
||||
./evaluate-extension-usage.py /path/to/sessions ... # explicit roots
|
||||
./evaluate-extension-usage.py --host HOST /path ... # label a root (for combined host+container runs)
|
||||
|
||||
For a true host+container picture, run once per machine (or copy each
|
||||
machine's ~/.pi/agent/sessions here) and pass all roots together.
|
||||
"""
|
||||
import json, sys, os, glob, re, collections, argparse
|
||||
|
||||
TIER_RE = re.compile(r'\[(low|medium|high|critical)\]')
|
||||
OBS_LINE_RE = re.compile(r'^\[[0-9a-f]{12}\] ', re.M)
|
||||
|
||||
|
||||
def walk_tools(x, counter):
|
||||
if isinstance(x, dict):
|
||||
tn = x.get("toolName")
|
||||
if tn:
|
||||
counter[tn] += 1
|
||||
for v in x.values():
|
||||
walk_tools(v, counter)
|
||||
elif isinstance(x, list):
|
||||
for v in x:
|
||||
walk_tools(v, counter)
|
||||
|
||||
|
||||
def analyze(roots):
|
||||
files = []
|
||||
for r in roots:
|
||||
if os.path.isfile(r) and r.endswith(".jsonl"):
|
||||
files.append(r)
|
||||
else:
|
||||
files += glob.glob(os.path.join(r, "**", "*.jsonl"), recursive=True)
|
||||
files = sorted(set(files))
|
||||
|
||||
tool_total = collections.Counter()
|
||||
per_session = []
|
||||
compactions = []
|
||||
for f in files:
|
||||
tc = collections.Counter()
|
||||
with open(f, errors="ignore") as fh:
|
||||
for ln in fh:
|
||||
ln = ln.strip()
|
||||
if not ln:
|
||||
continue
|
||||
try:
|
||||
o = json.loads(ln)
|
||||
except Exception:
|
||||
continue
|
||||
walk_tools(o, tc)
|
||||
if o.get("type") == "compaction":
|
||||
s = o.get("summary", "") or ""
|
||||
compactions.append({
|
||||
"file": os.path.basename(f),
|
||||
"tokensBefore": o.get("tokensBefore"),
|
||||
"observations": len(OBS_LINE_RE.findall(s)),
|
||||
"tiers": dict(collections.Counter(TIER_RE.findall(s))),
|
||||
})
|
||||
tool_total.update(tc)
|
||||
per_session.append((os.path.basename(f)[:10], tc.get("fork", 0),
|
||||
tc.get("recall", 0), sum(tc.values())))
|
||||
return files, tool_total, per_session, compactions
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("roots", nargs="*",
|
||||
default=[os.path.expanduser("~/.pi/agent/sessions")])
|
||||
args = ap.parse_args()
|
||||
|
||||
files, tool_total, per_session, comp = analyze(args.roots)
|
||||
if not files:
|
||||
print("No .jsonl transcripts found under:", args.roots, file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
print(f"=== {len(files)} transcripts under {args.roots} ===\n")
|
||||
print("Tool call totals:")
|
||||
for t, c in tool_total.most_common():
|
||||
mark = " <== pi-fork" if t == "fork" else (" <== obsmem recall" if t == "recall" else "")
|
||||
print(f" {c:6d} {t}{mark}")
|
||||
|
||||
fk = tool_total["fork"]; rc = tool_total["recall"]
|
||||
fk_sess = sum(1 for p in per_session if p[1])
|
||||
rc_sess = sum(1 for p in per_session if p[2])
|
||||
print(f"\npi-fork: {fk} calls across {fk_sess} sessions")
|
||||
print(f"recall: {rc} calls across {rc_sess} sessions"
|
||||
+ (" (!) zero recall over the window — see SKILL.md calibration note" if rc == 0 else ""))
|
||||
|
||||
if comp:
|
||||
tot_obs = sum(c["observations"] for c in comp)
|
||||
tb = [c["tokensBefore"] for c in comp if c["tokensBefore"]]
|
||||
print(f"\nobsmem passive: {len(comp)} compactions, {tot_obs} observations carried"
|
||||
+ (f", avg tokensBefore {sum(tb)//len(tb):,}" if tb else ""))
|
||||
agg = collections.Counter()
|
||||
for c in comp:
|
||||
agg.update(c["tiers"])
|
||||
if agg:
|
||||
print(" relevance tiers:", dict(agg))
|
||||
else:
|
||||
print("\nobsmem passive: no compaction events found "
|
||||
"(short sessions, or obsmem not active on these transcripts)")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,16 @@
|
||||
# Skills in this directory whose OWNER is the skillset repo.
|
||||
#
|
||||
# Read by devbox-skill-reconcile, which runs after the skillset deploy in
|
||||
# entrypoint-user.sh: for each name below, if the mounted skillset ships a
|
||||
# skill of that name, the baked link in ~/.agents/skills/ is repointed at the
|
||||
# live clone. The baked copy remains the fallback for containers started
|
||||
# WITHOUT a skillset mount, and a user override always wins over both.
|
||||
#
|
||||
# Add a name here ONLY if the skillset repo is the authoritative source (see
|
||||
# the ownership table in VENDORED.md). Do NOT add:
|
||||
# pi-devbox-environment — authored in this repo; baked IS canonical
|
||||
# pi-extensions — owned by the pi-extensions package repo and copied
|
||||
# over the snapshot at build time; the skillset copy
|
||||
# is a downstream duplicate that can lag, so letting
|
||||
# it win would regress the skill.
|
||||
mempalace
|
||||
@@ -0,0 +1,14 @@
|
||||
# xterm-ghostty — alias of the maintained ncurses `ghostty` terminfo entry.
|
||||
#
|
||||
# Ghostty sets TERM=xterm-ghostty by default, but the ncurses terminfo
|
||||
# database (Debian: ncurses-term) ships the entry under the name `ghostty`
|
||||
# only — there is no `xterm-ghostty` alias, and no distro packages one. This
|
||||
# thin alias makes xterm-ghostty resolve to the same upstream-maintained
|
||||
# capability set, so SSH sessions from a Ghostty terminal work without
|
||||
# vendoring Ghostty's full (Zig-generated) terminfo here.
|
||||
#
|
||||
# `use=ghostty` is resolved by `tic` at compile time against the base
|
||||
# `ghostty` entry from ncurses-term (installed in Dockerfile.base before the
|
||||
# compile step). Compiled with `tic -x`.
|
||||
xterm-ghostty|Ghostty terminal emulator (xterm-ghostty alias),
|
||||
use=ghostty,
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-base-hash.sh — guard the base-rebuild invariant.
|
||||
#
|
||||
# Every floating `ARG *_REF` consumed by Dockerfile.base MUST be folded
|
||||
# into the base_tag hash in the docker-publish workflow. Otherwise a
|
||||
# ref-only change to that dependency does not change the base hash, the
|
||||
# Docker Hub probe finds the old base tag, and the base is NOT rebuilt —
|
||||
# the dependency fix silently fails to land. This is the v1.1.2-class
|
||||
# staleness footgun (then it was mempalace-toolkit; this guard stops the
|
||||
# next one before it ships).
|
||||
#
|
||||
# Runs in CI (base-decide job) and locally: bash scripts/check-base-hash.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
WF=".gitea/workflows/docker-publish.yml"
|
||||
DF="Dockerfile.base"
|
||||
|
||||
# Extract the hash-compute block: the `HASH=$( … ) | sha256sum | cut`
|
||||
# brace-group in the "Compute base tag" step. This lives in a separate
|
||||
# file from the workflow, so scanning $WF here is free of the self-match
|
||||
# hazard an inline workflow step would have.
|
||||
block=$(awk '/HASH=\$\(/{f=1} f{print} f && /cut -c1-12/{exit}' "$WF")
|
||||
if [ -z "$block" ]; then
|
||||
echo "::error::could not locate the HASH=\$( … ) | sha256sum block in $WF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
refs=$(grep -oE '^ARG [A-Z0-9_]+_REF' "$DF" | awk '{print $2}' | sort -u)
|
||||
fail=0
|
||||
for r in $refs; do
|
||||
lc=$(printf '%s' "$r" | tr '[:upper:]' '[:lower:]')
|
||||
if ! printf '%s' "$block" | grep -q "outputs.$lc"; then
|
||||
echo "::error::Dockerfile.base declares '$r' but it is NOT folded into the base_tag hash in $WF."
|
||||
echo "::error::Add echo \"\${{ needs.resolve-versions.outputs.$lc }}\" inside the HASH=\$( … ) | sha256sum block, or a $r-only change will silently fail to rebuild the base."
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$fail" = 0 ]; then
|
||||
echo "OK: all Dockerfile.base *_REF args are folded into base_tag (${refs:-none})."
|
||||
fi
|
||||
exit $fail
|
||||
Executable
+698
@@ -0,0 +1,698 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-doc-drift.sh — fail when a hand-maintained doc claim contradicts the
|
||||
# build files it describes.
|
||||
#
|
||||
# THE DEFECT CLASS THIS EXISTS TO CATCH, measured 2026-09-10 while preparing
|
||||
# v1.9.0. Five separate claims had rotted, all of them the same shape: a fact
|
||||
# written once by hand, in a file nothing verifies, about a value that lives
|
||||
# somewhere else and moved.
|
||||
#
|
||||
# 1..3. README.md's "Version pins" table was wrong on EVERY row — pi `0.84.4`
|
||||
# vs ARG PI_VERSION=0.85.1, pi-atelier `v0.10.0` vs v0.10.1, mempalace
|
||||
# `3.8.0` vs 3.9.0. That table is the worst possible place for this: it
|
||||
# exists precisely to be the reviewable record of what is deliberately
|
||||
# frozen, so when it lies, the review it enables is worthless.
|
||||
# 4. README.md carried a "Planned for an upcoming minor release" section
|
||||
# listing typst PDF export, which had ALREADY SHIPPED, tagged with a
|
||||
# self-contradicting "(shipped in Unreleased/base)" marker. The
|
||||
# CHANGELOG had already documented three earlier instances of exactly
|
||||
# this stale-"Unreleased"-pointer class (see its v1.8.7 notes).
|
||||
# 5. DOCKER_HUB.md claimed "Node.js v22" while this release ships Node 24.
|
||||
# This one is the reason the gate exists at all: DOCKER_HUB.md is
|
||||
# PUBLISHED. `update-description` in docker-publish.yml POSTs it to Hub
|
||||
# as full_description on every tag, so unlike README.md — which no
|
||||
# workflow or gate reads — a stale claim here is what users see.
|
||||
#
|
||||
# WHY A GATE AND NOT "REMEMBER TO CHECK". DOCKER_HUB.md had gone eight releases
|
||||
# (v1.8.6 → v1.9.0) without a touch. Nothing generates it and nothing verifies
|
||||
# it; the only mechanism keeping it true was whoever remembered. That is the
|
||||
# same failure mode check-skill-floor.sh was written for, and the same fix:
|
||||
# convert "someone remembers" into "CI refuses".
|
||||
#
|
||||
# TWO CLASSES OF CHECK, DELIBERATELY. Checks 1-7 compare a doc string to a
|
||||
# value that EXISTS IN THIS REPO, so they can never be wrong about the world and
|
||||
# need no network, no token, and no built image. Checks 8-9 compare against what
|
||||
# is PUBLISHED (Docker Hub's measured sizes; the ref labels baked into the last
|
||||
# released image), because those claims have no in-repo anchor at all and had
|
||||
# rotted for exactly that reason. They need the network and therefore SKIP,
|
||||
# loudly and counted, when it is absent -- a skip is neither OK nor a failure,
|
||||
# because printing an unverified claim as OK is the habit this file exists to
|
||||
# break, while failing on a third party's uptime would make every release
|
||||
# hostage to it. Claims that need a RUNNING CONTAINER (the "N mempalace_* tools"
|
||||
# count, uncompressed on-disk sizes) are still not gated here; assert them in
|
||||
# scripts/smoke-test.sh where a real image is available.
|
||||
#
|
||||
# DELIBERATELY NOT GATED: Dockerfile.base's `# BASE_REBUILD_DATE:` comment, which
|
||||
# is also stale (2026-07-13, three base rebuilds ago). base_tag is a hash of
|
||||
# Dockerfile.base's CONTENT plus rootfs/, comments included, so a gate that
|
||||
# demanded that comment be current would force a ~60 min base rebuild on any
|
||||
# release that touched no base files at all. Fix it when you are already
|
||||
# rebuilding the base — then it is free. This is a real cost asymmetry, not
|
||||
# laziness.
|
||||
#
|
||||
# EXIT CODES (same contract as lint-shell.sh and check-skill-floor.sh):
|
||||
# 0 every checked claim matches
|
||||
# 1 at least one claim has drifted
|
||||
# 2 cannot run (a file or ARG this gate reads is missing/unparseable)
|
||||
# A gate that cannot run must not pass, so a missing input is 2, never 0.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
README="README.md"
|
||||
HUB="DOCKER_HUB.md"
|
||||
DF_VARIANT="Dockerfile.variant"
|
||||
DF_BASE="Dockerfile.base"
|
||||
|
||||
# Docker Hub rejects a full_description longer than this. docker-publish.yml has
|
||||
# no size check of its own; it only notices via a non-200 from the API, i.e.
|
||||
# after paying the whole build. Catching it here makes it a 2-second failure.
|
||||
HUB_MAX_CHARS=25000
|
||||
|
||||
WARN_ONLY=0
|
||||
FAILURES=0
|
||||
SKIPS=0
|
||||
|
||||
# Tolerance for the published size claims (check 8), as a percentage OF THE
|
||||
# MEASURED SIZE. The denominator matters: against the claim instead, the same
|
||||
# drift reads as a different number, and an early draft of this gate took 20%
|
||||
# from the claim-relative figure and would therefore have MISSED its own
|
||||
# motivating case. Both bounds are measured, not guessed:
|
||||
# - the rot that motivated this check: claimed 1.1 GB vs measured 1.37 GB
|
||||
# = 19.7% off, so the threshold must sit BELOW that or the gate is theatre.
|
||||
# - the largest legitimate skew, i.e. a claim describing the currently-published
|
||||
# release while the next tag changes the size: v1.9.1's 1.37 GB against
|
||||
# v1.9.2's measured 1.23 GB = 11.4% off, so the threshold must sit ABOVE that
|
||||
# or every size-changing release trips it.
|
||||
# 15% sits in that 11.4%-19.7% window. Widen it only with a measured reason, and
|
||||
# re-derive both bounds if you do.
|
||||
SIZE_TOLERANCE_PCT="${SIZE_TOLERANCE_PCT:-15}"
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: check-doc-drift.sh [--warn-only] [-h|--help]
|
||||
|
||||
Compares hand-written claims in README.md and DOCKER_HUB.md against the build
|
||||
files they describe (Dockerfile.base, Dockerfile.variant).
|
||||
|
||||
--warn-only Report drift but exit 0 (advisory use, e.g. a local pre-push hook).
|
||||
|
||||
Environment:
|
||||
SKIP_SIZE_CHECK=1 skip check 8 (published size claims vs Docker Hub)
|
||||
SKIP_REF_CHECK=1 skip check 9 (refs moved since the last release are named)
|
||||
SIZE_TOLERANCE_PCT check 8 tolerance, default 15 (see comment for its bounds)
|
||||
|
||||
Exit: 0 = in sync, 1 = drift, 2 = cannot run.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--warn-only) WARN_ONLY=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "::error::unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
for f in "$README" "$HUB" "$DF_VARIANT" "$DF_BASE"; do
|
||||
if [ ! -f "$f" ]; then
|
||||
echo "::error::$f not found (cwd $PWD). Cannot evaluate doc drift, so this is exit 2, not a pass."
|
||||
exit 2
|
||||
fi
|
||||
done
|
||||
|
||||
# Read `ARG NAME=value` from a Dockerfile. Exit 2 when absent: if the ARG this
|
||||
# gate is built around has been renamed, the gate is measuring nothing and must
|
||||
# say so rather than silently comparing against an empty string.
|
||||
read_arg() {
|
||||
local file="$1" name="$2" value
|
||||
value="$(sed -n "s/^ARG ${name}=\\(.*\\)\$/\\1/p" "$file" | head -1)"
|
||||
if [ -z "$value" ]; then
|
||||
echo "::error::ARG ${name} not found in ${file}. It was probably renamed;" >&2
|
||||
echo "::error::update check-doc-drift.sh to match, because this gate is now blind." >&2
|
||||
exit 2
|
||||
fi
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
# One row of README's "Version pins" table: `| pi | `0.85.1` | ... |`
|
||||
read_pin_row() {
|
||||
sed -n "s/^| $1 | \`\\([^\`]*\`*\\)\` |.*/\\1/p" "$README" | head -1
|
||||
}
|
||||
|
||||
fail() {
|
||||
FAILURES=$((FAILURES + 1))
|
||||
echo "::error::$1"
|
||||
}
|
||||
|
||||
ok() { printf ' OK %s\n' "$1"; }
|
||||
|
||||
# A check that could not be EVALUATED, as distinct from one that passed.
|
||||
# Deliberately neither ok() nor fail(): printing it as OK would launder an
|
||||
# unmeasured claim into a passing one (the exact habit this file exists to
|
||||
# break), while failing on a third party's uptime would make every release
|
||||
# hostage to Docker Hub's API. Loud, counted, and surfaced in the summary.
|
||||
skip() { SKIPS=$((SKIPS + 1)); printf ' SKIP %s\n' "$1"; }
|
||||
|
||||
echo "Checking hand-maintained doc claims against the build files they describe."
|
||||
echo
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1-3. README's version-pin table vs the ARGs it names by name.
|
||||
# ---------------------------------------------------------------------------
|
||||
check_pin() {
|
||||
local label="$1" documented="$2" actual="$3" where="$4"
|
||||
if [ -z "$documented" ]; then
|
||||
fail "README.md: no '| $label |' row found in the version-pin table. Either the
|
||||
table was restructured (update this gate) or the row was dropped (restore it)."
|
||||
return
|
||||
fi
|
||||
if [ "$documented" != "$actual" ]; then
|
||||
fail "README.md version-pin table is stale for $label: says '$documented',
|
||||
$where says '$actual'. Fix the table — it is the reviewable record of what
|
||||
this repo deliberately freezes, so a wrong row defeats its only purpose."
|
||||
return
|
||||
fi
|
||||
ok "README pin $label = $actual"
|
||||
}
|
||||
|
||||
PI_ACTUAL="$(read_arg "$DF_VARIANT" PI_VERSION)"
|
||||
ATELIER_ACTUAL="$(read_arg "$DF_VARIANT" PI_ATELIER_REF)"
|
||||
MEMPALACE_ACTUAL="$(read_arg "$DF_BASE" MEMPALACE_VERSION)"
|
||||
|
||||
check_pin pi "$(read_pin_row pi)" "$PI_ACTUAL" "ARG PI_VERSION in $DF_VARIANT"
|
||||
check_pin pi-atelier "$(read_pin_row pi-atelier)" "$ATELIER_ACTUAL" "ARG PI_ATELIER_REF in $DF_VARIANT"
|
||||
check_pin mempalace "$(read_pin_row mempalace)" "$MEMPALACE_ACTUAL" "ARG MEMPALACE_VERSION in $DF_BASE"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 4. DOCKER_HUB.md's Node claim vs ARG NODE_VERSION. This is the published page,
|
||||
# so it is the one whose staleness reaches users.
|
||||
# ---------------------------------------------------------------------------
|
||||
NODE_ACTUAL="$(read_arg "$DF_BASE" NODE_VERSION)"
|
||||
NODE_DOCUMENTED="$(sed -n 's/.*\*\*Node\.js\*\* v\([0-9][0-9]*\).*/\1/p' "$HUB" | head -1)"
|
||||
if [ -z "$NODE_DOCUMENTED" ]; then
|
||||
fail "$HUB: could not find a '**Node.js** vNN' claim. If the wording changed,
|
||||
update this gate; do not leave the published page unverified."
|
||||
elif [ "$NODE_DOCUMENTED" != "$NODE_ACTUAL" ]; then
|
||||
fail "$HUB claims Node v$NODE_DOCUMENTED but ARG NODE_VERSION=$NODE_ACTUAL.
|
||||
This file is PUBLISHED to Docker Hub by update-description on every tag,
|
||||
and it is read from the TAG — so fix it before tagging, not after."
|
||||
else
|
||||
ok "$HUB Node claim = v$NODE_ACTUAL"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Placeholders CI will not substitute. docker-publish.yml substitutes exactly
|
||||
# {{PI_VERSION}} and then greps for leftovers of that ONE token, so any other
|
||||
# {{...}} sails through the guard and is published literally.
|
||||
# ---------------------------------------------------------------------------
|
||||
UNKNOWN_PLACEHOLDERS="$(grep -o '{{[A-Za-z0-9_]*}}' "$HUB" | sort -u | grep -v '^{{PI_VERSION}}$' || true)"
|
||||
if [ -n "$UNKNOWN_PLACEHOLDERS" ]; then
|
||||
fail "$HUB contains placeholders CI does not substitute, which would be
|
||||
published verbatim: $(echo "$UNKNOWN_PLACEHOLDERS" | tr '\n' ' ')
|
||||
docker-publish.yml only fills {{PI_VERSION}}; add substitution there first."
|
||||
else
|
||||
ok "$HUB has no placeholders beyond {{PI_VERSION}}"
|
||||
fi
|
||||
|
||||
# Match only the UPPER_SNAKE placeholder convention CI uses. A bare '{{' search
|
||||
# is WRONG here, and the first version of this check proved it by failing on
|
||||
# README.md:900 — `docker inspect --format '{{json .Config.Labels}}'`, a Go
|
||||
# template in a legitimate example, not a placeholder. The gate was wrong, not
|
||||
# the doc. Keep this anchored to [A-Z] so Go/Jinja/Handlebars examples pass.
|
||||
README_PLACEHOLDERS="$(grep -o '{{[A-Z][A-Z0-9_]*}}' "$README" | sort -u || true)"
|
||||
if [ -n "$README_PLACEHOLDERS" ]; then
|
||||
fail "$README contains placeholder(s) nothing substitutes, so they would render
|
||||
literally for every reader: $(echo "$README_PLACEHOLDERS" | tr '\n' ' ')
|
||||
Only DOCKER_HUB.md gets substitution, and only for {{PI_VERSION}}."
|
||||
else
|
||||
ok "$README has no unsubstituted placeholders"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 6. Hub full_description length.
|
||||
# ---------------------------------------------------------------------------
|
||||
HUB_CHARS="$(wc -c < "$HUB" | tr -d ' ')"
|
||||
if [ "$HUB_CHARS" -gt "$HUB_MAX_CHARS" ]; then
|
||||
fail "$HUB is $HUB_CHARS chars, over Docker Hub's $HUB_MAX_CHARS-char
|
||||
full_description limit. update-description would fail with a non-200 AFTER
|
||||
the full build. Trim it — this file is the essentials-only page, and
|
||||
README.md is the long form on purpose."
|
||||
else
|
||||
ok "$HUB is $HUB_CHARS chars (limit $HUB_MAX_CHARS)"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7. Stale "Unreleased" pointers. "Unreleased" is a CHANGELOG-only concept; in
|
||||
# a user-facing doc it is always a pointer that outlived what it pointed at.
|
||||
# This class has now bitten five times, hence a gate rather than vigilance.
|
||||
# ---------------------------------------------------------------------------
|
||||
STALE_MARKERS="$(grep -n 'Unreleased' "$README" "$HUB" || true)"
|
||||
if [ -n "$STALE_MARKERS" ]; then
|
||||
fail "'Unreleased' appears in a user-facing doc, which is always a stale
|
||||
pointer once the thing ships (it has happened five times here):
|
||||
${STALE_MARKERS//$'\n'/$'\n' }
|
||||
State the fact directly, or move it to CHANGELOG.md where 'Unreleased' means something."
|
||||
else
|
||||
ok "no stale 'Unreleased' pointers in $README or $HUB"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 8. Published size claims vs Docker Hub's MEASURED full_size.
|
||||
#
|
||||
# Why this exists: every other claim in these docs is checked against a file
|
||||
# in this repo, so it cannot rot without someone editing the thing it
|
||||
# describes. The size claims had no such anchor -- nothing in the repo states
|
||||
# the image size -- so they quietly went 24% wrong across eight releases
|
||||
# (DOCKER_HUB.md said ~1.1 GB; :latest measured 1.37 GB on 2026-09-14).
|
||||
# DOCKER_HUB.md is POSTed to Docker Hub by update-description, so that number
|
||||
# is the first thing a stranger reads about this image.
|
||||
#
|
||||
# Hub's `full_size` tracks the FIRST manifest entry (amd64 here), NOT the sum
|
||||
# across architectures -- measured: v1.9.2 full_size=1.228 GB, amd64=1.228,
|
||||
# arm64=1.211, sum=2.439. That matches the table's per-arch "Size
|
||||
# (compressed)" column, which is why full_size is the right field.
|
||||
#
|
||||
# NOT COVERED, deliberately: README.md's ~3.2 GB figures are UNCOMPRESSED
|
||||
# on-disk sizes, and the registry API exposes compressed sizes only (layer
|
||||
# sizes in a manifest are compressed; the config blob carries no uncompressed
|
||||
# totals). Measuring them needs a real pull, so they are out of scope here --
|
||||
# do not read a green check 8 as covering them.
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared by checks 8 and 9: which Hub repo, and its tag list (one request).
|
||||
# Derive the repo from the doc's own rows rather than hardcoding it, so a
|
||||
# rename cannot leave these checks silently probing a repo nobody publishes to.
|
||||
# shellcheck disable=SC2016 # single quotes are deliberate: this is a sed
|
||||
# script, and its \( \) groups and \1 backreference must reach sed unexpanded.
|
||||
HUB_REPO_PATH="$(sed -n 's/^| `\([^:`]*\):[^`]*`.*/\1/p' "$HUB" | head -1)"
|
||||
HUB_TAGS_JSON=""
|
||||
HAVE_NET_TOOLS=0
|
||||
if command -v curl >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then
|
||||
HAVE_NET_TOOLS=1
|
||||
if [ -n "$HUB_REPO_PATH" ] && \
|
||||
{ [ "${SKIP_SIZE_CHECK:-0}" != "1" ] || [ "${SKIP_REF_CHECK:-0}" != "1" ]; }; then
|
||||
HUB_TAGS_JSON="$(curl -sS -m 20 \
|
||||
"https://hub.docker.com/v2/repositories/${HUB_REPO_PATH}/tags/?page_size=100" \
|
||||
2>/dev/null || true)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "${SKIP_SIZE_CHECK:-0}" = "1" ]; then
|
||||
skip "size claims -- SKIP_SIZE_CHECK=1 was set"
|
||||
elif [ "$HAVE_NET_TOOLS" = 0 ]; then
|
||||
skip "size claims -- need both curl and python3 to measure them"
|
||||
else
|
||||
if [ -z "$HUB_REPO_PATH" ]; then
|
||||
skip "size claims -- found no \`repo:tag\` image rows in $HUB to check"
|
||||
else
|
||||
if [ -z "$HUB_TAGS_JSON" ]; then
|
||||
skip "size claims -- Docker Hub API unreachable (offline?); NOT verified"
|
||||
else
|
||||
SIZE_RC=0
|
||||
# NO `|| true` on the python invocation: an early draft had one, and it
|
||||
# swallowed the exit code so a printed DRIFT line still exited 0 -- a gate
|
||||
# that reports the defect and passes anyway. The outer `|| SIZE_RC=$?` is
|
||||
# what keeps `set -e` happy while preserving the code.
|
||||
SIZE_OUT="$(HUB_MD="$HUB" HUB_JSON="$HUB_TAGS_JSON" TOL="$SIZE_TOLERANCE_PCT" \
|
||||
python3 <<'PYEOF'
|
||||
import json, os, re, sys
|
||||
|
||||
try:
|
||||
data = json.loads(os.environ["HUB_JSON"])
|
||||
except (ValueError, KeyError) as exc:
|
||||
print(" SKIP size claims -- Hub API returned unparseable JSON (%s)" % exc)
|
||||
sys.exit(3)
|
||||
|
||||
# full_size == first manifest entry (amd64), which is the per-arch number the
|
||||
# table's "Size (compressed)" column claims. Verified against .images[] sizes.
|
||||
sizes = {
|
||||
r["name"]: r["full_size"] / 1e9
|
||||
for r in data.get("results", [])
|
||||
if isinstance(r.get("full_size"), int) and r.get("name")
|
||||
}
|
||||
if not sizes:
|
||||
print(" SKIP size claims -- Hub API returned no usable tags")
|
||||
sys.exit(3)
|
||||
|
||||
tol = float(os.environ["TOL"])
|
||||
row = re.compile(r"^\|\s*`([^`:]+):([^`]+)`\s*\|[^|]*\|\s*~?([0-9]+(?:\.[0-9]+)?)\s*GB\s*\|")
|
||||
checked = drift = 0
|
||||
|
||||
with open(os.environ["HUB_MD"], encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
m = row.match(line)
|
||||
if not m:
|
||||
continue # rows saying "same", and every non-image row
|
||||
_repo, tag, claimed = m.group(1), m.group(2), float(m.group(3))
|
||||
if "X.Y.Z" in tag:
|
||||
continue # placeholder row; the concrete tag is checked instead
|
||||
# base-<hash> is content-addressed and immutable, so its size is
|
||||
# base-latest's by construction -- probe the alias that always exists.
|
||||
probe = "base-latest" if tag.startswith("base-") else tag
|
||||
actual = sizes.get(probe)
|
||||
if actual is None:
|
||||
print(" SKIP size %s -- tag '%s' not present on Hub" % (tag, probe))
|
||||
continue
|
||||
checked += 1
|
||||
off = abs(claimed - actual) / actual * 100
|
||||
if off <= tol:
|
||||
print(" OK size %s claims ~%.2f GB, Hub measures %.2f GB (%.0f%% off)"
|
||||
% (tag, claimed, actual, off))
|
||||
else:
|
||||
drift += 1
|
||||
print(" DRIFT size %s claims ~%.2f GB but Hub measures %.2f GB"
|
||||
" (%.0f%% off, tolerance %.0f%%)" % (tag, claimed, actual, off, tol))
|
||||
|
||||
if checked == 0:
|
||||
print(" SKIP size claims -- no checkable rows resolved to a published tag")
|
||||
sys.exit(3)
|
||||
sys.exit(1 if drift else 0)
|
||||
PYEOF
|
||||
)" || SIZE_RC=$?
|
||||
printf '%s\n' "$SIZE_OUT"
|
||||
case "$SIZE_RC" in
|
||||
0) : ;;
|
||||
3) SKIPS=$((SKIPS + 1)) ;;
|
||||
*)
|
||||
fail "a published size claim in $HUB has drifted from what Docker Hub
|
||||
actually serves (see DRIFT above). This page is POSTed to Docker Hub by
|
||||
update-description, so it is the first size a stranger sees. Re-measure and
|
||||
update the table:
|
||||
curl -sS 'https://hub.docker.com/v2/repositories/${HUB_REPO_PATH}/tags/?page_size=100' |
|
||||
jq -r '.results[] | \"\\(.name) \\(.full_size/1e9)\"'"
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 9. Everything the NEXT build would bake differently from the LAST PUBLISHED
|
||||
# release must be named in the CHANGELOG text above that release's heading.
|
||||
#
|
||||
# Why this exists, measured 2026-09-19: pi-extensions 25c1265 (a new `task`
|
||||
# tool and a hook that blocks certain `fork` calls -- a change to how every
|
||||
# agent in the container delegates work) and mempalace-toolkit 817b3a8 (the
|
||||
# feed's mine deadline had never reached the transport) both reached this
|
||||
# image through floating `*_REF=main` ARGs. Neither produced a diff in this
|
||||
# repo, so nothing here asked for a CHANGELOG entry, and neither had one
|
||||
# until a reader asked. This is the same shape as check 8: a fact with no
|
||||
# in-repo anchor rots. The hand practice that existed for it -- the
|
||||
# "Dependency audit" table in each release's notes ("Baked in vN | Upstream
|
||||
# now") -- is precisely a "someone remembers" mechanism, and it had lapsed.
|
||||
#
|
||||
# How it measures, with no docker/crane/token: the last published `vX.Y.Z`
|
||||
# is the highest such tag in Hub's tag list (shared with check 8); its
|
||||
# amd64 config blob is read through the anonymous registry API (token ->
|
||||
# manifest index -> per-arch manifest -> config) and carries one
|
||||
# `se.jordbo.pi-devbox.<name>-ref` label per component, each holding the
|
||||
# SHA that build-args actually baked (resolve-versions in docker-publish.yml
|
||||
# turns every ref into a SHA before `docker build`). "What the next build
|
||||
# would bake" is resolved the way that job does it: a 40-hex ARG is itself,
|
||||
# a tag or branch is `git ls-remote`d (peeled `^{}` first -- an annotated
|
||||
# tag's un-dereferenced SHA is the tag object, a false alarm this repo has
|
||||
# already fallen for once), pi-studio is the highest semver tag, and
|
||||
# `PI_VERSION` / `MEMPALACE_VERSION` are compared as literals against the
|
||||
# `pi-version` / `mempalace-version` labels (the latter set in Dockerfile.base
|
||||
# and inherited; absent on releases before it shipped, which reports SKIP).
|
||||
#
|
||||
# The rule: baked == would-bake is OK with no mention required. If they
|
||||
# differ, the text ABOVE the last published version's `## ` heading -- i.e.
|
||||
# `## Unreleased` plus any not-yet-published `## vX.Y.Z` section, which is
|
||||
# what the release commit turns Unreleased into -- must contain the
|
||||
# would-bake value's 7-char SHA prefix (or, for pi-studio, the tag name; for
|
||||
# pi, the version string). Naming the SHA, not just the repo, is the point:
|
||||
# it is what the audit table always recorded, and it makes the failure
|
||||
# message's compare URL a copy-paste away from knowing what moved.
|
||||
#
|
||||
# Every upstream commit therefore re-reds this gate until the CHANGELOG
|
||||
# names the new head. That is the intended cost: the thing that gets baked
|
||||
# is the thing that gets named, and a typo-fix upstream costs one edited
|
||||
# SHA here. Read from the TAG like everything else in these docs -- the
|
||||
# release commit renames Unreleased, so the pending text still covers it.
|
||||
#
|
||||
# SKIPs, each counted: SKIP_REF_CHECK=1; no curl/python3; Hub unreachable;
|
||||
# the release's labels unreadable; one component's upstream unreachable
|
||||
# (that component only). A published tag whose heading is MISSING from the
|
||||
# CHANGELOG is a failure, not a skip: that is drift in its own right.
|
||||
# ---------------------------------------------------------------------------
|
||||
if [ "${SKIP_REF_CHECK:-0}" = "1" ]; then
|
||||
skip "ref moves -- SKIP_REF_CHECK=1 was set"
|
||||
elif [ "$HAVE_NET_TOOLS" = 0 ]; then
|
||||
skip "ref moves -- need both curl and python3 to read the published labels"
|
||||
elif ! command -v git >/dev/null 2>&1; then
|
||||
skip "ref moves -- need git (ls-remote) to resolve what the next build would bake"
|
||||
elif [ -z "$HUB_REPO_PATH" ]; then
|
||||
skip "ref moves -- found no \`repo:tag\` image rows in $HUB to locate the published image"
|
||||
elif [ -z "$HUB_TAGS_JSON" ]; then
|
||||
skip "ref moves -- Docker Hub API unreachable (offline?); NOT verified"
|
||||
else
|
||||
# One plain top-level assignment per ARG, on purpose: read_arg exits 2 on a
|
||||
# missing ARG, and under `set -e` that only propagates from a bare
|
||||
# `VAR="$(...)"`. Nested inside a heredoc's $(...) the exit would be swallowed
|
||||
# by `cat`, and a renamed ARG would leave this check comparing a label against
|
||||
# an empty string and reporting the component "unchanged".
|
||||
TOOLKIT_REPO="$(read_arg "$DF_VARIANT" PI_TOOLKIT_REPO)"; TOOLKIT_REF="$(read_arg "$DF_VARIANT" PI_TOOLKIT_REF)"
|
||||
EXTENSIONS_REPO="$(read_arg "$DF_VARIANT" PI_EXTENSIONS_REPO)"; EXTENSIONS_REF="$(read_arg "$DF_VARIANT" PI_EXTENSIONS_REF)"
|
||||
FORK_REPO="$(read_arg "$DF_VARIANT" PI_FORK_REPO)"; FORK_REF="$(read_arg "$DF_VARIANT" PI_FORK_REF)"
|
||||
OBSMEM_REPO="$(read_arg "$DF_VARIANT" PI_OBSMEM_REPO)"; OBSMEM_REF="$(read_arg "$DF_VARIANT" PI_OBSMEM_REF)"
|
||||
ATELIER_REPO="$(read_arg "$DF_VARIANT" PI_ATELIER_REPO)"
|
||||
MPTK_REPO="$(read_arg "$DF_BASE" MEMPALACE_TOOLKIT_REPO)"; MPTK_REF="$(read_arg "$DF_BASE" MEMPALACE_TOOLKIT_REF)"
|
||||
STUDIO_REPO="$(read_arg "$DF_VARIANT" PI_STUDIO_REPO)"
|
||||
SKILLSET_SNAPSHOT="$(read_arg "$DF_VARIANT" SKILLSET_SNAPSHOT_REF)"
|
||||
# name|kind|repo|ref -- one line per label the variant image carries.
|
||||
# kinds: ref = branch/tag/SHA resolved like resolve-versions does;
|
||||
# studio = highest semver tag of the repo (label lives on <tag>-studio);
|
||||
# literal = the ARG value IS the baked value (a SHA pin, a version).
|
||||
REF_COMPONENTS="pi-toolkit|ref|$TOOLKIT_REPO|$TOOLKIT_REF
|
||||
pi-extensions|ref|$EXTENSIONS_REPO|$EXTENSIONS_REF
|
||||
pi-fork|ref|$FORK_REPO|$FORK_REF
|
||||
pi-obsmem|ref|$OBSMEM_REPO|$OBSMEM_REF
|
||||
pi-atelier|ref|$ATELIER_REPO|$ATELIER_ACTUAL
|
||||
mempalace-toolkit|ref|$MPTK_REPO|$MPTK_REF
|
||||
pi-studio|studio|$STUDIO_REPO|
|
||||
skillset-snapshot|literal||$SKILLSET_SNAPSHOT
|
||||
pi-version|literal||$PI_ACTUAL
|
||||
mempalace-version|literal||$MEMPALACE_ACTUAL"
|
||||
REF_RC=0
|
||||
# Same discipline as check 8: no `|| true` on the python, or a printed DRIFT
|
||||
# exits 0. Per-component SKIP lines are counted afterwards by grep, so a run
|
||||
# that evaluated eight components and could not reach the ninth reports one
|
||||
# skip, not a green tick over the ninth.
|
||||
REF_OUT="$(HUB_REPO="$HUB_REPO_PATH" HUB_JSON="$HUB_TAGS_JSON" CHANGELOG="CHANGELOG.md" \
|
||||
COMPONENTS="$REF_COMPONENTS" python3 <<'PYEOF'
|
||||
import json, os, re, subprocess, sys, urllib.request, urllib.parse
|
||||
|
||||
SHA40 = re.compile(r"^[0-9a-f]{40}$")
|
||||
SEMVER = re.compile(r"^v?[0-9]+\.[0-9]+\.[0-9]+$")
|
||||
LABEL = "se.jordbo.pi-devbox."
|
||||
|
||||
|
||||
def ver_key(tag):
|
||||
return tuple(int(x) for x in tag.lstrip("v").split("."))
|
||||
|
||||
|
||||
def http_json(url, headers=None, timeout=30):
|
||||
req = urllib.request.Request(url, headers=headers or {})
|
||||
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
||||
return json.loads(resp.read().decode("utf-8"))
|
||||
|
||||
|
||||
def labels_of(repo, tag):
|
||||
"""Config labels of <repo>:<tag>'s amd64 image via the anonymous registry API."""
|
||||
tok = http_json(
|
||||
"https://auth.docker.io/token?service=registry.docker.io&scope="
|
||||
+ urllib.parse.quote(f"repository:{repo}:pull", safe=":")
|
||||
)["token"]
|
||||
hdr = {
|
||||
"Authorization": f"Bearer {tok}",
|
||||
"Accept": ", ".join([
|
||||
"application/vnd.oci.image.index.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.list.v2+json",
|
||||
"application/vnd.oci.image.manifest.v1+json",
|
||||
"application/vnd.docker.distribution.manifest.v2+json",
|
||||
]),
|
||||
}
|
||||
base = f"https://registry-1.docker.io/v2/{repo}"
|
||||
man = http_json(f"{base}/manifests/{tag}", hdr)
|
||||
if "manifests" in man: # multi-arch index: pick linux/amd64, as check 8 does
|
||||
cands = [m for m in man["manifests"]
|
||||
if m.get("platform", {}).get("architecture") == "amd64"
|
||||
and m.get("platform", {}).get("os") == "linux"]
|
||||
if not cands:
|
||||
raise RuntimeError("no linux/amd64 entry in the manifest index")
|
||||
man = http_json(f"{base}/manifests/{cands[0]['digest']}", hdr)
|
||||
cfg = http_json(f"{base}/blobs/{man['config']['digest']}", hdr)
|
||||
return cfg.get("config", {}).get("Labels") or {}
|
||||
|
||||
|
||||
def ls_remote(repo, *patterns):
|
||||
# GIT_TERMINAL_PROMPT=0: a repo flipped private must fail fast as a SKIP,
|
||||
# not sit waiting for a username on a CI runner until the job times out.
|
||||
env = dict(os.environ, GIT_TERMINAL_PROMPT="0")
|
||||
out = subprocess.run(["git", "ls-remote", repo, *patterns], env=env,
|
||||
capture_output=True, text=True, timeout=60, check=True).stdout
|
||||
return {line.split("\t")[1]: line.split("\t")[0] for line in out.splitlines() if "\t" in line}
|
||||
|
||||
|
||||
def resolve_ref(repo, ref):
|
||||
"""What docker-publish.yml's resolve-versions would pass as the build-arg."""
|
||||
if SHA40.match(ref):
|
||||
return ref, ref
|
||||
refs = ls_remote(repo, f"refs/heads/{ref}", f"refs/tags/{ref}", f"refs/tags/{ref}^{{}}")
|
||||
for key in (f"refs/tags/{ref}^{{}}", f"refs/heads/{ref}", f"refs/tags/{ref}"):
|
||||
if key in refs:
|
||||
return refs[key], ref
|
||||
raise RuntimeError(f"'{ref}' is neither a branch nor a tag of {repo}")
|
||||
|
||||
|
||||
def resolve_studio(repo):
|
||||
refs = ls_remote(repo, "refs/tags/*")
|
||||
tags = {k[len("refs/tags/"):]: v for k, v in refs.items()}
|
||||
names = sorted((t for t in tags if SEMVER.match(t)), key=ver_key)
|
||||
if not names:
|
||||
raise RuntimeError(f"no semver tag at {repo}")
|
||||
tag = names[-1]
|
||||
return tags.get(tag + "^{}", tags[tag]), tag
|
||||
|
||||
|
||||
def compare_url(repo, a, b):
|
||||
root = repo[:-4] if repo.endswith(".git") else repo
|
||||
return f"{root}/compare/{a}...{b}"
|
||||
|
||||
|
||||
try:
|
||||
hub = json.loads(os.environ["HUB_JSON"])
|
||||
except (ValueError, KeyError) as exc:
|
||||
print(" SKIP ref moves -- Hub API returned unparseable JSON (%s)" % exc)
|
||||
sys.exit(3)
|
||||
released = sorted((r["name"] for r in hub.get("results", [])
|
||||
if isinstance(r.get("name"), str) and re.fullmatch(r"v[0-9]+\.[0-9]+\.[0-9]+", r["name"])),
|
||||
key=ver_key)
|
||||
if not released:
|
||||
print(" SKIP ref moves -- Hub lists no published vX.Y.Z tag to compare against")
|
||||
sys.exit(3)
|
||||
last = released[-1]
|
||||
repo = os.environ["HUB_REPO"]
|
||||
|
||||
# The text every not-yet-published change lives in: everything above the last
|
||||
# published version's heading. Its absence is drift, not a skip.
|
||||
text = open(os.environ["CHANGELOG"], encoding="utf-8").read()
|
||||
# (\s|$) rather than \b: a word boundary would accept "## v1.9.2-rc1" or
|
||||
# "## v1.9.2-typo" as v1.9.2's heading. Caught by the sabotage test, not review.
|
||||
m = re.search(r"^## v?%s(\s|$)" % re.escape(last.lstrip("v")), text, re.M)
|
||||
if not m:
|
||||
print(" DRIFT ref moves -- %s is the last PUBLISHED tag on Hub but %s has no '## %s' heading"
|
||||
% (last, os.environ["CHANGELOG"], last))
|
||||
sys.exit(1)
|
||||
pending = text[:m.start()].lower()
|
||||
|
||||
try:
|
||||
labels = labels_of(repo, last)
|
||||
except Exception as exc: # network, auth, shape -- all "could not measure"
|
||||
print(" SKIP ref moves -- could not read %s:%s's labels from the registry (%s); NOT verified"
|
||||
% (repo, last, exc))
|
||||
sys.exit(3)
|
||||
studio_labels = None
|
||||
|
||||
checked = drift = 0
|
||||
problems = []
|
||||
for line in os.environ["COMPONENTS"].splitlines():
|
||||
if not line.strip():
|
||||
continue
|
||||
name, kind, url, ref = line.split("|", 3)
|
||||
# <name>-ref labels hold SHAs; names that already end in -version are the
|
||||
# label (pi-version, mempalace-version) -- a version string, compared literally.
|
||||
key = LABEL + name if name.endswith("-version") else LABEL + name + "-ref"
|
||||
try:
|
||||
if kind == "studio":
|
||||
if studio_labels is None:
|
||||
studio_labels = labels_of(repo, last + "-studio")
|
||||
baked = studio_labels.get(key)
|
||||
else:
|
||||
baked = labels.get(key)
|
||||
except Exception as exc:
|
||||
print(" SKIP %-18s -- could not read %s:%s-studio's labels (%s)" % (name, repo, last, exc))
|
||||
continue
|
||||
if not baked:
|
||||
print(" SKIP %-18s -- %s carries no %s label" % (name, last, key))
|
||||
continue
|
||||
try:
|
||||
if kind == "ref":
|
||||
now, shown = resolve_ref(url, ref)
|
||||
elif kind == "studio":
|
||||
now, shown = resolve_studio(url)
|
||||
else:
|
||||
now, shown = ref, ref
|
||||
except Exception as exc:
|
||||
print(" SKIP %-18s -- could not resolve what the next build would bake (%s)" % (name, exc))
|
||||
continue
|
||||
checked += 1
|
||||
is_sha = bool(SHA40.match(now))
|
||||
short = (lambda s: s[:7] if SHA40.match(s) else s)
|
||||
if baked == now:
|
||||
print(" OK %-18s unchanged since %s (%s)" % (name, last, short(now)))
|
||||
continue
|
||||
names = [now[:7].lower()] if is_sha else [now.lower()]
|
||||
if kind == "studio":
|
||||
names.append(shown.lower())
|
||||
if any(n in pending for n in names):
|
||||
print(" OK %-18s %s -> %s since %s, named above the %s heading"
|
||||
% (name, short(baked), short(now), last, last))
|
||||
continue
|
||||
drift += 1
|
||||
hint = compare_url(url, baked, now) if (url and is_sha and SHA40.match(baked)) else ""
|
||||
problems.append(" %-18s %s -> %s%s" % (name, short(baked), short(now), (" " + hint) if hint else ""))
|
||||
print(" DRIFT %-18s %s -> %s since %s, NOT named above the %s heading"
|
||||
% (name, short(baked), short(now), last, last))
|
||||
|
||||
if problems:
|
||||
print(" Name each new value (7-char SHA prefix, or the tag/version) in CHANGELOG.md above '## %s':" % last)
|
||||
print("\n".join(problems))
|
||||
if checked == 0 and drift == 0:
|
||||
print(" SKIP ref moves -- no component could be evaluated")
|
||||
sys.exit(3)
|
||||
sys.exit(1 if drift else 0)
|
||||
PYEOF
|
||||
)" || REF_RC=$?
|
||||
printf '%s\n' "$REF_OUT"
|
||||
REF_SKIPS="$(printf '%s\n' "$REF_OUT" | grep -c '^ SKIP ' || true)"
|
||||
case "$REF_RC" in
|
||||
0) SKIPS=$((SKIPS + REF_SKIPS)) ;;
|
||||
3) SKIPS=$((SKIPS + 1)) ;;
|
||||
*)
|
||||
SKIPS=$((SKIPS + REF_SKIPS))
|
||||
fail "a component the next build would bake differently from the last published
|
||||
release is not named in CHANGELOG.md (see DRIFT above). These reach the image
|
||||
through floating refs, so nothing else in this repo records that they moved;
|
||||
the CHANGELOG entry is the only place a reader of the next tag can learn it.
|
||||
Name the new SHA (7 chars is enough) where you describe the change -- the
|
||||
compare URL above shows what moved."
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAILURES" -eq 0 ]; then
|
||||
if [ "$SKIPS" -gt 0 ]; then
|
||||
echo "OK: every checked doc claim matches the build files" \
|
||||
"($SKIPS check(s) SKIPPED and therefore NOT verified -- see SKIP above)."
|
||||
else
|
||||
echo "OK: every checked doc claim matches the build files."
|
||||
fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::error::$FAILURES doc claim(s) have drifted from the build files."
|
||||
echo
|
||||
echo "Docs are read from the TAG, not from main: docker-publish.yml checks out"
|
||||
echo "github.ref, so a fix pushed after tagging does not reach the release or the"
|
||||
echo "Hub page. Update the docs BEFORE you tag."
|
||||
|
||||
if [ "$WARN_ONLY" -eq 1 ]; then
|
||||
echo "(--warn-only: exiting 0 anyway)"
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
Executable
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-skill-floor.sh — fail when the vendored pi-extensions skill snapshot in
|
||||
# rootfs/ ("the floor") has drifted from the package repo it is a snapshot of.
|
||||
#
|
||||
# THE DEFECT THIS EXISTS TO CATCH, measured 2026-09-10.
|
||||
# rootfs/usr/local/share/pi-devbox/skills/pi-extensions/ ships a vendored copy
|
||||
# of the pi-extensions skill so the skill is ALWAYS present in the image.
|
||||
# Dockerfile.variant then copies the freshly-cloned package copy OVER the served
|
||||
# path at /usr/local/share/... — but it never writes back to the repo floor. So
|
||||
# the floor only silently rots, and it had: 34284 B, untouched since fa04d20
|
||||
# (2026-07-30), while the package copy was 38973 B. Four copies existed with
|
||||
# three different sizes.
|
||||
#
|
||||
# Why that is worse than ordinary staleness: the floor is a FALLBACK. The copy
|
||||
# step is guarded by `if [ -f /opt/pi-extensions/skill/SKILL.md ]`, so a build
|
||||
# where the package clone yields no skill/ keeps the vendored snapshot and still
|
||||
# succeeds — green, with no manifest flag and no label saying which copy was
|
||||
# served. The image would ship a July skill and nothing would say so. Keeping
|
||||
# the floor fresh means that fallback is harmless instead of a silent regression.
|
||||
#
|
||||
# WHY A DIRECTORY HASH AND NOT `sha256sum SKILL.md`.
|
||||
# The same pipeline Dockerfile.variant uses for skillset_snapshot_tree_sha256,
|
||||
# and for the same documented reason: a file-only compare answers "did this one
|
||||
# file change", not "is this the same skill". pi-extensions ships TWO files
|
||||
# (SKILL.md + evaluate-extension-usage.py), so a sibling-file edit would pass a
|
||||
# file-only check. If you change the pipeline here, change it there too.
|
||||
#
|
||||
# WHY GATING ON ANOTHER REPO IS PROPORTIONATE HERE, since that is normally a
|
||||
# smell: this fires only when the package's skill/ DIRECTORY HASH changes, which
|
||||
# is exactly and only when the floor has genuinely gone stale. pi-extensions
|
||||
# commits that do not touch skill/ leave the hash alone and cannot turn this red.
|
||||
# The repo is also anonymously clonable (verified 2026-09-10 with `git ls-remote`
|
||||
# and no credentials), so this needs no secret and cannot break on token expiry.
|
||||
#
|
||||
# Exit codes — deliberately three, matching scripts/lint-shell.sh's philosophy
|
||||
# that a gate which cannot run must not pass:
|
||||
# 0 in sync (or the package legitimately has no skill/ at this ref)
|
||||
# 1 DRIFT — the floor differs from the package
|
||||
# 2 cannot run — no package copy could be obtained
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
FLOOR_DIR="${REPO_ROOT}/rootfs/usr/local/share/pi-devbox/skills/pi-extensions"
|
||||
|
||||
# Defaults mirror Dockerfile.variant's ARGs so this checks what the build builds.
|
||||
PI_EXTENSIONS_REPO="${PI_EXTENSIONS_REPO:-https://gitea.jordbo.se/joakimp/pi-extensions.git}"
|
||||
PI_EXTENSIONS_REF="${PI_EXTENSIONS_REF:-main}"
|
||||
|
||||
PACKAGE_DIR=""
|
||||
WARN_ONLY=0
|
||||
TMPDIR_CLONE=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/check-skill-floor.sh [options]
|
||||
|
||||
--package-dir DIR Compare against an existing skill directory instead of
|
||||
cloning. In a devbox container use /opt/pi-extensions/skill
|
||||
for a fully offline run.
|
||||
--warn-only Report drift but exit 0 (advisory use, e.g. a local hook).
|
||||
-h, --help This text.
|
||||
|
||||
Environment: PI_EXTENSIONS_REPO, PI_EXTENSIONS_REF (default main) — both mirror
|
||||
the Dockerfile.variant ARGs of the same name.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--package-dir) PACKAGE_DIR="${2:-}"; shift 2 ;;
|
||||
--warn-only) WARN_ONLY=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "::error::unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$TMPDIR_CLONE" ]; then rm -rf "$TMPDIR_CLONE"; fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Identical to Dockerfile.variant's tree_sha256(): relative paths + per-file
|
||||
# sha256 over a sorted `find`, folded into one digest. Deterministic, never
|
||||
# readdir order.
|
||||
tree_sha256() {
|
||||
( cd "$1" && find . -type f -print | LC_ALL=C sort | xargs -r sha256sum ) \
|
||||
2>/dev/null | sha256sum | cut -d' ' -f1
|
||||
}
|
||||
|
||||
if [ ! -d "$FLOOR_DIR" ]; then
|
||||
echo "::error::floor directory is missing: ${FLOOR_DIR}"
|
||||
echo "::error::rootfs/ is supposed to guarantee the skill is always in the image."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SOURCE_DESC=""
|
||||
if [ -n "$PACKAGE_DIR" ]; then
|
||||
if [ ! -d "$PACKAGE_DIR" ]; then
|
||||
echo "::error::--package-dir does not exist: ${PACKAGE_DIR}"
|
||||
exit 2
|
||||
fi
|
||||
SOURCE_DESC="local directory ${PACKAGE_DIR}"
|
||||
else
|
||||
command -v git >/dev/null 2>&1 || { echo "::error::git not found; cannot obtain the package copy."; exit 2; }
|
||||
TMPDIR_CLONE=$(mktemp -d)
|
||||
# Fetch the single ref shallowly. `git fetch <ref>` accepts a branch, a tag
|
||||
# and (on Gitea) a reachable commit, which is why this is not `clone --branch`
|
||||
# — CI resolves PI_EXTENSIONS_REF to a 40-hex SHA before the build.
|
||||
if ! ( cd "$TMPDIR_CLONE" \
|
||||
&& git init -q . \
|
||||
&& git remote add origin "$PI_EXTENSIONS_REPO" \
|
||||
&& git fetch -q --depth 1 origin "$PI_EXTENSIONS_REF" \
|
||||
&& git checkout -q FETCH_HEAD ) 2>/dev/null; then
|
||||
echo "::error::could not fetch ${PI_EXTENSIONS_REF} from ${PI_EXTENSIONS_REPO}"
|
||||
echo "::error::Cannot determine whether the floor is stale, so this is exit 2, not a pass."
|
||||
echo "::error::For an offline run, pass --package-dir /opt/pi-extensions/skill"
|
||||
exit 2
|
||||
fi
|
||||
PACKAGE_SHA=$( cd "$TMPDIR_CLONE" && git rev-parse --short HEAD )
|
||||
PACKAGE_DIR="${TMPDIR_CLONE}/skill"
|
||||
SOURCE_DESC="${PI_EXTENSIONS_REPO} @ ${PI_EXTENSIONS_REF} (${PACKAGE_SHA})"
|
||||
fi
|
||||
|
||||
# A ref with no skill/ is the documented fallback case: Dockerfile.variant keeps
|
||||
# the vendored snapshot and the build succeeds. Nothing to compare, so this is
|
||||
# not drift — but it IS the exact condition under which the floor ships, so say
|
||||
# so loudly rather than printing a silent green tick.
|
||||
if [ ! -d "$PACKAGE_DIR" ]; then
|
||||
echo "::warning::package has no skill/ at this ref — the vendored floor is what will ship."
|
||||
echo " source : ${SOURCE_DESC}"
|
||||
echo " floor : $(tree_sha256 "$FLOOR_DIR")"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
FLOOR_HASH=$(tree_sha256 "$FLOOR_DIR")
|
||||
PKG_HASH=$(tree_sha256 "$PACKAGE_DIR")
|
||||
|
||||
if [ "$FLOOR_HASH" = "$PKG_HASH" ]; then
|
||||
echo "OK: vendored pi-extensions floor matches the package."
|
||||
echo " source : ${SOURCE_DESC}"
|
||||
echo " tree_sha256: ${FLOOR_HASH}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# `set -e` interacts badly with `[ … ] && x` as a bare statement, so both of
|
||||
# these are explicit if-blocks rather than AND-lists.
|
||||
LEVEL="error"
|
||||
if [ "$WARN_ONLY" -eq 1 ]; then LEVEL="warning"; fi
|
||||
|
||||
echo "::${LEVEL}::vendored pi-extensions skill floor has DRIFTED from the package."
|
||||
echo " source : ${SOURCE_DESC}"
|
||||
echo " floor tree_sha256 : ${FLOOR_HASH}"
|
||||
echo " pkg tree_sha256 : ${PKG_HASH}"
|
||||
echo ""
|
||||
echo " per-file differences:"
|
||||
diff -rq "$FLOOR_DIR" "$PACKAGE_DIR" 2>&1 | sed 's/^/ /' || true
|
||||
echo ""
|
||||
echo " Remedy — re-sync the floor and commit it:"
|
||||
echo " cp -a <pi-extensions>/skill/. ${FLOOR_DIR}/"
|
||||
echo " git add ${FLOOR_DIR#"${REPO_ROOT}/"} && git commit"
|
||||
echo ""
|
||||
echo " NOTE this forces one full base rebuild: base_tag hashes Dockerfile.base"
|
||||
echo " + rootfs/, and that rebuild is what re-bakes the refreshed floor."
|
||||
|
||||
if [ "$WARN_ONLY" -eq 1 ]; then exit 0; fi
|
||||
exit 1
|
||||
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
# Gitea-accurate guard against the recurring "bash syntax under the default
|
||||
# sh/dash shell" footgun (ed49b8d resolve-versions; b7197e8 promote-base-latest,
|
||||
# run 418).
|
||||
#
|
||||
# WHY A CUSTOM CHECK AND NOT JUST actionlint:
|
||||
# actionlint models *GitHub* Actions, whose default `run` shell is bash. It
|
||||
# therefore assumes a step that omits `shell:` runs under bash, and does NOT
|
||||
# flag `set -o pipefail` there. Gitea Actions' default is `sh` (dash), so the
|
||||
# exact bug we hit (omit `shell:`, use bash syntax) is invisible to actionlint.
|
||||
# actionlint only fires when a step *explicitly* declares `shell: sh`.
|
||||
#
|
||||
# THE INVARIANT THIS ENFORCES:
|
||||
# Every `run:` step in every .gitea/workflows/*.yml must resolve to an
|
||||
# effective shell of `bash` — via the step's own `shell:`, a job-level
|
||||
# `defaults.run.shell`, or a workflow-level `defaults.run.shell`. Any step
|
||||
# that would fall through to Gitea's `sh` default is a FAILURE, because a
|
||||
# future author adding bash syntax to it fails silently in CI.
|
||||
#
|
||||
# Pair this with actionlint (which catches explicit `shell: sh` + bash syntax,
|
||||
# expression errors, and much else). Together they cover the class on Gitea.
|
||||
set -euo pipefail
|
||||
|
||||
WF_DIR="${1:-.gitea/workflows}"
|
||||
|
||||
python3 - "$WF_DIR" <<'PY'
|
||||
import sys, glob, os
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
sys.stderr.write("ERROR: python3 yaml module missing (apt install python3-yaml)\n")
|
||||
sys.exit(2)
|
||||
|
||||
wf_dir = sys.argv[1]
|
||||
files = sorted(glob.glob(os.path.join(wf_dir, "*.yml")) + glob.glob(os.path.join(wf_dir, "*.yaml")))
|
||||
if not files:
|
||||
sys.stderr.write(f"ERROR: no workflow files under {wf_dir}\n")
|
||||
sys.exit(2)
|
||||
|
||||
problems = []
|
||||
for f in files:
|
||||
with open(f) as fh:
|
||||
doc = yaml.safe_load(fh) or {}
|
||||
wf_shell = (((doc.get("defaults") or {}).get("run") or {}).get("shell"))
|
||||
jobs = doc.get("jobs") or {}
|
||||
for jname, job in jobs.items():
|
||||
job = job or {}
|
||||
job_shell = (((job.get("defaults") or {}).get("run") or {}).get("shell"))
|
||||
steps = job.get("steps") or []
|
||||
for i, step in enumerate(steps):
|
||||
step = step or {}
|
||||
if "run" not in step:
|
||||
continue # `uses:` steps have no shell
|
||||
eff = step.get("shell") or job_shell or wf_shell or "sh" # Gitea default = sh
|
||||
if eff != "bash":
|
||||
name = step.get("name") or f"step[{i}]"
|
||||
problems.append(f"{f}: job '{jname}' / '{name}': effective shell = '{eff}' (Gitea default is sh; declare shell: bash or a bash default)")
|
||||
|
||||
if problems:
|
||||
sys.stderr.write("Workflow shell guard FAILED — bash default not guaranteed:\n")
|
||||
for p in problems:
|
||||
sys.stderr.write(f" - {p}\n")
|
||||
sys.exit(1)
|
||||
print(f"Workflow shell guard OK — all run: steps in {len(files)} workflow file(s) resolve to bash.")
|
||||
PY
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env bash
|
||||
# Shellcheck + syntax-check every shell script in this repo. Severity: error.
|
||||
#
|
||||
# SINGLE SOURCE OF TRUTH for two callers:
|
||||
# .gitea/workflows/lint.yml — advisory, every branch push and PR
|
||||
# .gitea/workflows/docker-publish.yml — the release GATE (lint-gate job)
|
||||
# Extracted from lint.yml on 2026-09-08 rather than copied, because a second
|
||||
# copy is exactly the drift this repo has been bitten by (see skillset's
|
||||
# pi-extensions mirror, refreshed the same evening after sitting 9579 B behind).
|
||||
#
|
||||
# WHY THIS CHECK EXISTS AT ALL
|
||||
# actionlint shellchecks workflow `run:` steps only. The repo's own scripts —
|
||||
# entrypoint.sh, scripts/*.sh, and the extensionless tools under
|
||||
# rootfs/usr/local/bin/ — were never shellchecked. A sibling repo with the same
|
||||
# gap shipped a broken `echo "$json" | python3 <<'EOF' ... json.load(sys.stdin)`
|
||||
# for two months: with no script argument python reads its SCRIPT from stdin,
|
||||
# so the heredoc IS stdin and json.load hits EOF. shellcheck flags that at
|
||||
# severity error (SC2259); nothing ever ran it.
|
||||
#
|
||||
# WHY THE RELEASE GATES ON IT (added 2026-09-08, the expensive way round)
|
||||
# v1.8.14's first attempt failed after build-base had already spent ~46 min:
|
||||
# scripts/smoke-test.sh had an apostrophe inside a single-quoted exec_test body
|
||||
# ("the fleet\'s"), which CLOSES the string, so the body truncated and its tail
|
||||
# ran on the CI runner instead of inside the image. shellcheck had already
|
||||
# caught it as SC2289 at severity error — the lint job went red on the very
|
||||
# push that introduced it and stayed red for 24 hours, unread. lint.yml
|
||||
# deliberately does not run on tag pushes (sound: the tagged tree was linted on
|
||||
# main, and a tag-ref lint run sorts above the publish run and makes a release
|
||||
# look finished early). The gap was never "lint the tag" — it was that a tree
|
||||
# whose lint FAILED could still be released. Hence a gate inside the publish
|
||||
# workflow, ~40 s, ahead of everything expensive.
|
||||
#
|
||||
# SEVERITY CHOICE
|
||||
# -S error is 0 findings across this repo when clean, so it is free to add.
|
||||
# -S warning is NOT free here (20x SC2088 tilde-in-quotes in
|
||||
# recreate-sanity-check.sh, plus assorted SC2016 — both intentional), and a
|
||||
# noisy gate trains people to ignore it. Error-only, matching the
|
||||
# SHELLCHECK_OPTS philosophy in lint.yml.
|
||||
# Reproduce the count before editing it (the `$ ` prefix is load-bearing: a
|
||||
# comment whose first word is "shellcheck" is parsed as a DIRECTIVE, and a
|
||||
# malformed one is SC1072/SC1073 at severity error — this gate caught exactly
|
||||
# that when the line was first written without it):
|
||||
# $ shellcheck -S warning -f gcc scripts/*.sh rootfs/usr/local/bin/* \
|
||||
# entrypoint*.sh hooks/* | grep -c SC2088
|
||||
#
|
||||
# Usage: bash scripts/lint-shell.sh [root] (default root: repo top level)
|
||||
set -uo pipefail
|
||||
|
||||
root="${1:-}"
|
||||
if [ -z "$root" ]; then
|
||||
root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
|
||||
fi
|
||||
cd "$root" || { echo "::error::cannot cd to $root"; exit 2; }
|
||||
|
||||
# A gate that cannot run must not pass. Without this, a machine (or a CI job
|
||||
# whose install step was reordered away) without shellcheck would sail through
|
||||
# printing nothing, which is the failure mode this whole file exists to prevent.
|
||||
if ! command -v shellcheck >/dev/null 2>&1; then
|
||||
echo "::error::shellcheck not found — the gate cannot run, so it must not pass" >&2
|
||||
echo " install it (apt-get install -y shellcheck) or run this in CI" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
# Union of two signals, because either alone misses a real case: a shebang scan
|
||||
# misses a sourced fragment with no shebang, and a *.sh glob misses the
|
||||
# extensionless tools in rootfs/usr/local/bin/. Silent skipping is precisely the
|
||||
# failure mode this gate exists to prevent, so err toward over-collecting.
|
||||
# -print0/mapfile -d '' so a path containing a space cannot silently split.
|
||||
mapfile -d '' -t all_files < <(find . -not -path './.git/*' -type f -print0)
|
||||
sh_files=()
|
||||
for f in "${all_files[@]}"; do
|
||||
case "$f" in *.sh) sh_files+=("$f"); continue;; esac
|
||||
if head -n1 "$f" 2>/dev/null | grep -qE '^#!.*\b(bash|sh)\b'; then
|
||||
sh_files+=("$f")
|
||||
fi
|
||||
done
|
||||
|
||||
echo "Checking ${#sh_files[@]} shell file(s) with $(shellcheck --version | awk '/version:/{print $2}')"
|
||||
# A green tick over an empty file set is not a check.
|
||||
if [ "${#sh_files[@]}" -eq 0 ]; then
|
||||
echo "::error::no shell files found — the shebang scan or the checkout is wrong"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
rc=0
|
||||
shellcheck -S error -f gcc "${sh_files[@]}" || rc=1
|
||||
|
||||
# bash -n catches a different class than shellcheck (unbalanced constructs it
|
||||
# declines to parse), so both run and both count.
|
||||
for f in "${sh_files[@]}"; do
|
||||
bash -n "$f" || { echo "::error file=$f::bash -n failed"; rc=1; }
|
||||
done
|
||||
|
||||
if [ "$rc" -eq 0 ]; then
|
||||
echo "OK: ${#sh_files[@]} shell file(s) clean at severity error"
|
||||
fi
|
||||
exit "$rc"
|
||||
Executable
+612
@@ -0,0 +1,612 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runtime post-recreate verification for pi-devbox.
|
||||
#
|
||||
# Verifies that after `docker compose up -d --force-recreate`:
|
||||
# - The new image is actually live (both the pi version and — when asked —
|
||||
# the pi-devbox image release tag; see the two version notes below)
|
||||
# - Persisted named volumes survived (~/.pi config, shell history, zoxide,
|
||||
# nvim data, uv cache, ssh-local)
|
||||
# - pi runtime wiring is intact: keybindings symlink, AGENTS.md symlink,
|
||||
# ≥4 extensions, the mempalace.ts bridge, settings.json, and the pi-fork /
|
||||
# pi-observational-memory / (studio variant) pi-studio package
|
||||
# registrations in settings.json packages[]
|
||||
# - Shell defaults re-seeded from /etc/skel-devbox
|
||||
# - ssh ControlMaster works: /tmp/sshcm exists 700 AND the ControlPath that
|
||||
# ssh actually resolves (ssh -G) is a writable directory
|
||||
# - /opt toolkits intact
|
||||
# - Known expected-absences don't regress
|
||||
#
|
||||
# This is repo/maintainer tooling — the runtime peer of smoke-test.sh.
|
||||
# smoke-test.sh runs at BUILD time with `--entrypoint=""`, so it can never see
|
||||
# a recreated container's persisted volumes or the entrypoint's runtime
|
||||
# deploy. This script is its runtime counterpart: it inspects what is actually
|
||||
# live in the container you are sitting in after a recreate.
|
||||
#
|
||||
# It is NOT baked into the published Docker Hub image; run it from a checkout of
|
||||
# the pi-devbox repo (which a maintainer already has for CI builds). A plain
|
||||
# `docker pull` consumer is not the audience and will not have this file.
|
||||
#
|
||||
# TWO DIFFERENT VERSIONS, TWO DIFFERENT FLAGS. This distinction has already
|
||||
# cost a release day, so it is spelled out here and in AGENTS.md step 4:
|
||||
#
|
||||
# --expected-version the PI CODING AGENT version, e.g. 0.84.3
|
||||
# (`pi --version`; pinned as ARG PI_VERSION in
|
||||
# Dockerfile.variant, which CI reads as the source
|
||||
# of truth)
|
||||
# --expected-image-version the PI-DEVBOX IMAGE release tag, e.g. 1.8.9 or
|
||||
# v1.8.9 (the `release_tag` baked into
|
||||
# /etc/pi-devbox/build-manifest.json)
|
||||
#
|
||||
# Passing a release tag to --expected-version used to report
|
||||
# "pi version mismatch: expected 1.8.8, got 0.84.3" — an accusation aimed at
|
||||
# the wrong component, on the last gate of a release. Both flags now detect
|
||||
# being handed the other one's value and say so instead.
|
||||
#
|
||||
# Neither flag is required. Both values are derivable from the image's own
|
||||
# build manifest, so by default the script asserts the LIVE pi version against
|
||||
# the version recorded at build time — which is not a tautology: a stale
|
||||
# `pi` in the ~/.pi/npm-global volume can shadow the baked one, exactly the
|
||||
# way a stale npm:pi-atelier can (see the packages[] check below). Pass the
|
||||
# flags when you want an assertion against a value you name yourself, which
|
||||
# is what a release checklist wants.
|
||||
#
|
||||
# Usage: ./scripts/recreate-sanity-check.sh [--expected-version X.Y.Z]
|
||||
# [--expected-image-version X.Y.Z]
|
||||
# [--variant studio|plain]
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 all checks passed
|
||||
# 1 one or more checks failed
|
||||
# 2 usage error
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
EXPECTED_VERSION=""
|
||||
EXPECTED_IMAGE_VERSION=""
|
||||
VARIANT=""
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MANIFEST=/etc/pi-devbox/build-manifest.json
|
||||
|
||||
usage() {
|
||||
cat >&2 <<'EOF'
|
||||
usage: recreate-sanity-check.sh [--expected-version X.Y.Z]
|
||||
[--expected-image-version X.Y.Z]
|
||||
[--variant studio|plain]
|
||||
|
||||
--expected-version pi coding agent version, e.g. 0.84.3 (`pi --version`)
|
||||
--expected-image-version pi-devbox image release tag, e.g. 1.8.9 or v1.8.9
|
||||
--variant studio|plain (auto-detected when omitted)
|
||||
|
||||
These are two different versions. Both are read from the image's own build
|
||||
manifest when the corresponding flag is omitted.
|
||||
EOF
|
||||
}
|
||||
|
||||
# Parse arguments. Every flag takes a value, so reject a missing one rather
|
||||
# than swallowing the next flag as if it were the value.
|
||||
need_value() {
|
||||
case "${2:-}" in
|
||||
""|-*)
|
||||
echo "$1 requires a value" >&2
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
}
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--expected-version)
|
||||
need_value "$@"
|
||||
EXPECTED_VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--expected-image-version)
|
||||
need_value "$@"
|
||||
EXPECTED_IMAGE_VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--variant)
|
||||
need_value "$@"
|
||||
VARIANT="$2"
|
||||
shift 2
|
||||
;;
|
||||
--help|-h)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "unknown option: $1" >&2
|
||||
usage
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
FAILED=0
|
||||
pass() { echo " ✓ $1"; }
|
||||
fail() { echo " ✗ $1" >&2; FAILED=$((FAILED + 1)); }
|
||||
warn() { echo " ⚠ $1" >&2; }
|
||||
|
||||
# Read one top-level field from the build manifest, or print nothing. The
|
||||
# manifest is the image's own ground truth (written at `docker build` time by
|
||||
# Dockerfile.variant), so it needs no checkout and no network. Absent on an
|
||||
# image built before it existed, hence every caller treats "" as unknown.
|
||||
manifest_field() {
|
||||
[ -f "$MANIFEST" ] || return 0
|
||||
command -v jq >/dev/null 2>&1 || return 0
|
||||
jq -r --arg k "$1" '.[$k] // empty' "$MANIFEST" 2>/dev/null || true
|
||||
}
|
||||
# Release tags are written with a leading v in the manifest and quoted without
|
||||
# one in checklists; compare on the bare number so both spellings work.
|
||||
strip_v() { printf '%s' "${1#v}"; }
|
||||
|
||||
# Auto-detect variant if not provided. The studio variant vendors pi-studio to
|
||||
# /opt/pi-studio; the plain variant does not.
|
||||
if [ -z "$VARIANT" ]; then
|
||||
if [ -d /opt/pi-studio ]; then
|
||||
VARIANT="studio"
|
||||
else
|
||||
VARIANT="plain"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Print header with git context
|
||||
echo "=== Recreate sanity check (variant: $VARIANT) ==="
|
||||
if GIT_TAG=$(git -C "$REPO_DIR" describe --tags 2>/dev/null); then
|
||||
echo " Repo HEAD: $GIT_TAG (version-match only meaningful when image tag matches)"
|
||||
else
|
||||
echo " Repo HEAD: (not a git repo or no tags)"
|
||||
fi
|
||||
echo
|
||||
|
||||
MANIFEST_PI_VERSION=$(manifest_field pi_version)
|
||||
MANIFEST_RELEASE_TAG=$(manifest_field release_tag)
|
||||
|
||||
echo "-- pi (coding agent) version --"
|
||||
if ACTUAL_VERSION=$(pi --version 2>&1 | head -1); then
|
||||
if [ -n "$EXPECTED_VERSION" ]; then
|
||||
if [ "$(strip_v "$EXPECTED_VERSION")" = "$(strip_v "$ACTUAL_VERSION")" ]; then
|
||||
pass "pi version $ACTUAL_VERSION (matches --expected-version)"
|
||||
elif [ -n "$MANIFEST_RELEASE_TAG" ] &&
|
||||
[ "$(strip_v "$EXPECTED_VERSION")" = "$(strip_v "$MANIFEST_RELEASE_TAG")" ]; then
|
||||
# Exact, not heuristic: the value handed over IS this image's release
|
||||
# tag, so it cannot be a pi version anyone meant.
|
||||
fail "--expected-version $EXPECTED_VERSION is the pi-devbox IMAGE version, not the pi version — use --expected-image-version $EXPECTED_VERSION (live pi is $ACTUAL_VERSION)"
|
||||
else
|
||||
fail "pi version mismatch: expected $EXPECTED_VERSION, got $ACTUAL_VERSION (this flag asserts the pi coding agent version; for the image release tag use --expected-image-version)"
|
||||
fi
|
||||
elif [ -n "$MANIFEST_PI_VERSION" ]; then
|
||||
# Not a tautology: the manifest records what pi reported at BUILD time,
|
||||
# while `pi --version` resolves through PATH, which a stale npm-global
|
||||
# volume install can shadow.
|
||||
if [ "$MANIFEST_PI_VERSION" = "$ACTUAL_VERSION" ]; then
|
||||
pass "pi version $ACTUAL_VERSION (matches this image's build manifest)"
|
||||
else
|
||||
fail "live pi $ACTUAL_VERSION != $MANIFEST_PI_VERSION recorded in $MANIFEST — a stale pi in the ~/.pi/npm-global volume is shadowing the baked one"
|
||||
fi
|
||||
else
|
||||
warn "pi version $ACTUAL_VERSION (no --expected-version and no build manifest to compare against — informational only)"
|
||||
fi
|
||||
else
|
||||
fail "pi --version failed"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- pi-devbox image version --"
|
||||
if [ -z "$MANIFEST_RELEASE_TAG" ]; then
|
||||
if [ -n "$EXPECTED_IMAGE_VERSION" ]; then
|
||||
fail "cannot verify --expected-image-version $EXPECTED_IMAGE_VERSION: no readable release_tag in $MANIFEST (image built before the manifest existed, or jq missing)"
|
||||
else
|
||||
warn "image release tag unknown (no readable $MANIFEST) — pi-devbox-version would say the same"
|
||||
fi
|
||||
elif [ -n "$EXPECTED_IMAGE_VERSION" ]; then
|
||||
if [ "$(strip_v "$EXPECTED_IMAGE_VERSION")" = "$(strip_v "$MANIFEST_RELEASE_TAG")" ]; then
|
||||
pass "image version $MANIFEST_RELEASE_TAG (matches --expected-image-version)"
|
||||
elif [ -n "$MANIFEST_PI_VERSION" ] &&
|
||||
[ "$(strip_v "$EXPECTED_IMAGE_VERSION")" = "$MANIFEST_PI_VERSION" ]; then
|
||||
fail "--expected-image-version $EXPECTED_IMAGE_VERSION is the pi version, not the image release tag — use --expected-version $EXPECTED_IMAGE_VERSION (this image is $MANIFEST_RELEASE_TAG)"
|
||||
else
|
||||
fail "image version mismatch: expected $EXPECTED_IMAGE_VERSION, got $MANIFEST_RELEASE_TAG — the recreate did not pick up the intended image"
|
||||
fi
|
||||
else
|
||||
warn "image version $MANIFEST_RELEASE_TAG (no --expected-image-version given — informational only)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- Persisted named volumes (must survive --force-recreate) --"
|
||||
|
||||
# ~/.pi config volume (devbox-pi-config) — holds agent settings, extensions,
|
||||
# keybindings symlink. Must exist and be non-empty after recreate.
|
||||
if [ -d "$HOME/.pi/agent" ] && [ -n "$(ls -A "$HOME/.pi/agent" 2>/dev/null)" ]; then
|
||||
pass "~/.pi/agent exists and is non-empty"
|
||||
else
|
||||
fail "~/.pi/agent missing or empty"
|
||||
fi
|
||||
|
||||
# shell history volume (devbox-shell-history). An empty .bash_history right
|
||||
# after recreate is NORMAL — only the mount point must exist.
|
||||
if [ -d "$HOME/.cache/bash" ]; then
|
||||
pass "~/.cache/bash exists as directory"
|
||||
else
|
||||
fail "~/.cache/bash missing or not a directory"
|
||||
fi
|
||||
|
||||
# remaining persisted volumes — mount points must exist
|
||||
for vol_path in \
|
||||
"$HOME/.local/share/zoxide" \
|
||||
"$HOME/.local/share/nvim" \
|
||||
"$HOME/.local/share/uv" \
|
||||
"$HOME/.ssh-local"; do
|
||||
if [ -d "$vol_path" ]; then
|
||||
pass "$vol_path exists"
|
||||
else
|
||||
fail "$vol_path missing or not a directory"
|
||||
fi
|
||||
done
|
||||
|
||||
# mempalace palace — CONDITIONAL. In this repo's docker-compose.yml the
|
||||
# devbox-palace named volume is commented out; the palace is reached via the
|
||||
# shared /workspace (virtiofs) path instead. So absence of a local palace dir
|
||||
# is NOT a recreate regression here.
|
||||
if [ -f "$HOME/.mempalace/palace/chroma.sqlite3" ]; then
|
||||
SIZE=$(du -h "$HOME/.mempalace/palace/chroma.sqlite3" | cut -f1)
|
||||
if [ -s "$HOME/.mempalace/palace/chroma.sqlite3" ]; then
|
||||
pass "~/.mempalace/palace/chroma.sqlite3 exists ($SIZE)"
|
||||
else
|
||||
fail "~/.mempalace/palace/chroma.sqlite3 exists but is empty"
|
||||
fi
|
||||
else
|
||||
warn "~/.mempalace/palace/chroma.sqlite3 absent — expected unless devbox-palace volume is enabled (palace is shared via /workspace by default)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- pi runtime wiring (deployed by entrypoint-user.sh) --"
|
||||
|
||||
# keybindings symlink (pi-toolkit)
|
||||
if [ -L "$HOME/.pi/agent/keybindings.json" ]; then
|
||||
pass "~/.pi/agent/keybindings.json symlink (pi-toolkit)"
|
||||
else
|
||||
fail "~/.pi/agent/keybindings.json missing or not a symlink"
|
||||
fi
|
||||
|
||||
# global AGENTS.md symlink (pi-toolkit) — global instructions loaded by pi at
|
||||
# every start (directs the agent to read the pi-extensions skill at session start)
|
||||
if [ -L "$HOME/.pi/agent/AGENTS.md" ]; then
|
||||
pass "~/.pi/agent/AGENTS.md symlink (pi-toolkit)"
|
||||
else
|
||||
fail "~/.pi/agent/AGENTS.md missing or not a symlink"
|
||||
fi
|
||||
|
||||
# extensions deployed (pi-extensions) — expect ≥4 *.ts
|
||||
EXT_COUNT=$(ls -1 "$HOME"/.pi/agent/extensions/*.ts 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$EXT_COUNT" -ge 4 ]; then
|
||||
pass "$EXT_COUNT extensions deployed (≥4, pi-extensions)"
|
||||
else
|
||||
fail "only $EXT_COUNT extensions deployed (expected ≥4)"
|
||||
fi
|
||||
|
||||
# mempalace.ts bridge symlink
|
||||
if [ -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
|
||||
pass "~/.pi/agent/extensions/mempalace.ts bridge symlink"
|
||||
else
|
||||
fail "~/.pi/agent/extensions/mempalace.ts missing or not a symlink"
|
||||
fi
|
||||
|
||||
# settings.json bootstrapped
|
||||
if [ -f "$HOME/.pi/agent/settings.json" ]; then
|
||||
pass "~/.pi/agent/settings.json bootstrapped"
|
||||
else
|
||||
fail "~/.pi/agent/settings.json missing"
|
||||
fi
|
||||
|
||||
# settings.json merge: the entrypoint deep-merges new template keys into a
|
||||
# preserved settings.json on every start, so config added in an image upgrade
|
||||
# (e.g. the observational-memory / pi-fork blocks) reaches existing volumes.
|
||||
# Assert those blocks are present and that the file is still valid JSON.
|
||||
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.pi/agent/settings.json" ]; then
|
||||
if jq -e 'has("observational-memory") and has("pi-fork")' "$HOME/.pi/agent/settings.json" >/dev/null 2>&1; then
|
||||
pass "settings.json has observational-memory + pi-fork blocks (template merge)"
|
||||
else
|
||||
fail "settings.json missing observational-memory and/or pi-fork blocks (template merge did not land)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# pi package registrations (pi install <local-path> → recorded in settings.json).
|
||||
# Check the `packages` ARRAY, not the whole file: the settings template ships a
|
||||
# top-level "pi-fork" CONFIG block (asserted just above), so `grep -q pi-fork
|
||||
# settings.json` is a guaranteed false green — which is how an un-registered
|
||||
# fork tool went unnoticed from v1.0.0 through v1.6.3. Same array check the
|
||||
# fixed entrypoint-user.sh guard uses.
|
||||
_pkg_registered() {
|
||||
_s="$HOME/.pi/agent/settings.json"
|
||||
[ -f "$_s" ] || return 1
|
||||
if command -v jq >/dev/null 2>&1; then
|
||||
jq -e --arg n "$1" \
|
||||
'(.packages // []) | any((type == "string") and (. == "npm:" + $n or endswith("/" + $n)))' \
|
||||
"$_s" >/dev/null 2>&1
|
||||
else
|
||||
grep -q "opt/$1\"" "$_s"
|
||||
fi
|
||||
}
|
||||
|
||||
# True when a literal `npm:pi-atelier` entry is still present — the
|
||||
# volume-resident registration the entrypoint migrates away from.
|
||||
_npm_atelier_present() {
|
||||
_s="$HOME/.pi/agent/settings.json"
|
||||
[ -f "$_s" ] || return 1
|
||||
command -v jq >/dev/null 2>&1 || return 1
|
||||
jq -e '(.packages // []) | any(. == "npm:pi-atelier")' "$_s" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
if [ -f "$HOME/.pi/agent/settings.json" ]; then
|
||||
for pkg in pi-fork pi-observational-memory; do
|
||||
if _pkg_registered "$pkg"; then
|
||||
pass "$pkg registered in settings.json packages[]"
|
||||
else
|
||||
fail "$pkg NOT in settings.json packages[] (tool will not load)"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$VARIANT" = "studio" ]; then
|
||||
if _pkg_registered pi-studio; then
|
||||
pass "pi-studio registered in settings.json packages[]"
|
||||
else
|
||||
fail "pi-studio NOT in settings.json packages[] (studio variant)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# pi-atelier — vendored from v1.7.0 on. Absent on older images, and
|
||||
# deliberately unregistered when DEVBOX_ATELIER=0; neither is a failure.
|
||||
if [ -d /opt/pi-atelier ]; then
|
||||
if [ "${DEVBOX_ATELIER:-1}" = "0" ]; then
|
||||
if _pkg_registered pi-atelier; then
|
||||
fail "pi-atelier still in packages[] despite DEVBOX_ATELIER=0"
|
||||
else
|
||||
pass "pi-atelier unregistered (DEVBOX_ATELIER=0, as requested)"
|
||||
fi
|
||||
elif _pkg_registered pi-atelier; then
|
||||
pass "pi-atelier registered in settings.json packages[]"
|
||||
else
|
||||
fail "pi-atelier NOT in settings.json packages[] (sidebar will not load)"
|
||||
fi
|
||||
if _npm_atelier_present; then
|
||||
fail "stale npm:pi-atelier still in packages[] — it resolves through the ~/.pi/npm-global VOLUME and shadows the pinned /opt copy (entrypoint migration did not run)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── agent-browser must resolve to the image, not the config volume ────
|
||||
# The same volume-shadowing hazard already asserted for pi (above) and
|
||||
# pi-atelier (just now), for the third package it has bitten. This check
|
||||
# belongs HERE rather than only in smoke-test.sh: a build-time container has an
|
||||
# empty ~/.pi/npm-global, so smoke-test can never see the stale copy that a
|
||||
# real recreate inherits. Measured instance: 0.27.0 from 2026-07-17 shadowed
|
||||
# the image's 0.35.2 for ~7 weeks on mbp-m1-2020, silently supplying an older
|
||||
# BUNDLED SKILL (3 skillsets vs 8) — the agent read the stale instructions
|
||||
# without any version mismatch ever being surfaced.
|
||||
AB_PATH=$(command -v agent-browser 2>/dev/null || true)
|
||||
if [ -z "$AB_PATH" ]; then
|
||||
warn "agent-browser not on PATH (expected in v1.6.0+ images; skipping shadow check)"
|
||||
else
|
||||
AB_REAL=$(readlink -f "$AB_PATH" 2>/dev/null || echo "$AB_PATH")
|
||||
AB_VER=$(agent-browser --version 2>/dev/null | head -n1)
|
||||
case "$AB_REAL" in
|
||||
/usr/*)
|
||||
pass "agent-browser resolves to the image copy (${AB_VER:-version unknown})"
|
||||
;;
|
||||
*)
|
||||
fail "agent-browser resolves to $AB_REAL (${AB_VER:-version unknown}) — a ~/.pi/npm-global VOLUME copy is shadowing the image; the entrypoint retirement guard did not run or could not move it"
|
||||
;;
|
||||
esac
|
||||
if [ -d "$HOME/.pi/npm-global/lib/node_modules/agent-browser" ]; then
|
||||
fail "stale agent-browser still present in the ~/.pi/npm-global volume (entrypoint guard did not retire it)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── pi <-> pi-atelier compatibility floor ─────────────────────────────
|
||||
# atelier < 0.7.1 wraps pi's private TUI renderer in a way that recurses under
|
||||
# pi >= 0.84: pi hangs at startup burning CPU, with no error message. atelier's
|
||||
# own peerDependencies (>=0.80.7) do not encode this. Assert it here too, not
|
||||
# just in the build-time smoke test: this script runs after a real
|
||||
# `--force-recreate` on a live box, where a volume-resident old copy is exactly
|
||||
# what could bite.
|
||||
if [ -d /opt/pi-atelier ] && command -v jq >/dev/null 2>&1; then
|
||||
_ge() { [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)" = "$2" ]; }
|
||||
_av=$(jq -r '.version // empty' /opt/pi-atelier/package.json 2>/dev/null || true)
|
||||
_pv=$(pi --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)
|
||||
if [ -n "$_av" ] && [ -n "$_pv" ]; then
|
||||
if _ge "$_pv" 0.84.0 && ! _ge "$_av" 0.7.1; then
|
||||
fail "pi $_pv with pi-atelier $_av — atelier < 0.7.1 hangs pi >= 0.84 at startup (bump PI_ATELIER_REF in Dockerfile.variant)"
|
||||
else
|
||||
pass "pi $_pv + pi-atelier $_av (compatibility floor OK)"
|
||||
fi
|
||||
else
|
||||
warn "could not compare pi/pi-atelier versions (pi='$_pv' atelier='$_av')"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- ssh ControlMaster: socket dir + EFFECTIVE ControlPath --"
|
||||
# TWO LAYERS, and the second is the one that has actually broken in the field.
|
||||
#
|
||||
# LAYER 1 (original check): /tmp/sshcm, the directory entrypoint-user.sh creates
|
||||
# for the base image's system drop-in
|
||||
# (/etc/ssh/ssh_config.d/00-devbox-controlmaster.conf).
|
||||
#
|
||||
# LAYER 2 (added 2026-09-15): the directory a config NAMES — which is not the
|
||||
# same question, and asserting layer 1 is structurally blind to it. On
|
||||
# emb-7kj4vr4g a durable ~/.pi/ssh/config pointed ControlPath at /tmp/ssh-cm
|
||||
# (with a hyphen), a directory nothing in the image creates. EVERY ssh died
|
||||
# unix_listener: cannot bind to path /tmp/ssh-cm/<hash>: No such file or directory
|
||||
# rc=255 with the remote command never running — while this script printed a
|
||||
# green tick for layer 1, truthfully, about the wrong object.
|
||||
#
|
||||
# The same rc=255 has a second, independent cause already documented in prose in
|
||||
# Dockerfile.base ("SSH client defaults" CAVEAT) and never verified anywhere: a
|
||||
# per-host `ControlPath ~/.ssh/cm/%r@%h:%p` inherited from a bind-mounted
|
||||
# READ-ONLY ~/.ssh. Measured to be the identical failure class:
|
||||
# unix_listener: cannot bind to path ~/.ssh/cm/...: Read-only file system
|
||||
# So do not guess which config wins — ask ssh. `ssh -G` applies real config
|
||||
# precedence (first-obtained-value-wins, system drop-in, Include, -F override)
|
||||
# and prints the fully expanded ControlPath. Require its parent to exist and be
|
||||
# writable. Cost measured at 0.116 s for 48 hosts; -G never opens a connection.
|
||||
if [ -d /tmp/sshcm ] && [ "$(stat -c %a /tmp/sshcm 2>/dev/null)" = "700" ]; then
|
||||
pass "/tmp/sshcm exists with mode 700"
|
||||
else
|
||||
fail "/tmp/sshcm missing or not mode 700"
|
||||
fi
|
||||
|
||||
# Probe one route. $1 = label, $2 = config to force with -F ("" = ssh's own
|
||||
# default precedence), $3 = severity when a ControlPath dir is unusable.
|
||||
#
|
||||
# SEVERITY SPLIT IS DELIBERATE. The default route legitimately resolves into the
|
||||
# read-only ~/.ssh on any host whose own config pins ControlPath there, and the
|
||||
# supported workaround (`ssh -F ~/.ssh-local/config`) already exists — so that
|
||||
# is a warn, not a fail. Failing it would paint this script red on every run of
|
||||
# every device, and a check that fires benignly every time is one you learn to
|
||||
# ignore. The sidecar route is the PRESCRIBED one, so there it is a hard fail.
|
||||
_ssh_cm_probe() {
|
||||
local label="$1" cfg="${2:-}" sev="${3:-fail}"
|
||||
local h out cm cp dir n=0 shown
|
||||
local bad=()
|
||||
while IFS= read -r h; do
|
||||
[ -n "$h" ] || continue
|
||||
if [ -n "$cfg" ]; then
|
||||
out=$(ssh -F "$cfg" -G "$h" 2>/dev/null) || continue
|
||||
else
|
||||
out=$(ssh -G "$h" 2>/dev/null) || continue
|
||||
fi
|
||||
cm=$(printf '%s\n' "$out" | awk '/^controlmaster /{print $2; exit}')
|
||||
case "$cm" in '' | no | none | false) continue ;; esac
|
||||
cp=$(printf '%s\n' "$out" | awk '/^controlpath /{print $2; exit}')
|
||||
case "$cp" in '' | none) continue ;; esac
|
||||
n=$((n + 1))
|
||||
dir=$(dirname "$cp")
|
||||
if [ ! -d "$dir" ] || [ ! -w "$dir" ]; then
|
||||
bad+=("$h")
|
||||
fi
|
||||
done <<< "$SSH_CM_HOSTS"
|
||||
|
||||
# Cap the host list. A 50-name line is the noisy gate this repo already warns
|
||||
# about in lint-shell.sh: unreadable output is ignored output. Six names plus
|
||||
# a count is enough to identify the class and act.
|
||||
if [ "${#bad[@]}" -gt 0 ]; then
|
||||
shown="${bad[*]:0:6}"
|
||||
if [ "${#bad[@]}" -gt 6 ]; then
|
||||
shown="$shown (+$(( ${#bad[@]} - 6 )) more)"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$n" -eq 0 ]; then
|
||||
warn "$label: no host resolves to ControlMaster on — effective ControlPath not exercised"
|
||||
elif [ "${#bad[@]}" -eq 0 ]; then
|
||||
pass "$label: $n ControlMaster host(s), every ControlPath dir exists and is writable"
|
||||
elif [ "$sev" = "warn" ]; then
|
||||
warn "$label: ${#bad[@]}/$n host(s) resolve ControlPath to a missing or unwritable dir [$shown] — expected when ~/.ssh/config pins ControlPath inside the read-only ~/.ssh; use 'ssh -F ~/.ssh-local/config' (see Dockerfile.base CAVEAT)"
|
||||
else
|
||||
fail "$label: ${#bad[@]}/$n host(s) resolve ControlPath to a missing or unwritable dir [$shown] — ssh dies rc=255 'unix_listener: cannot bind to path' and the remote command never runs"
|
||||
fi
|
||||
}
|
||||
|
||||
if command -v ssh >/dev/null 2>&1; then
|
||||
# Concrete Host aliases only: patterns (*, ?) and negations (!) are not
|
||||
# connectable targets, so `ssh -G` on them proves nothing.
|
||||
_cm_cfgs=()
|
||||
if [ -r "$HOME/.ssh/config" ]; then _cm_cfgs+=("$HOME/.ssh/config"); fi
|
||||
if [ -r "$HOME/.ssh-local/config" ]; then _cm_cfgs+=("$HOME/.ssh-local/config"); fi
|
||||
if [ "${#_cm_cfgs[@]}" -gt 0 ]; then
|
||||
SSH_CM_HOSTS=$(awk 'tolower($1)=="host"{for(i=2;i<=NF;i++) if ($i !~ /[*?!]/) print $i}' \
|
||||
"${_cm_cfgs[@]}" 2>/dev/null | sort -u)
|
||||
else
|
||||
SSH_CM_HOSTS=""
|
||||
fi
|
||||
|
||||
if [ -z "$SSH_CM_HOSTS" ]; then
|
||||
warn "no concrete Host aliases in ~/.ssh/config or ~/.ssh-local/config — effective ControlPath not verified"
|
||||
else
|
||||
_ssh_cm_probe "default ssh precedence" "" warn
|
||||
if [ -r "$HOME/.ssh-local/config" ]; then
|
||||
_ssh_cm_probe "ssh -F ~/.ssh-local/config" "$HOME/.ssh-local/config" fail
|
||||
else
|
||||
warn "~/.ssh-local/config absent — setup-lan-access.sh did not run; the prescribed multiplex route is unverified"
|
||||
fi
|
||||
fi
|
||||
else
|
||||
warn "ssh not on PATH — effective ControlPath not verified"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- Shell defaults re-seeded from /etc/skel-devbox --"
|
||||
if [ -f "$HOME/.bash_aliases" ]; then
|
||||
pass "~/.bash_aliases exists"
|
||||
else
|
||||
fail "~/.bash_aliases missing"
|
||||
fi
|
||||
|
||||
# History flush must survive shell nesting. The DEVBOX_HIST_SET guard must NOT
|
||||
# be exported: if it leaks into child processes, nested shells (esp. tmux
|
||||
# panes) skip installing `history -a` and lose in-memory history on abrupt
|
||||
# termination. Assert a child login shell still wires up the per-prompt flush.
|
||||
if bash -lic 'bash -lic "case \"\$PROMPT_COMMAND\" in *\"history -a\"*) exit 0;; *) exit 1;; esac"' </dev/null >/dev/null 2>&1; then
|
||||
pass "nested shell installs 'history -a' (DEVBOX_HIST_SET not exported)"
|
||||
else
|
||||
fail "nested shell missing 'history -a' — DEVBOX_HIST_SET leaking to children?"
|
||||
fi
|
||||
|
||||
if [ -f "$HOME/.inputrc" ]; then
|
||||
pass "~/.inputrc exists"
|
||||
else
|
||||
fail "~/.inputrc missing"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- cli_utils bind-mount --"
|
||||
if [ -d /workspace/cli_utils ] && [ -d /workspace/cli_utils/.git ]; then
|
||||
pass "/workspace/cli_utils exists with .git subdir"
|
||||
else
|
||||
warn "/workspace/cli_utils missing or .git subdir absent — expected only if cli_utils is bind-mounted"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- Baked /opt toolkits --"
|
||||
for opt_path in /opt/pi-toolkit /opt/pi-extensions /opt/pi-fork /opt/pi-observational-memory /opt/mempalace-toolkit; do
|
||||
if [ -d "$opt_path" ]; then
|
||||
pass "$opt_path exists"
|
||||
else
|
||||
fail "$opt_path missing"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$VARIANT" = "studio" ]; then
|
||||
if [ -d /opt/pi-studio ] && [ -f /opt/pi-studio/client/studio-client.js ]; then
|
||||
pass "/opt/pi-studio exists with prebuilt client bundle"
|
||||
else
|
||||
fail "/opt/pi-studio missing or prebuilt client bundle absent (studio variant)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# mempalace MCP entrypoint on PATH
|
||||
if command -v mempalace-mcp >/dev/null 2>&1; then
|
||||
pass "mempalace-mcp on PATH"
|
||||
else
|
||||
fail "mempalace-mcp not on PATH"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- Known expected-absences (regressions vs by-design) --"
|
||||
if ! command -v go >/dev/null 2>&1; then
|
||||
warn "go absent — expected unless image built with INSTALL_GO=true"
|
||||
else
|
||||
pass "go is on PATH"
|
||||
fi
|
||||
|
||||
if [ "$VARIANT" = "plain" ] && [ ! -d /opt/pi-studio ]; then
|
||||
warn "/opt/pi-studio absent — expected on the plain (non-studio) variant"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo "=== FAILED: $FAILED check(s) ===" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "=== PASSED ==="
|
||||
+920
-17
File diff suppressed because it is too large
Load Diff
Executable
+293
@@ -0,0 +1,293 @@
|
||||
#!/usr/bin/env bash
|
||||
# vendor-mempalace-skill.sh — refresh the vendored mempalace skill snapshot
|
||||
# AND its recorded provenance, together, so the two cannot drift apart.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# ---------------
|
||||
# rootfs/usr/local/share/pi-devbox/skills/mempalace/SKILL.md is a snapshot of a
|
||||
# file owned by the PRIVATE skillset repo (see VENDORED.md). Because the image
|
||||
# cannot clone that repo, refreshing the snapshot was a manual `cp` — and the
|
||||
# result was anonymous: nothing recorded WHICH skillset commit the bytes came
|
||||
# from. The only staleness check available was a hand-maintained phrase canary
|
||||
# in scripts/smoke-test.sh, which by construction detects "older than the phrase
|
||||
# I remembered to pin", never "older than skillset main".
|
||||
#
|
||||
# Two facts now travel with the snapshot: the skillset commit it was taken from
|
||||
# (ARG SKILLSET_SNAPSHOT_REF in Dockerfile.variant) and the sha256 of the bytes
|
||||
# themselves (measured at build time into build-manifest.json). This script is
|
||||
# the only thing that should ever write the first one, because a `cp` without a
|
||||
# matching ARG bump produces a manifest that CONFIDENTLY LIES — worse than the
|
||||
# anonymous snapshot it replaced.
|
||||
#
|
||||
# HARDENED after peer review (pi@emb-7kj4vr4g, logstream correlation
|
||||
# skills-provenance-review, 2026-08-26) proved the original --check could print
|
||||
# OK and exit 0 without actually verifying anything: `git show <ref>:<path>`
|
||||
# emits NOTHING when the ref/path doesn't resolve, and `sha256sum` still hashes
|
||||
# that empty stdin, so "ref not found" silently collided with "the file really
|
||||
# is 0 bytes". Depending on which side of the comparison hit the collision this
|
||||
# fell through as either a false MISMATCH (blaming provenance for what was
|
||||
# really an incomplete clone) or, worse, a false OK. See EXIT STATUS below —
|
||||
# "cannot determine" is now its own outcome, distinct from "confirmed wrong",
|
||||
# which is the same distinction the phrase canary this script replaced lacked.
|
||||
#
|
||||
# USAGE
|
||||
# scripts/vendor-mempalace-skill.sh [skillset-root] [--force]
|
||||
# refresh: rewrite the snapshot and the ARG together.
|
||||
# scripts/vendor-mempalace-skill.sh --check [skillset-root]
|
||||
# verify only, writes nothing. The root path and any flag may appear in
|
||||
# either order — a positional-only parser previously made `<root>
|
||||
# --check` silently run a refresh instead of the verification asked for.
|
||||
#
|
||||
# skillset-root defaults to /workspace/skillset, then $HOME/skillset.
|
||||
#
|
||||
# --force (refresh mode only) proceed even when the recorded ref cannot be
|
||||
# proven to be an ancestor of the skillset's current HEAD — i.e.
|
||||
# skip the guard against silently REWINDING provenance, which a
|
||||
# detached HEAD, an older checkout, or a shallow clone lacking the
|
||||
# recorded commit can all trigger. Meant to be used deliberately,
|
||||
# not habitually: each use is a human deciding a rewind is fine.
|
||||
#
|
||||
# --check answers "is the committed snapshot really skillset@<recorded ref>?"
|
||||
# — the question CI cannot answer without a credential for the private repo,
|
||||
# and which anyone with the skillset checked out can answer for free.
|
||||
#
|
||||
# EXIT STATUS (same three codes in both modes)
|
||||
# 0 the operation succeeded, or (--check) the record is verified truthful.
|
||||
# This INCLUDES a truthful record that is merely stale — upstream has
|
||||
# moved on since the recorded ref, or the local working tree has since
|
||||
# diverged. A NOTICE is printed to stderr, but the snapshot is not being
|
||||
# accused of lying, so this is not a release-blocking failure. Skipping a
|
||||
# refresh is a legitimate release-day choice (see AGENTS.md); this exit
|
||||
# code is what makes that choice checkable rather than merely asserted.
|
||||
# 1 refused: a CONFIRMED problem. Dirty upstream file; a refresh that would
|
||||
# rewind past the recorded ref; or (--check) the vendored bytes provably
|
||||
# do NOT match the file at the recorded ref — a lying record.
|
||||
# 2 cannot determine: the recorded ref, or the path at that ref, is not
|
||||
# resolvable in this clone. Commonly a shallow clone missing history, or
|
||||
# a ref that was rewritten or never pushed. Deliberately NOT the same as
|
||||
# 1 — "I can't tell" must never be reported as "it's wrong".
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
DOCKERFILE="Dockerfile.variant"
|
||||
VENDORED="rootfs/usr/local/share/pi-devbox/skills/mempalace/SKILL.md"
|
||||
ARG_NAME="SKILLSET_SNAPSHOT_REF"
|
||||
REL_PATH="skills/mempalace/SKILL.md"
|
||||
|
||||
die() { printf '%s: %s\n' "$(basename "$0")" "$1" >&2; exit 1; }
|
||||
|
||||
# Parse flags and the optional root path in either order, and reject anything
|
||||
# unrecognised rather than silently absorbing it.
|
||||
MODE="refresh"
|
||||
FORCE=0
|
||||
ROOT=""
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--check) MODE="check" ;;
|
||||
--force) FORCE=1 ;;
|
||||
# This is the one script whose argument ORDER was itself a landmine, so the
|
||||
# path that documents the trap must not be the path that errors.
|
||||
-h|--help)
|
||||
awk 'NR>1 && /^#/ { sub(/^# ?/, ""); print; next } NR>1 { exit }' "$0"
|
||||
exit 0
|
||||
;;
|
||||
--*) die "unknown option: $arg (try --help)" ;;
|
||||
*)
|
||||
[ -z "$ROOT" ] || die "unexpected extra argument: $arg (root already set to $ROOT)"
|
||||
ROOT="$arg"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
if [ "$MODE" = "check" ] && [ "$FORCE" = 1 ]; then
|
||||
die "--force has no effect with --check (nothing is written); remove it"
|
||||
fi
|
||||
|
||||
if [ -z "$ROOT" ]; then
|
||||
for candidate in /workspace/skillset "$HOME/skillset"; do
|
||||
if [ -d "$candidate/.git" ]; then
|
||||
ROOT="$candidate"
|
||||
break
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
[ -n "$ROOT" ] || die "no skillset clone found (pass one: $(basename "$0") /path/to/skillset)"
|
||||
[ -d "$ROOT/.git" ] || die "not a git clone: $ROOT"
|
||||
[ -f "$ROOT/$REL_PATH" ] || die "no $REL_PATH in $ROOT"
|
||||
[ -f "$VENDORED" ] || die "vendored snapshot missing: $VENDORED"
|
||||
# LOAD-BEARING, DO NOT DELETE AS "REDUNDANT WITH THE EXISTENCE PROBES": -f
|
||||
# accepts an empty file, and sha256 of an empty file equals sha256 of a failed
|
||||
# pipeline's empty stdin. Guarding it HERE, before mode dispatch, makes that
|
||||
# collision unreachable by construction rather than by a probe further down --
|
||||
# which also means no test below exercises the collision any more. Remove this
|
||||
# line and the false "OK" for a nonexistent ref returns with nothing failing.
|
||||
[ -s "$VENDORED" ] || die "vendored snapshot is empty: $VENDORED"
|
||||
|
||||
head_sha=$(git -C "$ROOT" rev-parse HEAD 2>/dev/null) || die "cannot read HEAD of $ROOT"
|
||||
recorded=$(grep -oE "^ARG ${ARG_NAME}=[0-9a-f]{40}$" "$DOCKERFILE" | cut -d= -f2 || true)
|
||||
[ -n "$recorded" ] || die "no 'ARG ${ARG_NAME}=<40-hex>' line in $DOCKERFILE"
|
||||
|
||||
sha_of() { sha256sum "$1" | cut -d' ' -f1; }
|
||||
vendored_sha=$(sha_of "$VENDORED")
|
||||
upstream_sha=$(sha_of "$ROOT/$REL_PATH")
|
||||
|
||||
# Does $REL_PATH exist at HEAD at all? Proven with `cat-file -e` BEFORE
|
||||
# hashing anything. Piping a failed `git show` straight into sha256sum, as
|
||||
# this script used to, hashes an EMPTY stream and produces sha256(""): a real,
|
||||
# collidable value — not a representation of absence. That collapsed "doesn't
|
||||
# exist" and "exists and happens to be empty" into the same signal, which is
|
||||
# exactly the defect class the peer review found in --check's at_ref, below.
|
||||
blob_sha=""
|
||||
if git -C "$ROOT" cat-file -e "HEAD:$REL_PATH" 2>/dev/null; then
|
||||
blob_sha=$(git -C "$ROOT" show "HEAD:$REL_PATH" | sha256sum | cut -d' ' -f1)
|
||||
fi
|
||||
|
||||
upstream_dirty=""
|
||||
if [ -z "$blob_sha" ]; then
|
||||
upstream_dirty="not present at HEAD (untracked, or absent at this commit)"
|
||||
elif [ "$blob_sha" != "$upstream_sha" ]; then
|
||||
if ! git -C "$ROOT" diff --quiet -- "$REL_PATH" 2>/dev/null; then
|
||||
upstream_dirty="modified but not committed"
|
||||
elif ! git -C "$ROOT" diff --cached --quiet -- "$REL_PATH" 2>/dev/null; then
|
||||
upstream_dirty="staged but not committed"
|
||||
else
|
||||
upstream_dirty="different at HEAD than in the working tree"
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ "$MODE" = "check" ]; then
|
||||
# Resolve the recorded ref the same careful way: existence is proven with
|
||||
# `cat-file -e` before anything is hashed, and "the ref itself is missing"
|
||||
# is reported distinctly from "the ref resolves but the path isn't there
|
||||
# at it" — both used to be silently swallowed into a plausible sha256("").
|
||||
ref_exists=0
|
||||
path_at_ref_exists=0
|
||||
at_ref=""
|
||||
if git -C "$ROOT" cat-file -e "${recorded}^{commit}" 2>/dev/null; then
|
||||
ref_exists=1
|
||||
if git -C "$ROOT" cat-file -e "${recorded}:${REL_PATH}" 2>/dev/null; then
|
||||
path_at_ref_exists=1
|
||||
at_ref=$(git -C "$ROOT" show "${recorded}:${REL_PATH}" | sha256sum | cut -d' ' -f1)
|
||||
fi
|
||||
fi
|
||||
|
||||
printf 'recorded ref: %s\n' "$recorded"
|
||||
printf 'vendored sha256: %s\n' "$vendored_sha"
|
||||
if [ "$path_at_ref_exists" = 1 ]; then
|
||||
printf 'sha256 at ref: %s\n' "$at_ref"
|
||||
elif [ "$ref_exists" = 1 ]; then
|
||||
printf 'sha256 at ref: <%s not present at %s>\n' "$REL_PATH" "${recorded:0:7}"
|
||||
else
|
||||
printf 'sha256 at ref: <%s not present in this clone>\n' "${recorded:0:7}"
|
||||
fi
|
||||
printf 'skillset HEAD: %s (%s)\n' "$head_sha" "$upstream_sha"
|
||||
if [ -n "$upstream_dirty" ]; then
|
||||
printf 'live working tree: %s\n' "$upstream_dirty"
|
||||
fi
|
||||
|
||||
rc=0
|
||||
if [ "$ref_exists" != 1 ]; then
|
||||
printf 'CANNOT-DETERMINE: %s is not present in %s — fetch, or check against a complete clone\n' "$recorded" "$ROOT" >&2
|
||||
rc=2
|
||||
elif [ "$path_at_ref_exists" != 1 ]; then
|
||||
printf 'MISMATCH: %s does not exist at %s in this clone — the recorded ref cannot be describing these bytes\n' "$REL_PATH" "$recorded" >&2
|
||||
rc=1
|
||||
elif [ "$at_ref" != "$vendored_sha" ]; then
|
||||
printf 'MISMATCH: the vendored snapshot is NOT the file at the recorded ref\n' >&2
|
||||
rc=1
|
||||
else
|
||||
printf 'OK: the vendored snapshot is exactly skillset@%s:%s\n' "${recorded:0:7}" "$REL_PATH"
|
||||
fi
|
||||
|
||||
# Staleness is orthogonal to truthfulness: a record can correctly describe
|
||||
# an old commit even after upstream has moved on, and a dirty local working
|
||||
# tree in $ROOT doesn't rewrite git history either — it says nothing about
|
||||
# whether the RECORDED, committed ref describes the RECORDED, committed
|
||||
# bytes. Only worth reporting once we already know rc=0 (truthful) — a
|
||||
# MISMATCH or CANNOT-DETERMINE is the dominant fact and a staleness note
|
||||
# would only muddy it.
|
||||
if [ "$rc" = 0 ] && [ "$vendored_sha" != "$upstream_sha" ]; then
|
||||
# Name the ACTUAL cause. "working tree differs" is wrong when the tree is
|
||||
# clean and the ref simply moved on — a message that names the wrong cause
|
||||
# is the same defect class as a canary pinned to a deleted phrase.
|
||||
if [ "$recorded" != "$head_sha" ] && [ "$blob_sha" = "$upstream_sha" ]; then
|
||||
# Do not ASSERT which side is newer — test it. Asserting that HEAD is the
|
||||
# newer side points the operator at a refresh (which costs a ~67-minute
|
||||
# base rebuild) when the real remedy may be `git pull` in this clone. The
|
||||
# refresh path below already uses this primitive; reuse it here.
|
||||
if git -C "$ROOT" merge-base --is-ancestor "$recorded" "$head_sha" 2>/dev/null; then
|
||||
printf 'NOTICE: %s has moved on to %s; the snapshot describes the older %s (stale, not untruthful — refresh to catch up)\n' \
|
||||
"$ROOT" "${head_sha:0:7}" "${recorded:0:7}" >&2
|
||||
elif git -C "$ROOT" merge-base --is-ancestor "$head_sha" "$recorded" 2>/dev/null; then
|
||||
printf 'NOTICE: %s is BEHIND at %s; the snapshot describes the newer %s — pull this clone, do NOT refresh the snapshot\n' \
|
||||
"$ROOT" "${head_sha:0:7}" "${recorded:0:7}" >&2
|
||||
else
|
||||
printf 'NOTICE: %s (HEAD %s) and the recorded %s have DIVERGED — neither is an ancestor of the other; reconcile the clone before refreshing\n' \
|
||||
"$ROOT" "${head_sha:0:7}" "${recorded:0:7}" >&2
|
||||
fi
|
||||
else
|
||||
printf 'NOTICE: the working tree of %s differs from the snapshot (HEAD %s)\n' \
|
||||
"$ROOT" "${head_sha:0:7}" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
exit "$rc"
|
||||
fi
|
||||
|
||||
[ -z "$upstream_dirty" ] || die "$ROOT/$REL_PATH is $upstream_dirty — commit it first, or the recorded ref would not describe these bytes"
|
||||
|
||||
if [ "$vendored_sha" = "$upstream_sha" ] && [ "$recorded" = "$head_sha" ]; then
|
||||
printf 'already current: snapshot == skillset@%s\n' "${head_sha:0:7}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Refuse to silently REWIND provenance. `git checkout <tag>`, a detached HEAD,
|
||||
# or an older checkout can all leave $ROOT's HEAD behind the already-recorded
|
||||
# ref; without this guard a refresh there would happily rewrite both the ARG
|
||||
# and the bytes backwards and report it as an ordinary update.
|
||||
if [ "$recorded" != "$head_sha" ]; then
|
||||
if git -C "$ROOT" cat-file -e "${recorded}^{commit}" 2>/dev/null; then
|
||||
if ! git -C "$ROOT" merge-base --is-ancestor "$recorded" "$head_sha" 2>/dev/null; then
|
||||
if [ "$FORCE" != 1 ]; then
|
||||
die "refusing: $ROOT's HEAD ($head_sha) is not a descendant of the recorded ref ($recorded) — this looks like a rewind. Pass --force if this is intentional."
|
||||
fi
|
||||
printf 'WARNING: --force set; %s is not an ancestor of HEAD %s — proceeding anyway\n' "${recorded:0:7}" "${head_sha:0:7}" >&2
|
||||
fi
|
||||
else
|
||||
if [ "$FORCE" != 1 ]; then
|
||||
printf 'CANNOT-DETERMINE: %s is not present in %s (shallow clone?) — fetch full history to verify this refresh moves forward, or pass --force to proceed without that guarantee\n' "$recorded" "$ROOT" >&2
|
||||
exit 2
|
||||
fi
|
||||
printf 'WARNING: --force set; %s could not be resolved in %s — proceeding without verifying forward motion\n' "${recorded:0:7}" "$ROOT" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# Written FROM THE REF, not copied from the working tree, so the pair cannot
|
||||
# be a lie by construction. Via a temp file so a failed write cannot leave a
|
||||
# half-vendored snapshot behind.
|
||||
snap_tmp=$(mktemp)
|
||||
if ! git -C "$ROOT" show "HEAD:$REL_PATH" > "$snap_tmp" 2>/dev/null; then
|
||||
rm -f -- "$snap_tmp"
|
||||
die "cannot read HEAD:$REL_PATH from $ROOT"
|
||||
fi
|
||||
chmod 0644 -- "$snap_tmp"
|
||||
mv -- "$snap_tmp" "$VENDORED"
|
||||
[ "$(sha_of "$VENDORED")" = "$blob_sha" ] \
|
||||
|| die "internal: written snapshot does not match HEAD:$REL_PATH"
|
||||
|
||||
# In-place, and only the exact pinned line: a broad sed on this Dockerfile
|
||||
# could rewrite one of the other *_REF ARGs.
|
||||
tmp=$(mktemp)
|
||||
sed "s|^ARG ${ARG_NAME}=.*\$|ARG ${ARG_NAME}=${head_sha}|" "$DOCKERFILE" > "$tmp"
|
||||
chmod 0644 -- "$tmp"
|
||||
mv -- "$tmp" "$DOCKERFILE"
|
||||
|
||||
new_recorded=$(grep -oE "^ARG ${ARG_NAME}=[0-9a-f]{40}$" "$DOCKERFILE" | cut -d= -f2 || true)
|
||||
[ "$new_recorded" = "$head_sha" ] || die "failed to rewrite ${ARG_NAME} in $DOCKERFILE"
|
||||
|
||||
printf 'snapshot: %s -> %s\n' "${vendored_sha:0:12}" "$(sha_of "$VENDORED" | cut -c1-12)"
|
||||
printf 'ref: %s -> %s\n' "${recorded:0:7}" "${head_sha:0:7}"
|
||||
printf '\nNOTE: %s is hashed into base_tag, so this costs a base rebuild\n' "$VENDORED"
|
||||
printf 'on the next tag (~67 min). Also re-pin the phrase canary in\n'
|
||||
printf 'scripts/smoke-test.sh if the section it names changed.\n'
|
||||
Reference in New Issue
Block a user