Files
pi-devbox/scripts/recreate-sanity-check.sh
T
joakimp 8248688d58
Lint / actionlint (push) Successful in 32s
Lint / hadolint (push) Successful in 1m25s
fix(entrypoint,tests): register pi-fork — guard matched its own config block
The `pi install /opt/<pkg>` loop in entrypoint-user.sh guarded on a
whole-file substring grep of ~/.pi/agent/settings.json. settings.example.json
ships a top-level "pi-fork" CONFIG block (fork effort profiles, pi-toolkit
adb6907, 2026-06-17), so `grep -q pi-fork settings.json` matched the config
key itself and `pi install /opt/pi-fork` never ran — on fresh or preserved
volumes. The `fork` tool has therefore been absent since v1.0.0.

The 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.

Guard now inspects the `packages` array via jq, with a grep fallback on the
stored `.../opt/<name>"` path form, which a config key can never produce.
Existing volumes self-heal on the next container start.

Both test suites asserted the bug as green — smoke-test.sh:244 and
recreate-sanity-check.sh:204 used the same whole-file grep, so "pi-fork
registered (fork tool)" passed on every build and recreate while the tool was
missing. Both now assert against packages[] with the entrypoint's predicate,
labels say packages[], and the smoke readiness wait loop uses the array check
plus `docker exec -u developer` + $HOME instead of a hard-coded
/home/developer path.

Evidence: zero `fork` tool calls across all 19 sessions on this volume; the
v1.6.3 session that tuned pi-fork.deep to opus-5 was configuring a tool that
never loaded.
2026-07-29 19:21:49 +02:00

322 lines
11 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
}
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
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 ==="