Files
pi-devbox/scripts/recreate-sanity-check.sh
T
joakimp 43cd6e22f2
Publish Docker Image / resolve-versions (push) Successful in 9s
Lint / actionlint (push) Successful in 15s
Lint / hadolint (push) Successful in 13s
Publish Docker Image / base-decide (push) Successful in 8s
Publish Docker Image / build-base (push) Successful in 41m22s
Publish Docker Image / smoke-studio (push) Successful in 5m16s
Publish Docker Image / smoke (push) Successful in 7m31s
Publish Docker Image / build-variant-studio (push) Successful in 18m31s
Publish Docker Image / build-variant (push) Successful in 27m18s
Publish Docker Image / promote-base-latest (push) Successful in 11s
Publish Docker Image / update-description (push) Successful in 12s
v1.7.0: bundle pi-atelier at a pinned tag; pin pi to an audited 0.84.1
Two changes that belong together, because the first is what makes the second
dangerous to get wrong.

pi-atelier (TUI sidebar + status rail) is now vendored to /opt/pi-atelier at
PI_ATELIER_REF=v0.8.0 and registered by entrypoint-user.sh — the pi-fork /
pi-observational-memory / pi-studio pattern, deliberately NOT
`pi install npm:pi-atelier`, which writes into ~/.pi/npm-global on the config
volume where it shadows the image and pins nothing. Unlike its siblings it gets
no `npm install`: atelier declares zero runtime deps (peerDeps only, satisfied by
the baked pi) and has no build step, so pi loads its TypeScript straight from the
checkout via package.json `pi.extensions`.

pi is no longer resolved to npm `latest` at build time. The pin lives in
Dockerfile.variant and CI reads it from there, so a local `docker build` and a CI
release ship the same versions by construction. The pin is a CHECKPOINT, NOT A
FREEZE: bumping stays a one-line change; what stops is *unreviewed* adoption of
whatever shipped that morning, in the same build that then gets tagged and
published. CI fails when a pin is not concrete or not actually published on npm,
and warns — never adopts — when npm latest moves ahead, naming what to re-check.

Why this pairing needed care: pi-atelier 0.6.0/0.7.0 wrap pi's PRIVATE TUI
renderer, and under pi 0.84 that wrapper recurses — pi hangs at startup burning
CPU with no error. Upstream fixed the recursion in 0.7.1 and restored the
non-overlapping split in 0.7.2; 0.8.0 is additive on top. atelier's own
peerDependencies still say >=0.80.7, which does not express that floor, so
nothing in npm metadata could have warned us. The floor is therefore encoded as
an executable rule — pi >= 0.84 => pi-atelier >= 0.7.1 — asserted in both
smoke-test.sh (build time) and recreate-sanity-check.sh (after a real recreate),
verified against a 4x4 version matrix.

Existing volumes needed migration, not just vendoring: a hand-installed
`npm:pi-atelier` entry is counted as already-registered by the entrypoint guard,
so every existing volume would have kept its unpinned npm copy — and a 0.6.x copy
next to pi 0.84 is exactly the startup hang. The entrypoint now drops that one
exact string (settings.json.bak.atelier.<ts> backup, distinct prefix so it cannot
clobber the template merge's backup in the same second) and lets the pinned /opt
copy register. Tested against a real settings.json: only that entry removed,
other packages and all keys intact, idempotent, and unparseable JSON leaves the
file untouched. DEVBOX_ATELIER=0 opts out entirely — in the entrypoint rather
than via `pi uninstall`, because this component's failure mode is "pi will not
start", which cannot be repaired from inside pi.

0.84.1 was audited for this release, not merely adopted: theme/TUI changes are
additive, the session format is unchanged (CURRENT_SESSION_VERSION = 3 in both
0.83.0 and 0.84.1 with an identical migrateV1ToV2/migrateV2ToV3 ladder, so
existing transcripts are neither migrated nor at risk and pi-session-repair stays
valid), and the Node engine floor is unmoved at >=22.19.0. CI resolves the
atelier tag to its PEELED commit SHA — atelier uses annotated tags, so the
unpeeled ref is a tag object, not a commit; pi-studio's lightweight tags never
exposed that distinction.

Also: docs for overriding the read-only ~/.ssh/config from the container —
container-only keys in ~/.ssh-local, hardened authorized_keys, the fact that
`from=` must allow the HOST's addresses because container egress is NAT'd through
it, and the macOS-only-keyword trap (`UseKeychain` is fatal to Linux OpenSSH and
takes out dssh/pi --ssh while the host keeps working). Corrects two claims in
"Naming LAN peers": ssh-lan.conf is not ProxyJump-only, and first-time creation
does need one restart because the Include is emitted only when the file already
exists at start.
2026-08-07 21:34:41 +02:00

372 lines
13 KiB
Bash
Executable File

#!/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 (pi version matches, when an expected
# version is supplied — see the version note 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
# - /tmp/sshcm exists with mode 700 (ssh ControlMaster dir)
# - /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.
#
# Version note: pi's version is resolved from `latest` at CI build time and is
# NOT pinned to a concrete value in Dockerfile.variant (ARG PI_VERSION=latest).
# So unlike opencode-devbox, this script cannot self-derive an expected version
# from the Dockerfile. Pass --expected-version to assert a match; without it the
# live pi version is reported as an informational WARN, not a failure.
#
# Usage: ./scripts/recreate-sanity-check.sh [--expected-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=""
VARIANT=""
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# Parse arguments
while [[ $# -gt 0 ]]; do
case "$1" in
--expected-version)
EXPECTED_VERSION="$2"
shift 2
;;
--variant)
VARIANT="$2"
shift 2
;;
*)
echo "usage: $0 [--expected-version X.Y.Z] [--variant studio|plain]" >&2
exit 2
;;
esac
done
FAILED=0
pass() { echo "$1"; }
fail() { echo "$1" >&2; FAILED=$((FAILED + 1)); }
warn() { echo "$1" >&2; }
# 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
echo "-- pi version --"
if ACTUAL_VERSION=$(pi --version 2>&1 | head -1); then
if [ -n "$EXPECTED_VERSION" ]; then
if [ "$ACTUAL_VERSION" = "$EXPECTED_VERSION" ]; then
pass "pi version $ACTUAL_VERSION"
else
fail "pi version mismatch: expected $EXPECTED_VERSION, got $ACTUAL_VERSION"
fi
else
warn "pi version $ACTUAL_VERSION (no --expected-version given; pi is built from 'latest', cannot self-derive — informational only)"
fi
else
fail "pi --version failed"
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
# ── 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 dir --"
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
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 ==="