e070e0bcbf
Found while verifying v1.8.7 from inside a fresh container: the baked mempalace
snapshot is read by no host on this fleet. devbox-skill-reconcile repoints
~/.agents/skills/mempalace at the mounted live clone (the v1.8.5 fix working as
designed), and all four compose stacks mount a workspace containing the
skillset. So the phrase canary that blocked v1.8.7's first tag polices a file
nobody opens, while the drift that could actually mislead an agent — a git pull
nobody ran in /workspace/skillset — was invisible from inside the container and
is invisible to CI by construction.
Record provenance instead of policing it, and move the check to where the
skillset actually is:
- Dockerfile.variant: ARG SKILLSET_SNAPSHOT_REF (the claim) + a sha256 of the
shipped bytes measured in the manifest layer (the fact), as manifest siblings
rather than components{} members, plus an OCI label. An ARG default, not a
CI-resolved output: no credential for the private skillset, no change at any
of the four variant build call sites, and a local docker build records what CI
does. Variant-only, so no base rebuild — check-base-hash.sh scans
Dockerfile.base alone, verified by running it.
- pi-devbox-version: a skills: section naming baked vs live <repo> @ <sha> per
vendored skill, and for mempalace whether the live copy is identical to the
baked fingerprint, at the same commit with uncommitted edits, or divergent.
entrypoint-user.sh passes the new --no-skills, because the banner prints
before the links exist and long before the reconcile runs.
- scripts/vendor-mempalace-skill.sh: refresh the file and rewrite the ref
together (a cp without an ARG bump makes the manifest lie, which is worse than
anonymity); --check verifies the claim against a real clone.
- 5 new smoke assertions (78 -> 83), mutation-tested through the real sh -c
path: 6 fabricated manifests, where a well-formed hash of the wrong file
proves the two manifest assertions are not redundant; the all-baked reporting
test verified to FAIL against a live-skillset environment.
Reviewed mid-flight by pi@emb-7kj4vr4g over the logstream (correlation
skillset-vendor-drift), which retracted its own earlier recommendation of a
build-time byte-compare against skillset HEAD and supplied the better framing:
the invariant is NON-CONTRADICTION, not currency. Byte parity on a fallback
would have cost a resync commit plus a ~67-min base rebuild for each of the four
skillset commits pushed in one evening. Its warning also found a real bug here:
the script now CONSTRUCTS the snapshot from `git show HEAD:<path>` instead of
copying the working tree, because a clean `git diff` says nothing about an
untracked file — the one input the first draft would have recorded a false ref
for. Tested: untracked, unstaged and staged-but-uncommitted all refuse, atomically.
Also fixes three stale in-repo markers of the same class the canary belongs to
(true when written, silently false at release): two dangling "Unreleased"
pointers and a typst line still marked Unreleased five releases after v1.4.0.
201 lines
8.6 KiB
Bash
Executable File
201 lines
8.6 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# pi-devbox-version — show which pi-devbox image build is running.
|
|
#
|
|
# WHY THIS EXISTS
|
|
# The image bakes ground-truth build info into /etc/pi-devbox/build-manifest.json
|
|
# at `docker build` time (see Dockerfile.variant): the release tag, build date,
|
|
# source commit, live `pi --version` at build time, and the actual checked-out
|
|
# commit of every /opt component clone. That answers "what image am I running?"
|
|
# — but only if you know to go look for the file. This wraps it into one
|
|
# command, prints it human-first at container start (see entrypoint-user.sh),
|
|
# and stays available on demand for the rest of the session.
|
|
#
|
|
# USAGE
|
|
# pi-devbox-version human-readable summary (default)
|
|
# pi-devbox-version --json raw manifest JSON (for scripting)
|
|
# pi-devbox-version --quiet one-line "release_tag (source_revision)" form
|
|
# pi-devbox-version --no-skills skip the skill-source section (used at
|
|
# container start, where it would be premature)
|
|
#
|
|
# EXIT STATUS
|
|
# 0 on success. 1 if the manifest is missing (e.g. an image built before
|
|
# this file existed, or a non-pi-devbox base) — prints a short notice
|
|
# to stderr rather than failing silently.
|
|
|
|
set -euo pipefail
|
|
|
|
MANIFEST=/etc/pi-devbox/build-manifest.json
|
|
MODE="human"
|
|
SHOW_SKILLS="yes"
|
|
|
|
case "${1:-}" in
|
|
--json) MODE="json" ;;
|
|
--quiet|-q) MODE="quiet" ;;
|
|
--no-skills) SHOW_SKILLS="no" ;;
|
|
--help|-h)
|
|
sed -n '2,22p' "$0" | sed 's/^# \?//'
|
|
exit 0
|
|
;;
|
|
esac
|
|
|
|
if [ ! -f "$MANIFEST" ]; then
|
|
echo "pi-devbox-version: no build manifest at $MANIFEST" >&2
|
|
echo " (image predates the manifest, or this isn't a pi-devbox image)" >&2
|
|
exit 1
|
|
fi
|
|
|
|
if ! command -v jq >/dev/null 2>&1; then
|
|
echo "pi-devbox-version: jq not found; dumping raw manifest instead" >&2
|
|
cat "$MANIFEST"
|
|
exit 0
|
|
fi
|
|
|
|
if [ "$MODE" = "json" ]; then
|
|
cat "$MANIFEST"
|
|
exit 0
|
|
fi
|
|
|
|
release_tag=$(jq -r '.release_tag' "$MANIFEST")
|
|
build_date=$(jq -r '.build_date' "$MANIFEST")
|
|
source_rev=$(jq -r '.source_revision' "$MANIFEST")
|
|
pi_version_baked=$(jq -r '.pi_version' "$MANIFEST")
|
|
# `// empty` matters: images built before v1.8.6 have no such field, and
|
|
# `jq -r` renders a JSON null as the 4-char string "null" — which would
|
|
# print as a bogus version rather than being treated as absent.
|
|
mp_version_baked=$(jq -r '.mempalace_version // empty' "$MANIFEST")
|
|
|
|
if [ "$MODE" = "quiet" ]; then
|
|
printf '%s (%s)\n' "$release_tag" "${source_rev:0:7}"
|
|
exit 0
|
|
fi
|
|
|
|
# Live drift check: has `pi` been upgraded since this container was built?
|
|
# (image is immutable, but a volume-persisted ~/.pi could in theory shadow
|
|
# the baked binary — this stays honest rather than trusting the manifest
|
|
# blindly, same "ground truth over intent" spirit as how the manifest
|
|
# itself is generated in Dockerfile.variant.)
|
|
pi_version_live=""
|
|
if command -v pi >/dev/null 2>&1; then
|
|
pi_version_live=$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')
|
|
fi
|
|
|
|
# Same check for the palace, which matters more than it looks: mempalace is
|
|
# the one component that is BOTH client (here) and server (synlig runs this
|
|
# same image), so a skew between the two is a real failure mode rather than
|
|
# cosmetic. `mempalace --version` prints "MemPalace 3.8.0" — name-prefixed,
|
|
# unlike pi's bare "0.84.3" — hence $NF rather than reading the whole line.
|
|
mp_version_live=""
|
|
if command -v mempalace >/dev/null 2>&1; then
|
|
mp_version_live=$(mempalace --version 2>/dev/null | head -n1 | awk '{print $NF}' | tr -d '\r\n')
|
|
fi
|
|
|
|
printf 'pi-devbox %s\n' "$release_tag"
|
|
printf ' built: %s (source %s)\n' "$build_date" "${source_rev:0:12}"
|
|
if [ -n "$pi_version_live" ] && [ "$pi_version_live" != "$pi_version_baked" ]; then
|
|
printf ' pi: %s \033[33m(baked as %s — drift detected)\033[0m\n' "$pi_version_live" "$pi_version_baked"
|
|
else
|
|
printf ' pi: %s\n' "${pi_version_live:-$pi_version_baked}"
|
|
fi
|
|
|
|
# Printed only when known, so this degrades quietly on pre-v1.8.6 images
|
|
# instead of showing an empty or "null" palace line.
|
|
if [ -n "$mp_version_live" ] || [ -n "$mp_version_baked" ]; then
|
|
if [ -n "$mp_version_live" ] && [ -n "$mp_version_baked" ] && [ "$mp_version_live" != "$mp_version_baked" ]; then
|
|
printf ' palace: %s \033[33m(baked as %s — drift detected)\033[0m\n' "$mp_version_live" "$mp_version_baked"
|
|
else
|
|
printf ' palace: %s\n' "${mp_version_live:-$mp_version_baked}"
|
|
fi
|
|
fi
|
|
|
|
printf ' components:\n'
|
|
jq -r '.components | to_entries[] | select(.value != null) | " \(.key): \(.value[0:12])"' "$MANIFEST"
|
|
|
|
# ── Which copy of each vendored skill is actually being read? ─────────
|
|
# The image bakes fallback skills under /usr/local/share/pi-devbox/skills/,
|
|
# but for skills the skillset repo OWNS (skillset-owned.txt) a mounted live
|
|
# clone takes over at container start via devbox-skill-reconcile. Nothing
|
|
# reported which copy won, so a stale baked snapshot and a current live clone
|
|
# looked identical from inside — and on this fleet the baked mempalace copy is
|
|
# read by NOBODY (all four compose stacks mount a workspace containing the
|
|
# skillset), which is exactly the sort of fact that should be visible rather
|
|
# than reasoned about. Same "drift detected" shape as the pi/palace lines
|
|
# above: what is live, annotated with what was baked, when they disagree.
|
|
#
|
|
# Skipped with --no-skills at container start (entrypoint-user.sh calls this
|
|
# FIRST, before the baked links exist and long before the skillset deploy and
|
|
# reconcile run last), because a section that is accurate only after boot
|
|
# finishes is worse than no section at all.
|
|
BAKED_SKILLS=/usr/local/share/pi-devbox/skills
|
|
SKILLS_DIR="${HOME:-/home/developer}/.agents/skills"
|
|
|
|
if [ "$SHOW_SKILLS" = "yes" ] && [ -d "$BAKED_SKILLS" ] && [ -d "$SKILLS_DIR" ]; then
|
|
# Recorded provenance of the vendored mempalace snapshot (absent on images
|
|
# built before this existed — `// empty` so a JSON null never prints as the
|
|
# 4-char string "null", the same trap noted for mempalace_version above).
|
|
snap_ref=$(jq -r '.skillset_snapshot_ref // empty' "$MANIFEST")
|
|
snap_sha=$(jq -r '.skillset_snapshot_sha256 // empty' "$MANIFEST")
|
|
|
|
# Iterate the baked tree rather than a hardcoded name list, so vendoring a
|
|
# fourth skill needs no edit here. The header prints only if the tree is
|
|
# non-empty, so this can never emit a dangling "skills:" label.
|
|
_printed_header="no"
|
|
for _dir in "$BAKED_SKILLS"/*/; do
|
|
[ -d "$_dir" ] || continue
|
|
if [ "$_printed_header" = "no" ]; then
|
|
printf ' skills:\n'
|
|
_printed_header="yes"
|
|
fi
|
|
_name=$(basename "$_dir")
|
|
_link="$SKILLS_DIR/$_name"
|
|
|
|
if [ ! -e "$_link" ]; then
|
|
printf ' %-22s not linked\n' "$_name"
|
|
continue
|
|
fi
|
|
|
|
_target=$(readlink -f "$_link" 2>/dev/null || echo "$_link")
|
|
case "$_target" in
|
|
"$BAKED_SKILLS"/*|"$BAKED_SKILLS")
|
|
printf ' %-22s baked\n' "$_name"
|
|
continue
|
|
;;
|
|
esac
|
|
|
|
# Outside the baked tree: a mounted skillset clone, or a user override.
|
|
# The link target is <repo>/skills/<name>, so the repo root is two up.
|
|
# Everything here is guarded: this script runs on the container-start path
|
|
# and must never fail, and `set -e` is in force.
|
|
_root=$(cd "$_target/../.." 2>/dev/null && pwd) || _root=""
|
|
_head=""
|
|
if [ -n "$_root" ]; then
|
|
_head=$(git -C "$_root" rev-parse HEAD 2>/dev/null || echo "")
|
|
fi
|
|
_where="live ${_root:-$_target}"
|
|
[ -n "$_head" ] && _where="$_where @ ${_head:0:7}"
|
|
|
|
# For the one skill whose baked fingerprint we recorded, say plainly
|
|
# whether the live copy differs from what shipped. This is the check CI
|
|
# cannot perform (the skillset is private) and the container can, free.
|
|
_live_sha=""
|
|
if [ -n "$snap_sha" ] && [ "$_name" = "mempalace" ] && [ -f "$_target/SKILL.md" ]; then
|
|
_live_sha=$(sha256sum "$_target/SKILL.md" 2>/dev/null | cut -d' ' -f1 || echo "")
|
|
fi
|
|
if [ -z "$_live_sha" ]; then
|
|
printf ' %-22s %s\n' "$_name" "$_where"
|
|
elif [ "$_live_sha" = "$snap_sha" ]; then
|
|
printf ' %-22s %s (identical to baked snapshot)\n' "$_name" "$_where"
|
|
elif [ -n "$_head" ] && [ "$_head" = "$snap_ref" ]; then
|
|
# Same commit, different bytes — i.e. uncommitted edits in the live
|
|
# checkout. Distinguished from plain drift because otherwise the line
|
|
# reads as a self-contradiction ("@ c04cd15 ... baked snapshot c04cd15
|
|
# — live copy differs") and a reader would suspect the tool, not the
|
|
# working tree.
|
|
printf ' %-22s %s \033[33m(baked snapshot %s + uncommitted edits)\033[0m\n' \
|
|
"$_name" "$_where" "${snap_ref:0:7}"
|
|
else
|
|
printf ' %-22s %s \033[33m(baked snapshot %s — live copy differs)\033[0m\n' \
|
|
"$_name" "$_where" "${snap_ref:0:7}"
|
|
fi
|
|
done
|
|
fi
|