skills: record the vendored snapshot's provenance, and report which copy wins
Lint / actionlint (push) Successful in 16s
Lint / hadolint (push) Successful in 16s

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.
This commit is contained in:
2026-08-26 10:27:00 +02:00
parent dbb78798fb
commit e070e0bcbf
7 changed files with 604 additions and 7 deletions
+94 -1
View File
@@ -14,6 +14,8 @@
# 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
@@ -24,12 +26,14 @@ 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,20p' "$0" | sed 's/^# \?//'
sed -n '2,22p' "$0" | sed 's/^# \?//'
exit 0
;;
esac
@@ -105,3 +109,92 @@ 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
@@ -39,6 +39,33 @@ its skill file needed baking.
*different* skill, `opencode-mempalace-bridge`), so there is no public
package source to copy from. This snapshot is refreshed manually per release.
**Refresh it with `scripts/vendor-mempalace-skill.sh <skillset-root>`, not
`cp`.** Because the image cannot clone the private upstream, the snapshot used
to be *anonymous* — nothing recorded which skillset commit the bytes came
from, so the only staleness check possible was a hand-maintained phrase canary
in `scripts/smoke-test.sh`, which by construction detects "older than the
phrase I remembered to pin", never "older than skillset main". Two facts now
travel with the file:
| Fact | Where | Kind |
|---|---|---|
| `ARG SKILLSET_SNAPSHOT_REF` in `Dockerfile.variant` | manifest `skillset_snapshot_ref` + OCI label `se.jordbo.pi-devbox.skillset-snapshot-ref` | a **claim** about which commit these bytes are |
| `sha256sum` of this file, measured in the manifest layer | manifest `skillset_snapshot_sha256` | the bytes that **actually shipped** |
The script writes both together, refuses when the upstream file has
uncommitted modifications (no commit describes those bytes), and
`--check` verifies the claim against a real clone. Deliberately an `ARG`
default rather than a CI-resolved value: no credential for a private repo, no
change at any of the four `Dockerfile.variant` build call sites, and a local
`docker build` records the same thing CI does.
Verifying "is this snapshot current?" is **not** a CI job and was deliberately
not made one — see the Unreleased CHANGELOG entry for why (private repo;
another repo's branch must not be able to fail this build; and the artefact it
would guard is read by no host on this fleet). The check belongs where the
skillset actually is: `vendor-mempalace-skill.sh --check` for a maintainer,
and `pi-devbox-version`'s `skills:` section for an agent inside a container.
## Runtime precedence (v1.8.5+)
The baked links are created **early** in `entrypoint-user.sh` (before pi-deploy,
@@ -59,6 +86,21 @@ repoints the links for skills the **skillset owns**, listed one per line in
3. **baked snapshot** — everything else, and every skill when no skillset is
mounted
**Which one won is now reportable from inside the container:**
`pi-devbox-version` prints a `skills:` section naming, per vendored skill,
`baked` or `live <repo> @ <sha>` — and for `mempalace` whether that live copy is
identical to the baked fingerprint, at the same commit but with uncommitted
edits, or genuinely divergent. Before that, a stale baked snapshot and a current
live clone were indistinguishable from inside, which is how the freshness of
this file went unexamined for three releases. The section is suppressed with
`--no-skills` on the container-start banner, because `entrypoint-user.sh` prints
the version *before* the links exist and long before the reconcile below runs.
On this fleet, precedence 2 wins for `mempalace` on **every** host — all four
compose stacks mount a workspace containing the skillset — so the baked copy is
exercised only by CI and by a hypothetical no-mount container. Worth
remembering before spending effort on its freshness.
Ownership is per-skill on purpose: `pi-extensions`' authoritative source is the
package repo (copied over the snapshot at build), and `skillset` carries a
downstream copy that can lag, so handing it to the clone would *regress* the