8248688d58
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.
322 lines
11 KiB
Bash
Executable File
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 ==="
|