diff --git a/CHANGELOG.md b/CHANGELOG.md index 260a39c..c45e6f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -187,6 +187,44 @@ recorded origin was stale. Consequently no `rootfs/` bytes changed, the smoke-test phrase canary stays valid, and this ARG alone would not have forced a base rebuild — the node bump does that anyway. +**The silent-fallback hole is closed: the image now records WHICH `pi-extensions` +skill copy it shipped.** This was the half deliberately left open by the +`skill-floor` gate above, and it is the more important half, because "the floor is +currently fresh" is a fact with a shelf life while "the image says which copy it +got" keeps working. The refresh step in `Dockerfile.variant` is guarded by +`if [ -f /opt/pi-extensions/skill/SKILL.md ]`, so a build whose clone predates the +co-located skill kept the vendored floor and still succeeded **green**, with +nothing in the manifest, the labels or the logs distinguishing that from a normal +build. The two outcomes are indistinguishable by inspection afterwards — same +path, same filenames, same permissions — which is exactly how the floor went +unnoticed from 2026-07-30 to 2026-09-10. + +`build-manifest.json` gains `pi_extensions_skill_source` and +`pi_extensions_skill_tree_sha256`, both **measured rather than passed in as +build-args**, per the ground-truth rule the rest of that block already follows — +and necessarily so here, since the outcome depends on the clone's contents and no +ARG could express it. Three values, because two would force a lie: +`package` (served bytes equal the clone's `skill/`), `vendored-floor` (the clone +had no `skill/` at this ref, so the fallback shipped), and `divergent` — both +exist but differ, e.g. the clone ships `SKILL.md` but not +`evaluate-extension-usage.py`, leaving the served directory a genuine **mix** of +package and floor. No OCI label mirrors these, deliberately: `LABEL` cannot take a +value computed in a `RUN`, and a label fed from an ARG would be precisely the +claim-not-measurement this change exists to remove. + +Two `scripts/smoke-test.sh` assertions turn the record into a gate: one that the +source is named and is `package` — `vendored-floor` **fails** rather than warns, +since these images track `main` where the package has co-located `skill/` since +`fa04d20`, so a fallback means the clone did not resolve as intended — and one +that recomputes the tree hash over the served directory, because a recorded hash +that is never recompared is a claim rather than a measurement. `pi-devbox-version` +also annotates the line: `pi-extensions baked (package copy)` on the normal path, +and a yellow `(FALLBACK: vendored floor — clone had no skill/)` otherwise. Its +existing skill section reports which copy is being **read** at runtime; this is +the one fact that is decided at **build** time and cannot be recovered later. +Older images degrade cleanly — the field is absent, `jq // empty` yields nothing, +and the line prints plain `baked` exactly as before. + --- ## v1.8.14 — 2026-09-08 diff --git a/Dockerfile.variant b/Dockerfile.variant index eed7999..74ae4ed 100644 --- a/Dockerfile.variant +++ b/Dockerfile.variant @@ -473,6 +473,44 @@ RUN set -e; \ if [ -d "$_snap_dir" ] && [ -n "$(find "$_snap_dir" -type f -print -quit)" ]; then \ SKILL_SNAP="\"$(tree_sha256 "$_snap_dir")\""; \ fi; \ + # ── WHICH pi-extensions skill copy actually shipped ── + # Closes the silent-fallback hole. The refresh step above is guarded by + # `[ -f /opt/pi-extensions/skill/SKILL.md ]`, so a build whose clone predates + # the co-located skill (or a fork pointing at a mirror without it) keeps the + # vendored floor and still succeeds — GREEN, with nothing anywhere recording + # that a snapshot shipped instead of the package copy. Measured 2026-09-10: + # the floor had been stale since 2026-07-30, so that fallback would have + # shipped a six-week-old skill silently. The floor is fresh now and gated by + # the skill-floor CI job, but "the fallback is currently harmless" is not the + # same as "you can tell which copy you got", and only the second survives. + # + # MEASURED, never claimed, per the ground-truth rule above: the branch + # condition is re-derived from the same test the refresh step used, and the + # served bytes are then compared against the clone. A build-arg could not + # express this at all, since the outcome depends on the clone's contents. + # package served bytes == the clone's skill/ (the normal path) + # vendored-floor the clone has no skill/ at this ref (fallback shipped) + # divergent both exist but differ — e.g. the clone ships SKILL.md but + # not evaluate-extension-usage.py, so the served directory is + # a MIX of package and floor. Worth its own value: it is the + # one state neither of the other two names honestly. + # No OCI label mirrors this, deliberately: LABEL cannot take a value computed + # in a RUN, and a label fed from an ARG would be exactly the claim-not- + # measurement this block exists to avoid. + _px_dir=/usr/local/share/pi-devbox/skills/pi-extensions; \ + PIEXT_SRC='null'; PIEXT_HASH='null'; \ + if [ -d "$_px_dir" ] && [ -n "$(find "$_px_dir" -type f -print -quit)" ]; then \ + PIEXT_HASH="\"$(tree_sha256 "$_px_dir")\""; \ + if [ -f /opt/pi-extensions/skill/SKILL.md ]; then \ + if [ "$(tree_sha256 "$_px_dir")" = "$(tree_sha256 /opt/pi-extensions/skill)" ]; then \ + PIEXT_SRC='"package"'; \ + else \ + PIEXT_SRC='"divergent"'; \ + fi; \ + else \ + PIEXT_SRC='"vendored-floor"'; \ + fi; \ + fi; \ { \ echo '{'; \ echo " \"release_tag\": \"${RELEASE_TAG}\","; \ @@ -493,6 +531,8 @@ RUN set -e; \ # vendored skill directory, not one file — see tree_sha256() above. echo " \"skillset_snapshot_ref\": \"${SKILLSET_SNAPSHOT_REF}\","; \ echo " \"skillset_snapshot_tree_sha256\": ${SKILL_SNAP},"; \ + echo " \"pi_extensions_skill_source\": ${PIEXT_SRC},"; \ + echo " \"pi_extensions_skill_tree_sha256\": ${PIEXT_HASH},"; \ echo " \"components\": {"; \ echo " \"pi-toolkit\": \"$(rev /opt/pi-toolkit)\","; \ echo " \"pi-extensions\": \"$(rev /opt/pi-extensions)\","; \ diff --git a/rootfs/usr/local/bin/pi-devbox-version b/rootfs/usr/local/bin/pi-devbox-version index 4b17958..353de7d 100755 --- a/rootfs/usr/local/bin/pi-devbox-version +++ b/rootfs/usr/local/bin/pi-devbox-version @@ -157,6 +157,11 @@ if [ "$SHOW_SKILLS" = "yes" ] && [ -d "$BAKED_SKILLS" ] && [ -d "$SKILLS_DIR" ]; # is not hypothetical. snap_ref=$(jq -r '.skillset_snapshot_ref // empty' "$MANIFEST") snap_sha=$(jq -r '.skillset_snapshot_tree_sha256 // empty' "$MANIFEST") + # Which pi-extensions copy the BUILD baked. Distinct from everything else in + # this section, which reports which copy is being READ at runtime: for + # pi-extensions the baked tree is itself one of two possible copies, and that + # choice was made at build time and is not recoverable by inspection. + px_src=$(jq -r '.pi_extensions_skill_source // empty' "$MANIFEST") # Same pipeline Dockerfile.variant uses to measure the baked directory at # build time: relative paths in `find | sort` order, each hashed, the whole # listing folded into one sha256. Keep the two definitions identical — they @@ -187,7 +192,28 @@ if [ "$SHOW_SKILLS" = "yes" ] && [ -d "$BAKED_SKILLS" ] && [ -d "$SKILLS_DIR" ]; _target=$(readlink -f "$_link" 2>/dev/null || echo "$_link") case "$_target" in "$BAKED_SKILLS"/*|"$BAKED_SKILLS") - printf ' %-22s baked\n' "$_name" + # "baked" alone used to be the whole story. For pi-extensions it is not: + # the baked tree holds EITHER the package copy that Dockerfile.variant + # lays over the snapshot, OR the vendored floor, when the clone had no + # skill/ at that ref. The two are indistinguishable by inspection — same + # path, same filenames, same permissions — so the build records which one + # it used and this reports it. Without this line a six-week-stale + # fallback skill looks exactly like a current one, which is precisely how + # the floor went unnoticed from 2026-07-30 to 2026-09-10. + if [ "$_name" = "pi-extensions" ] && [ -n "$px_src" ]; then + case "$px_src" in + package) + printf ' %-22s baked (package copy)\n' "$_name" ;; + vendored-floor) + printf ' %-22s baked \033[33m(FALLBACK: vendored floor — clone had no skill/)\033[0m\n' "$_name" ;; + divergent) + printf ' %-22s baked \033[33m(MIXED: part package, part floor)\033[0m\n' "$_name" ;; + *) + printf ' %-22s baked\n' "$_name" ;; + esac + else + printf ' %-22s baked\n' "$_name" + fi continue ;; esac diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index c8cc75a..fbb7816 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -526,6 +526,50 @@ run "manifest skill fingerprint matches the baked snapshot" ' echo "manifest=[$m] actual=[$a]" >&2 [ -n "$m" ] && [ "$m" = "$a" ] ' + +# ── Which pi-extensions skill copy shipped ────────────────────────────── +# Closes the silent-fallback hole. The refresh in Dockerfile.variant is guarded +# by `[ -f /opt/pi-extensions/skill/SKILL.md ]`, so a build whose clone predates +# the co-located skill keeps the vendored floor and still succeeds GREEN, with +# nothing recording that a snapshot shipped instead of the package copy. Measured +# 2026-09-10: the floor had been stale since 2026-07-30, so that path would have +# shipped a six-week-old skill in silence. The floor is fresh now and gated by the +# skill-floor lint job, but "the fallback is currently harmless" is a fact with a +# shelf life, whereas "the image says which copy it got" keeps working. +# +# vendored-floor FAILS here rather than merely warning: these images track main, +# where the package has co-located skill/ since fa04d20, so a fallback means the +# clone did not resolve as intended and that is a defect to investigate. A fork +# deliberately pointing at a mirror without skill/ is the one case that should +# edit this assertion — which is the honest place for that decision to surface. +run "manifest names which pi-extensions skill copy shipped" ' + j=/etc/pi-devbox/build-manifest.json + s=$(jq -r ".pi_extensions_skill_source // empty" $j) + h=$(jq -r ".pi_extensions_skill_tree_sha256 // empty" $j) + echo "source=[$s] tree_sha256=[$h]" >&2 + printf "%s" "$h" | grep -qxE "[0-9a-f]{64}" || { + echo "pi_extensions_skill_tree_sha256 is not a 64-hex digest" >&2; exit 1; } + case "$s" in + package) ;; + vendored-floor) + echo "FALLBACK: clone had no skill/ at this ref, so the image ships the committed floor" >&2; exit 1 ;; + divergent) + echo "MIXED: served directory is part package and part floor" >&2; exit 1 ;; + *) + echo "pi_extensions_skill_source absent or unrecognised" >&2; exit 1 ;; + esac +' + +# Same shape as the mempalace fingerprint check above, and for the same reason: a +# recorded hash that is never recomputed is a claim, not a measurement. +run "recorded pi-extensions skill hash matches the served bytes" ' + j=/etc/pi-devbox/build-manifest.json + d=/usr/local/share/pi-devbox/skills/pi-extensions + m=$(jq -r ".pi_extensions_skill_tree_sha256 // empty" $j) + a=$( (cd "$d" && find . -type f -print | LC_ALL=C sort | xargs -r sha256sum) | sha256sum | cut -d" " -f1) + echo "manifest=[$m] actual=[$a]" >&2 + [ -n "$m" ] && [ "$m" = "$a" ] +' # OCI labels live in the image config, not the container fs — inspect them # from the host docker rather than via `docker run`. LBL=$(docker inspect --format '{{ index .Config.Labels "se.jordbo.pi-devbox.pi-extensions-ref" }}' "$IMAGE" 2>/dev/null || true)