f25efa074d
recreate-sanity-check.sh asserted `/tmp/sshcm exists with mode 700` and printed
a green tick while every ssh in the container died rc=255. It was right about
what it checked: the breakage was in the directory a CONFIG named, not the one
the image creates, and the old check could not see the disagreement between them.
Found on the v1.9.2 first boot on emb-7kj4vr4g. A durable ~/.pi/ssh/config,
hand-written into the ~/.pi named volume by the previous session so it would
survive the recreate, declared `ControlPath /tmp/ssh-cm/%C` — with a hyphen.
Nothing here creates that path; the canonical spelling is /tmp/sshcm, identical
in Dockerfile.base, entrypoint-user.sh, recreate-sanity-check.sh and
smoke-test.sh. ControlMaster auto with an unusable ControlPath does not degrade
to an unmultiplexed connection — it fails hard:
unix_listener: cannot bind to path /tmp/ssh-cm/<hash>: No such file or directory
rc=255, remote command never runs.
Now: resolve the ControlPath ssh itself would use via `ssh -G` and require its
parent to exist and be writable. -G applies real config precedence (first-value-
wins, the system drop-in, Include, an -F override), so it answers "which rule
captured this host" instead of re-implementing the guess, and it never opens a
connection — 0.116s for 48 hosts.
This also puts a check under a caveat documented in prose in Dockerfile.base
("SSH client defaults") and verified nowhere: a per-host ControlPath under a
read-only bind-mounted ~/.ssh gives the identical failure, "Read-only file
system". On the machine this was built on that is 16 of 50 hosts — freeipa-1..6,
gitea.egl.lan, runner-1..3, tor-ms22 — never reported by anything before.
Severity split is deliberate. Default ssh precedence legitimately lands in the
read-only ~/.ssh for any host whose own config pins it there, and the supported
workaround (ssh -F ~/.ssh-local/config, generated every start by
setup-lan-access.sh) already exists, so that is a warn. Failing it would paint
the script red on every run of every device, and a check that fires benignly
every time is one you learn to ignore — the same reasoning that keeps
lint-shell.sh at -S error. The sidecar route is prescribed, so there it is a
hard fail. Host lists cap at six names plus a count: unreadable output is
ignored output.
Teeth proven both directions, each sabotage confirmed by diff BEFORE the result
was believed: ControlPath -> nonexistent dir => rc=1; -> existing-but-read-only
dir => rc=1; restored => rc=0 with the sidecar byte-identical.
No config was added to fix the original problem — the fix was to DELETE
~/.pi/ssh/config, a third hand-maintained copy of what setup-lan-access.sh
already generates from version control on every container start, with fewer
features and one typo. The host-owned ~/.ssh/config is left alone on purpose:
those ~/.ssh/cm paths are correct on the host, where ~/.ssh is writable.
Also lint-shell.sh: the SC2088 count in the severity-choice rationale said 19
and is now 20, with the reproduce command recorded. That command needs a `$ `
prefix — a comment whose first word is "shellcheck" is parsed as a directive,
and the malformed one tripped SC1072/SC1073 at severity error. The gate caught
it on the very commit that introduced it.
613 lines
25 KiB
Bash
Executable File
613 lines
25 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 (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 ==="
|