#!/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 → 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/: 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 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 ==="