2278b22ba7
~/.ssh is commonly bind-mounted READ-ONLY from the host, so a per-host `ControlPath ~/.ssh/cm/%r@%h:%p` — the standard CGNAT multiplexing recipe, and correct on the host — resolves inside an unwritable dir in the container. Every push dies `unix_listener: cannot bind to path ...: Read-only file system`, behind git's misleading "make sure you have the correct access rights". setup-lan-access.sh already writes the fix: ~/.ssh-local/config overrides ControlPath into the writable ~/.ssh-local/cm BEFORE `Include ~/.ssh/config`, so -F repairs the socket path and keeps every per-host User/Port/IdentityFile. entrypoint-user.sh now points git at it, guarded on the sidecar existing — setup-lan-access.sh writes none on native Linux Docker, where -F at a missing file would break every git-over-ssh call instead of fixing one. An existing core.sshCommand is left alone (first-wins, as for the three git settings above). Why code and not another doc line: the remedy was already in the global AGENTS.md, in pi-devbox-environment SKILL.md §3, in 24 MemPalace drawers from three devices, and printed verbatim by recreate-sanity-check.sh — and an agent that had run that script two hours earlier still hit the failure and reinvented a /tmp/sshcm workaround. A fifth copy was not the missing piece. Assertions, each where it can actually pass: - smoke-test.sh: two STATIC greps (wiring line + its [ -r ] guard). `run` uses --entrypoint="", so asserting the runtime value there would repeat the v1.8.0 mistake of an assertion that cannot pass, unvalidated until the next tag. - smoke-test.sh runtime phase: a BICONDITIONAL — sidecar present => must route through it; absent => must be unset. The absent arm is the one CI exercises (native Linux runner), so "is set" would have failed CI for a correct image. - recreate-sanity-check.sh: the runtime assertion, plus an explicit fail for the inverted state (set while the sidecar is missing). The permanent "default ssh precedence" warning keeps its severity but now states that it is structural and can never reach zero, and whether git is wired, unwired, or has no sidecar. All five arms exercised against the real script before commit; that caught a defect in the first draft, which reported "git IS wired ... unaffected" about a state where the sidecar was gone and every git-over-ssh call failed. Host ~/.ssh/config needs no change: the same line is right on the host and unusable through a read-only mount, so the fix belongs in the container layer.
673 lines
29 KiB
Bash
Executable File
673 lines
29 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] — STRUCTURAL and permanent while ~/.ssh/config pins ControlPath inside the read-only ~/.ssh, so this line can never reach zero and is not a to-do; $_git_ssh_note (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
|
|
# Whether git-over-ssh already routes through the sidecar decides how much the
|
|
# permanent default-route warning below actually matters, so state it IN that
|
|
# message rather than leaving each reader to work it out. Asserted properly as
|
|
# its own pass/fail in the next section.
|
|
#
|
|
# THREE states, not two, and the [ -r ] test is why: core.sshCommand NAMING the
|
|
# sidecar does not mean the sidecar EXISTS. Without that test, the arm where the
|
|
# path is wired but the file is gone printed "git IS wired ... unaffected" about
|
|
# a state in which every single git-over-ssh call fails. Found by exercising all
|
|
# five arms of this check rather than only the healthy one.
|
|
if [ ! -r "$HOME/.ssh-local/config" ]; then
|
|
_git_ssh_note="there is no ssh sidecar on this host, so nothing for -F to point at and these hosts cannot multiplex at all — see the git-over-ssh check below"
|
|
elif command -v git >/dev/null 2>&1 &&
|
|
git config --global --get core.sshCommand 2>/dev/null |
|
|
grep -qF -- "-F $HOME/.ssh-local/config"; then
|
|
_git_ssh_note="git IS wired to the sidecar (core.sshCommand), so git push/fetch is unaffected; bare 'ssh' to these hosts still needs 'ssh -F ~/.ssh-local/config'"
|
|
else
|
|
_git_ssh_note="git is NOT wired to the sidecar (see the git-over-ssh check below), so both git and bare 'ssh' need 'ssh -F ~/.ssh-local/config'"
|
|
fi
|
|
|
|
# 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 "-- git-over-ssh routed through the writable ssh sidecar --"
|
|
# The one question in this area that is BINARY, FIXABLE, and therefore worth a
|
|
# check that can reach zero and stay there.
|
|
#
|
|
# The ControlPath warning above cannot: while a bind-mounted ~/.ssh/config pins
|
|
# ControlPath inside the read-only ~/.ssh, the default route will ALWAYS resolve
|
|
# to an unwritable dir, so that line fires benignly on every run of every device
|
|
# forever — and the script's own comment above says why that is dangerous: it is
|
|
# a warning you learn to skip. MEASURED 2026-09-22, exactly that: an agent ran
|
|
# this script, recorded "two by-design warnings", then two hours later hit
|
|
# `unix_listener: cannot bind ... Read-only file system` on `git push`, failed to
|
|
# connect it to the warning it had already read, and reinvented a /tmp/sshcm
|
|
# workaround — while the remedy string sat inside the dismissed warning.
|
|
# entrypoint-user.sh now wires core.sshCommand to the sidecar so nobody has to
|
|
# know; this section asserts the wiring actually happened, which is the part a
|
|
# reader can act on.
|
|
if ! command -v git >/dev/null 2>&1; then
|
|
warn "git not on PATH — sidecar wiring not verified"
|
|
elif [ ! -r "$HOME/.ssh-local/config" ]; then
|
|
# No sidecar (native Linux Docker, where setup-lan-access.sh writes none). The
|
|
# correct state is UNSET: -F pointing at a missing file breaks every
|
|
# git-over-ssh call, which is worse than the problem being solved.
|
|
if [ -z "$(git config --global --get core.sshCommand 2>/dev/null || true)" ]; then
|
|
pass "no ssh sidecar on this host and core.sshCommand correctly unset (guard holds)"
|
|
else
|
|
fail "core.sshCommand is set but ~/.ssh-local/config does not exist — every git-over-ssh call dies on a missing -F file; entrypoint-user.sh's [ -r ] guard did not hold"
|
|
fi
|
|
else
|
|
_git_ssh_cmd=$(git config --global --get core.sshCommand 2>/dev/null || true)
|
|
case "$_git_ssh_cmd" in
|
|
*"-F $HOME/.ssh-local/config"*)
|
|
pass "git core.sshCommand routes through the sidecar ($_git_ssh_cmd)" ;;
|
|
'')
|
|
fail "a sidecar exists but git core.sshCommand is unset — 'git push' to any host whose config pins ControlPath inside the read-only ~/.ssh dies rc=255 behind git's misleading 'correct access rights'. entrypoint-user.sh should set it; expected on images built before that wiring landed, where the fix is: git config --global core.sshCommand \"ssh -F \$HOME/.ssh-local/config\"" ;;
|
|
*)
|
|
warn "git core.sshCommand set to something else and left alone (first-wins, deliberate): $_git_ssh_cmd" ;;
|
|
esac
|
|
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 ==="
|