Compare commits
6 Commits
15a3728ae9
...
v1.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f0960e134 | |||
| 1c905480e3 | |||
| ff6fd1492a | |||
| edc7659add | |||
| cac5e00a31 | |||
| ecfd2fc2e5 |
@@ -92,7 +92,7 @@ jobs:
|
||||
|
||||
- name: Install actionlint (pinned)
|
||||
env:
|
||||
ACTIONLINT_VERSION: 1.7.7
|
||||
ACTIONLINT_VERSION: 1.7.12
|
||||
run: |
|
||||
curl -fsSL \
|
||||
"https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
|
||||
- name: Install hadolint (pinned)
|
||||
env:
|
||||
HADOLINT_VERSION: 2.14.0
|
||||
HADOLINT_VERSION: 2.15.1
|
||||
run: |
|
||||
curl -fsSL \
|
||||
"https://github.com/hadolint/hadolint/releases/download/v${HADOLINT_VERSION}/hadolint-Linux-x86_64" \
|
||||
@@ -137,3 +137,38 @@ jobs:
|
||||
|
||||
- name: Run hadolint
|
||||
run: hadolint Dockerfile.base Dockerfile.variant
|
||||
|
||||
skill-floor:
|
||||
# Gate the VENDORED pi-extensions skill snapshot in rootfs/ against the
|
||||
# package repo it is a snapshot of. Its own job rather than a step in
|
||||
# `actionlint`, so "the floor is stale" is a distinct red name in the runs
|
||||
# list instead of being buried in a lint job that is about something else.
|
||||
#
|
||||
# The gap it closes, measured 2026-09-10: the floor sat at 34284 B, untouched
|
||||
# since fa04d20 (2026-07-30), while the package copy was 38973 B.
|
||||
# Dockerfile.variant copies the fresh package copy over the SERVED path but
|
||||
# never writes back to the floor, so nothing in the repo ever noticed. That
|
||||
# matters because the floor is a FALLBACK: the copy is guarded by
|
||||
# `if [ -f /opt/pi-extensions/skill/SKILL.md ]`, so a build whose clone
|
||||
# yields no skill/ ships the vendored snapshot and still goes green, with no
|
||||
# manifest flag or label saying which copy was served.
|
||||
#
|
||||
# Gating on another repo is normally a smell; it is proportionate here
|
||||
# because the check compares the skill DIRECTORY hash, so it can only fire
|
||||
# when that directory actually changed — which is exactly when the floor has
|
||||
# gone stale. pi-extensions commits that leave skill/ alone cannot turn this
|
||||
# red. No secret is needed either: the repo is anonymously clonable (verified
|
||||
# 2026-09-10 with `git ls-remote` and no credentials), so this cannot start
|
||||
# failing when a token expires.
|
||||
#
|
||||
# Exit codes are 0 in sync / 1 drift / 2 cannot-run, matching
|
||||
# scripts/lint-shell.sh: a gate that cannot run must not pass, so an
|
||||
# unreachable package repo is a red 2 rather than a green tick.
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Vendored pi-extensions skill floor matches the package
|
||||
run: bash scripts/check-skill-floor.sh
|
||||
|
||||
+156
-1
@@ -11,7 +11,7 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
|
||||
|
||||
---
|
||||
|
||||
## Unreleased
|
||||
## v1.9.0 — 2026-09-10
|
||||
|
||||
**`shellcheck` is now in the image, because the release gate it depends on could
|
||||
not be run by anyone.** v1.8.14 made shell lint a release gate: `scripts/lint-shell.sh`
|
||||
@@ -92,6 +92,161 @@ derivation's `mine` query at the newest end (`order: "desc"`); with the previous
|
||||
default `asc` + `limit: 100`, a device passing 100 authored events would have its
|
||||
recent replies fall out of the join window and see answered asks resurface.
|
||||
|
||||
**Four small packages, each chosen from a gap that was measured rather than
|
||||
imagined.** All four were picked by looking back at a real session — the
|
||||
`gitea.egl.lan`/FreeIPA debugging of 2026-09-09..10 — and asking which absences
|
||||
actually cost time, not which tools sound useful. `bind9-dnsutils` (~6.1 MB, 10
|
||||
packages): `dig`, `host` **and** `nslookup` were all absent, so the container
|
||||
could resolve names but had no way to interrogate a *specific* nameserver —
|
||||
`getent hosts` only follows the resolver's default path, so diagnosing "gateway
|
||||
`172.16.88.1` NXDOMAINs the `egl.lan` zone while `10.20.253.1` is authoritative
|
||||
for it" had to be hand-rolled in `python3`. Note the package name: plain
|
||||
`dnsutils` is transitional in trixie. `ldap-utils` (1244 KB, **zero** extra deps):
|
||||
the fleet authenticates against FreeIPA, yet every LDAP probe had to be run by
|
||||
SSHing to an already-enrolled host; this gives simple binds only, since GSSAPI
|
||||
would additionally need `krb5-user` + `libsasl2-modules-gssapi-mit`, which is a
|
||||
Kerberos-client decision rather than a tool. `xxd` (198 KB) is frank convenience
|
||||
— `od -c` already does the job. `python3-yaml` (552 KB, zero extra deps) is the
|
||||
shellcheck story repeating exactly: `scripts/check-workflow-shell.sh`, the guard
|
||||
against the Gitea `sh`/dash footgun that broke `resolve-versions` (`ed49b8d`) and
|
||||
`promote-base-latest` (`b7197e8`), hard-exits with "python3 yaml module missing"
|
||||
without it — and `lint.yml` installing it explicitly in CI was the evidence the
|
||||
image lacked it. **`netcat-openbsd` was proposed and deliberately rejected**:
|
||||
measured redundant, because `socat` is already baked and bash's `/dev/tcp` does
|
||||
reachability checks with zero packages. The reason is recorded in
|
||||
`Dockerfile.base` so the omission reads as a decision rather than an oversight.
|
||||
|
||||
**The vendored `pi-extensions` skill floor was 41 days stale, and is now gated so
|
||||
it cannot silently rot again.** `rootfs/usr/local/share/pi-devbox/skills/pi-extensions/`
|
||||
sat at 34284 B, untouched since `fa04d20` (2026-07-30), while the package copy
|
||||
was 38973 B — four copies of one skill existed across the fleet with three
|
||||
different sizes. `Dockerfile.variant` copies the freshly-cloned package copy over
|
||||
the **served** path but never writes back to the repo floor, so nothing in the
|
||||
repo ever noticed. That is worse than ordinary staleness because the floor is a
|
||||
**fallback**: the copy is guarded by `if [ -f /opt/pi-extensions/skill/SKILL.md ]`,
|
||||
so a build whose clone yields no `skill/` keeps the vendored snapshot and still
|
||||
goes **green**, with no manifest flag and no label recording which copy was
|
||||
served — the image would ship a July skill and nothing would say so. The floor is
|
||||
refreshed here from `pi-extensions@c64c122`, and the new `skill-floor` job in
|
||||
`lint.yml` runs `scripts/check-skill-floor.sh` to keep it that way.
|
||||
|
||||
The check compares the **directory** hash, using the same `tree_sha256` pipeline
|
||||
`Dockerfile.variant` uses for `skillset_snapshot_tree_sha256` and for the same
|
||||
documented reason: a `sha256sum SKILL.md` answers "did this one file change", not
|
||||
"is this the same skill", and `pi-extensions` ships two files. That is not
|
||||
hypothetical — it was **verified by negative control**: with `SKILL.md` left
|
||||
byte-identical and only `evaluate-extension-usage.py` edited, the directory check
|
||||
correctly fails while a file-only compare would have passed. Exit codes are `0`
|
||||
in sync / `1` drift / `2` cannot-run, matching `scripts/lint-shell.sh`, so an
|
||||
unreachable package repo is a red `2` rather than a green tick. Gating on another
|
||||
repo is normally a smell; it is proportionate here because the check can only
|
||||
fire when `skill/` itself changed — which is exactly when the floor has gone
|
||||
stale — and it needs no secret, since `pi-extensions` is anonymously clonable
|
||||
(verified with `git ls-remote` and no credentials).
|
||||
|
||||
> **What this does *not* fix, stated so nobody reads more into it than is there.**
|
||||
> The floor is now fresh and guarded, but the *silent-fallback* half remains:
|
||||
> if the build-time copy is ever absent, the build still succeeds with no
|
||||
> manifest flag or OCI label recording that the vendored snapshot was served
|
||||
> instead of the package copy. The durable fix for that is a manifest field
|
||||
> alongside the existing `skillset_snapshot_tree_sha256`, which this change does
|
||||
> not add.
|
||||
|
||||
**Four pinned dependencies bumped, after an audit of everything the image gets
|
||||
from outside apt.** The audit itself is the useful part: of ~23 externally-managed
|
||||
components, the 19 that resolve `latest` at build time were already current or
|
||||
refresh themselves on the next rebuild, and the hard pins for `pi` (0.85.1),
|
||||
`mempalace` (3.9.0) and `pi-atelier` (v0.10.1) were all already the newest
|
||||
available. Only four needed a human.
|
||||
|
||||
**`NODE_VERSION` 22 → 24 (LTS "Krypton") — this one was a latent defect, not
|
||||
housekeeping.** `agent-browser` publishes `engines.node ">=24.0.0"`, so the image
|
||||
was *below a declared requirement*: v1.8.14 shipped node 22.23.2 with
|
||||
`agent-browser` 0.37.1, meaning every build installed it with an npm `EBADENGINE`
|
||||
warning and then ran the baked browser automation outside its supported range.
|
||||
The other two npm consumers are satisfied either way — `pi` declares `>=22.19.0`,
|
||||
`playwright` `>=20`. Verified before bumping, because a missing NodeSource suite
|
||||
would break every architecture at once: `setup_24.x` returns HTTP 200 and the
|
||||
`node_24.x` suite advertises `Architectures: amd64 arm64 armhf x86_64`, covering
|
||||
both the arm64 fleet and the amd64 CI runners. Nothing else in the repo pinned the
|
||||
node major.
|
||||
|
||||
**`actionlint` 1.7.7 → 1.7.12 and `hadolint` 2.14.0 → 2.15.1**, each run against
|
||||
the current tree at the new version *before* being pinned — both clean, no new
|
||||
findings. That ordering is the point: a linter bump is the one dependency update
|
||||
that can turn CI red on unchanged code, so discovering it locally costs a minute
|
||||
and discovering it in CI costs a round trip.
|
||||
|
||||
**`SKILLSET_SNAPSHOT_REF` `e9e09d9` → `4d7c0ea`**, via
|
||||
`scripts/vendor-mempalace-skill.sh` rather than by hand, because that script is
|
||||
the only thing that may write the ARG — a `cp` without a matching bump produces a
|
||||
manifest that confidently lies. This turned out to be **provenance-only**: the
|
||||
recorded ref was 6 commits behind, but `skills/mempalace/SKILL.md` is byte-identical
|
||||
at both (`3675bfab…`), so the vendored snapshot was already correct and only its
|
||||
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.
|
||||
|
||||
**This image also carries a real fix for the recurring
|
||||
`[mempalace ext] feed (tick) failed: mine timed out after 30000ms` message** that
|
||||
has been appearing in the pi TUI across the fleet since August
|
||||
(`mempalace-toolkit` `309980b` + `e68ee20`, picked up because CI resolves
|
||||
`MEMPALACE_TOOLKIT_REF` to a commit SHA at build time). It was parked as cosmetic
|
||||
on 2026-08-27 and it was not cosmetic: `lastFeedAt` was recorded only after a
|
||||
*successful* wait, but the extension's `Promise.race` abandons only the **wait**
|
||||
and cannot cancel the mine, so a timeout left the 10-minute debounce clock stale
|
||||
— and with `feedInFlight` already cleared, **both** guards stood open and every
|
||||
following settled turn started another mine on top of the one still running.
|
||||
Overlapping writers on a single-writer palace, each making the next slower and
|
||||
the next timeout likelier, which is why the message appeared many times per
|
||||
session instead of at most once per debounce window. Simulated over ten minutes
|
||||
of settled turns with a 60s mine: **16 mines launched, 15 of them overlapping**
|
||||
before; **2 and 0** after. Nothing was ever lost — the transcript is staged
|
||||
before the mine and `mine --mode convos` is idempotent — so this was wasted work
|
||||
and a misleading error, not data loss. The deadline also rose from 30s to 5
|
||||
minutes: the mine is the slowest call the extension makes (30–60s normally) yet
|
||||
carried the tightest deadline, 4x tighter than the `prepare` before it and 10x
|
||||
tighter than the init handshake. On a healthy fleet the message should now be
|
||||
absent; if it appears it is informative — a mine exceeding five minutes.
|
||||
|
||||
---
|
||||
|
||||
## v1.8.14 — 2026-09-08
|
||||
|
||||
+68
-1
@@ -122,6 +122,59 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
# are already present. NOTE this file feeds the base-decide
|
||||
# hash (Dockerfile.base + rootfs/), so adding it forces one
|
||||
# full base rebuild.
|
||||
# bind9-dnsutils — `dig` and `nslookup`. Added 2026-09-10 to close a
|
||||
# DIAGNOSTIC gap measured during the gitea.egl.lan/FreeIPA
|
||||
# work: the container could resolve names but had NO way to
|
||||
# ask a SPECIFIC nameserver anything. `getent hosts` only
|
||||
# follows the resolver's default path, so the whole "gateway
|
||||
# 172.16.88.1 returns NXDOMAIN for the egl.lan zone while
|
||||
# 10.20.253.1 is authoritative for it" diagnosis had to be
|
||||
# hand-rolled in python3 — dig, host AND nslookup were all
|
||||
# absent. `dig @10.20.253.1 freeipa-4.egl.lan` is the
|
||||
# one-liner that replaces it, and split-horizon DNS is a
|
||||
# recurring class of bug on this fleet, not a one-off. NOTE
|
||||
# the package to name is bind9-dnsutils: plain `dnsutils` is
|
||||
# a transitional package in trixie. ~6.1 MB total (6210 KB
|
||||
# measured): bind9-dnsutils 721 KB + bind9-host 161 KB +
|
||||
# bind9-libs 3804 KB plus 7 small libs (libfstrm0,
|
||||
# libjson-c5, liblmdb0, libmaxminddb0, libprotobuf-c1,
|
||||
# liburcu8t64, libuv1t64) under --no-install-recommends.
|
||||
# ldap-utils — `ldapsearch`/`ldapmodify`. Added 2026-09-10. This fleet
|
||||
# authenticates against FreeIPA (EGL.LAN), and every LDAP
|
||||
# probe during the Gitea auth work had to be run by SSHing to
|
||||
# an already-enrolled host because the container had no LDAP
|
||||
# client at all. 1244 KB and pulls NOTHING extra under
|
||||
# --no-install-recommends — its deps (libldap, libsasl2) are
|
||||
# already present. CAVEAT: this gives SIMPLE binds only,
|
||||
# which is what Gitea itself uses and what most probes need.
|
||||
# GSSAPI binds (`ldapsearch -Y GSSAPI`) additionally require
|
||||
# krb5-user + libsasl2-modules-gssapi-mit, deliberately NOT
|
||||
# added here — that is a Kerberos-client decision with
|
||||
# /etc/krb5.conf implications, not just a tool.
|
||||
# xxd — hex dump. 198 KB, no extra deps. Convenience, and honestly
|
||||
# marginal: `od -c` from coreutils is always present and does
|
||||
# the same job. Earned its place because verifying that
|
||||
# git-crypt actually encrypted a staged blob (the \0GITCRYPT\0
|
||||
# magic) is a recurring check in myconfigs and xxd is the
|
||||
# muscle-memory command for it.
|
||||
# NOT added — netcat-openbsd (133 KB): measured redundant on
|
||||
# 2026-09-10, because socat is already baked above AND bash's
|
||||
# /dev/tcp does reachability checks with zero packages
|
||||
# (verified against gitea.egl.lan:3000). Recorded here so the
|
||||
# omission reads as a decision rather than an oversight.
|
||||
# python3-yaml — PyYAML. Added 2026-09-10 for precisely the same reason as
|
||||
# shellcheck above: a gate this repo ALREADY OWNS could not be
|
||||
# run locally by anyone. scripts/check-workflow-shell.sh — the
|
||||
# guard that catches the "bash-only syntax under Gitea's default
|
||||
# sh/dash shell" footgun that broke resolve-versions (ed49b8d)
|
||||
# and promote-base-latest (b7197e8) — hard-exits with "ERROR:
|
||||
# python3 yaml module missing" without it. lint.yml installs it
|
||||
# explicitly in CI (`shellcheck python3-yaml`), which is itself
|
||||
# the evidence that the image lacked it. Measured 2026-09-10
|
||||
# while wiring the skill-floor job: the guard could not be run
|
||||
# before pushing — the same write → push → wait-for-CI loop that
|
||||
# shellcheck was baked to shorten. 552 KB, and pulls ZERO extra
|
||||
# packages under --no-install-recommends.
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y --no-install-recommends && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
@@ -163,6 +216,10 @@ RUN apt-get update && \
|
||||
kitty-terminfo \
|
||||
ncurses-term \
|
||||
iproute2 \
|
||||
bind9-dnsutils \
|
||||
ldap-utils \
|
||||
xxd \
|
||||
python3-yaml \
|
||||
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -576,7 +633,17 @@ ENV COLORTERM=truecolor
|
||||
ENV PATH="/home/developer/.local/bin:/home/developer/.cargo/bin:${PATH}"
|
||||
|
||||
# ── Node.js (required for pi + MCP servers + tldr) ──
|
||||
ARG NODE_VERSION=22
|
||||
# 24 (LTS "Krypton"), raised from 22 on 2026-09-10 because the image was BELOW a
|
||||
# DECLARED requirement, not merely behind the newest release: `agent-browser`
|
||||
# publishes engines.node ">=24.0.0", so every build on 22 installed it with an npm
|
||||
# EBADENGINE warning and then ran it outside its supported range — measured on
|
||||
# v1.8.14, which shipped node 22.23.2 with agent-browser 0.37.1. The other two npm
|
||||
# consumers are satisfied either way: pi declares ">=22.19.0" and playwright
|
||||
# ">=20". Verified before bumping, because a missing NodeSource suite would break
|
||||
# the build for every arch at once: deb.nodesource.com/setup_24.x returns HTTP 200
|
||||
# and the node_24.x suite advertises `Architectures: amd64 arm64 armhf x86_64`, so
|
||||
# both the arm64 fleet and the amd64 CI runners resolve.
|
||||
ARG NODE_VERSION=24
|
||||
RUN curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
|
||||
apt-get install -y --no-install-recommends nodejs && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
+41
-1
@@ -392,7 +392,7 @@ ARG MEMPALACE_TOOLKIT_REF=main
|
||||
# no ~67-minute base rebuild. (scripts/check-base-hash.sh scans only
|
||||
# Dockerfile.base, so no folding into the base hash is required — nor would
|
||||
# it be correct, since this ARG changes nothing about the base's contents.)
|
||||
ARG SKILLSET_SNAPSHOT_REF=e9e09d95f92670536a199fc986dfa24d787f18d1
|
||||
ARG SKILLSET_SNAPSHOT_REF=4d7c0ea9caeb3a1d6d9b04cf34f3fca5f9df4985
|
||||
|
||||
# Dockerfile.base sets description="pi-devbox — base image (variant-independent)"
|
||||
# and every variant INHERITS it, so both published images used to advertise
|
||||
@@ -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)\","; \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: pi-extensions
|
||||
description: >-
|
||||
Use the pi extensions (pi-fork, pi-observational-memory, ssh-controlmaster) effectively in the pi coding agent harness. Load this skill only when running inside pi (detection - `fork` and `recall` are present in your tool list, or `pi --ssh` was used to start the session). pi-fork dispatches focused subtasks to forked agents at fast/balanced/deep effort tiers; pi-observational-memory compacts long sessions into recallable observations + reflections; ssh-controlmaster rewires pi's read/write/edit/bash tools to execute on a remote host over a multiplexed SSH connection. This skill covers tier selection, task design, boundary discipline, when to use recall, and remote-pi mechanics.
|
||||
Use the pi extensions (pi-fork, pi-observational-memory, ssh-controlmaster) effectively in the pi coding agent harness. Load this skill only when running inside pi (detection - `fork` and `recall` are present in your tool list, or `pi --ssh` was used to start the session). pi-fork dispatches focused subtasks to forked agents at fast/balanced/deep effort tiers; pi-observational-memory compacts long sessions into recallable observations + reflections; ssh-controlmaster rewires pi's read/write/edit/bash tools to execute on a remote host over a multiplexed SSH connection. Also covers the context ladder L0-L4 and when to reach for the separate `pi-task` CLI instead of `fork` - isolated child, immutable spec, machine-checked envelope, write-boundary diff. This skill covers tier selection, task design, boundary discipline, when to use recall, and remote-pi mechanics.
|
||||
---
|
||||
|
||||
# Pi Extensions: pi-fork, pi-observational-memory, ssh-controlmaster
|
||||
@@ -161,6 +161,68 @@ The "three" things it completed were exactly the main thread's pending todos, vi
|
||||
- Distrust **quantities** and **provenance claims** in fork prose specifically ("all N sessions", "shipped with the image", "as expected") — those are the slots confabulation fills.
|
||||
- The fact that the fork was "right anyway" is not the same as the fork having followed instructions.
|
||||
|
||||
### The context ladder — and the second dispatch mechanism (`pi-task`)
|
||||
|
||||
Everything above describes a child that inherits everything. That is not a fixed
|
||||
cost of delegation — **how much context a child gets is a choice**, and `fork`
|
||||
sits at one extreme of it. Five rungs:
|
||||
|
||||
| rung | what the child sees | mechanism | built? |
|
||||
|---|---|---|---|
|
||||
| **L0** | nothing but the goal | `pi-task` default: fresh `--session-id pitask-<id>-<stamp>` in a private `--session-dir` | yes |
|
||||
| **L1** | goal + **names** of files/commands to read itself | `pi-task` spec `context.files` / `context.commands` (`bin/pi-task:154,157`) | yes |
|
||||
| **L2** | goal + an **excerpt the parent curated** | `pi-task` spec `context.facts`, pasted verbatim (`bin/pi-task:151`) | yes |
|
||||
| **L3** | a **truncated tail** of the parent branch | *nothing implements this* — would need a new spec key plus `--session <trimmed snapshot>` | **no** |
|
||||
| **L4** | the **entire** parent branch | `fork(task=…)` — `getHeader()+getBranch()`, no offset or limit anywhere in the call chain | yes |
|
||||
|
||||
**`pi-task` is a CLI, not an extension — it will never appear in your tool list.**
|
||||
Invoke it with `bash`: `/opt/pi-toolkit/bin/pi-task run <spec.json>` (source at
|
||||
`/workspace/pi-toolkit/bin/pi-task`, `schema` subcommand prints the spec fields).
|
||||
It reads an immutable JSON spec, and "inherit the session" is not expressible in
|
||||
that schema — the isolation is structural, not a request.
|
||||
|
||||
**Choose the lowest rung that can do the job:**
|
||||
|
||||
- **`fork` (L4)** when the subtask only makes sense against this conversation,
|
||||
when you want several independent opinions in parallel from one message, or for
|
||||
read-only exploration whose detail you will discard. Everything in "Boundary
|
||||
discipline" above applies in full.
|
||||
- **`pi-task` (L0–L2)** when the brief contains a **prohibition** (the inherited
|
||||
transcript is exactly what overrides those), when you want a **pass/fail**
|
||||
result instead of prose, when you need an **audit trail**, or when writes
|
||||
outside an authorised set must be caught.
|
||||
- **Neither** for trivial work, iterative work (both are one-shot), or judgement
|
||||
that needs context only you have.
|
||||
|
||||
**What `pi-task` gets you that no brief can.** The envelope must parse or the run
|
||||
FAILED, however fluent the prose. `roots[]` is the WATCHED set and
|
||||
`write_allowed` the CHANGEABLE subset, diffed before and after with git
|
||||
`--porcelain --ignored`. That `--ignored` flag is load-bearing: in the T4 test the
|
||||
child obeyed its brief perfectly and still tripped the detector, because
|
||||
`py_compile` wrote `__pycache__` into a watched-but-not-writable root — a
|
||||
gitignored path that plain `--porcelain` reports as clean. Note the structural
|
||||
point that test exposed: under `read_only: true` a write is *defiance*, so a
|
||||
well-behaved child never produces a delta and the detector is never exercised.
|
||||
Splitting WATCHED from WRITABLE is what lets an **obedient** child reveal a
|
||||
violation, which is the realistic hazard.
|
||||
|
||||
**What it does not fix.** `--no-extensions` removes extensions, not the core
|
||||
`read`/`write`/`edit`/`bash` tools — exactly as described above — so the boundary
|
||||
diff is post-hoc **detection, not prevention**. And a fresh L0 context removes the
|
||||
*narrative* failures (parent voice, invented continuity) without removing
|
||||
confabulation: given an under-specified spec built on a false premise, the child
|
||||
still filled the `deliverable` slot with a confident shape. The envelope's own
|
||||
structure creates that pressure. Verify decisive claims from the filesystem
|
||||
regardless of which rung you used.
|
||||
|
||||
**Trap — the capability floor is inverted from intuition.** `runner.ts:188` reads
|
||||
`if (extensions !== null) args.push("--no-extensions")`. So `pi-fork.extensions:
|
||||
[]` passes the flag and the floor is **on**; setting it to `null` — documented in
|
||||
`settings.json` as the way to "restore normal extension loading" — passes nothing
|
||||
and the floor is **off**, restoring palace writes inside every fork child.
|
||||
Changing `[]` to `null` as a tidy-up re-arms what was deliberately disarmed.
|
||||
`pi-task` hardcodes the flag and cannot drift this way.
|
||||
|
||||
### Anti-patterns
|
||||
|
||||
- **Forking trivial work.** A fork has overhead. If the task takes < 30 seconds in your main thread, just do it.
|
||||
@@ -230,7 +292,7 @@ When entries conflict, **the most recent observation reflects the latest known s
|
||||
## Quick Reference
|
||||
|
||||
```
|
||||
fork(task=..., effort=fast|balanced|deep)
|
||||
fork(task=..., effort=fast|balanced|deep) # L4: child inherits your WHOLE branch
|
||||
- state decision authority explicitly
|
||||
- pass verified context up front
|
||||
- specify deliverable shape
|
||||
@@ -240,6 +302,13 @@ fork(task=..., effort=fast|balanced|deep)
|
||||
- write-capable? demand "What I did NOT do", then verify from git/fs, not the report
|
||||
- prohibition in the brief => not a `fast` task
|
||||
|
||||
bash: /opt/pi-toolkit/bin/pi-task run <spec> # L0-L2: isolated child, NOT a tool
|
||||
- schema | selftest | run [--dry-run]
|
||||
- context.facts (pasted) / .files (names only) / .commands
|
||||
- roots[] = WATCHED, write_allowed[] = CHANGEABLE subset
|
||||
- envelope must parse or the run FAILED
|
||||
- audit + cost: ~/.pi/agent/pi-task/<stamp>-<id>/result.json
|
||||
|
||||
recall(id=<12-char-hex>)
|
||||
- only when stakes justify the cost
|
||||
- id must already be visible in your context
|
||||
|
||||
Executable
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-skill-floor.sh — fail when the vendored pi-extensions skill snapshot in
|
||||
# rootfs/ ("the floor") has drifted from the package repo it is a snapshot of.
|
||||
#
|
||||
# THE DEFECT THIS EXISTS TO CATCH, measured 2026-09-10.
|
||||
# rootfs/usr/local/share/pi-devbox/skills/pi-extensions/ ships a vendored copy
|
||||
# of the pi-extensions skill so the skill is ALWAYS present in the image.
|
||||
# Dockerfile.variant then copies the freshly-cloned package copy OVER the served
|
||||
# path at /usr/local/share/... — but it never writes back to the repo floor. So
|
||||
# the floor only silently rots, and it had: 34284 B, untouched since fa04d20
|
||||
# (2026-07-30), while the package copy was 38973 B. Four copies existed with
|
||||
# three different sizes.
|
||||
#
|
||||
# Why that is worse than ordinary staleness: the floor is a FALLBACK. The copy
|
||||
# step is guarded by `if [ -f /opt/pi-extensions/skill/SKILL.md ]`, so a build
|
||||
# where the package clone yields no skill/ keeps the vendored snapshot and still
|
||||
# succeeds — green, with no manifest flag and no label saying which copy was
|
||||
# served. The image would ship a July skill and nothing would say so. Keeping
|
||||
# the floor fresh means that fallback is harmless instead of a silent regression.
|
||||
#
|
||||
# WHY A DIRECTORY HASH AND NOT `sha256sum SKILL.md`.
|
||||
# The same pipeline Dockerfile.variant uses for skillset_snapshot_tree_sha256,
|
||||
# and for the same documented reason: a file-only compare answers "did this one
|
||||
# file change", not "is this the same skill". pi-extensions ships TWO files
|
||||
# (SKILL.md + evaluate-extension-usage.py), so a sibling-file edit would pass a
|
||||
# file-only check. If you change the pipeline here, change it there too.
|
||||
#
|
||||
# WHY GATING ON ANOTHER REPO IS PROPORTIONATE HERE, since that is normally a
|
||||
# smell: this fires only when the package's skill/ DIRECTORY HASH changes, which
|
||||
# is exactly and only when the floor has genuinely gone stale. pi-extensions
|
||||
# commits that do not touch skill/ leave the hash alone and cannot turn this red.
|
||||
# The repo is also anonymously clonable (verified 2026-09-10 with `git ls-remote`
|
||||
# and no credentials), so this needs no secret and cannot break on token expiry.
|
||||
#
|
||||
# Exit codes — deliberately three, matching scripts/lint-shell.sh's philosophy
|
||||
# that a gate which cannot run must not pass:
|
||||
# 0 in sync (or the package legitimately has no skill/ at this ref)
|
||||
# 1 DRIFT — the floor differs from the package
|
||||
# 2 cannot run — no package copy could be obtained
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
FLOOR_DIR="${REPO_ROOT}/rootfs/usr/local/share/pi-devbox/skills/pi-extensions"
|
||||
|
||||
# Defaults mirror Dockerfile.variant's ARGs so this checks what the build builds.
|
||||
PI_EXTENSIONS_REPO="${PI_EXTENSIONS_REPO:-https://gitea.jordbo.se/joakimp/pi-extensions.git}"
|
||||
PI_EXTENSIONS_REF="${PI_EXTENSIONS_REF:-main}"
|
||||
|
||||
PACKAGE_DIR=""
|
||||
WARN_ONLY=0
|
||||
TMPDIR_CLONE=""
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Usage: scripts/check-skill-floor.sh [options]
|
||||
|
||||
--package-dir DIR Compare against an existing skill directory instead of
|
||||
cloning. In a devbox container use /opt/pi-extensions/skill
|
||||
for a fully offline run.
|
||||
--warn-only Report drift but exit 0 (advisory use, e.g. a local hook).
|
||||
-h, --help This text.
|
||||
|
||||
Environment: PI_EXTENSIONS_REPO, PI_EXTENSIONS_REF (default main) — both mirror
|
||||
the Dockerfile.variant ARGs of the same name.
|
||||
EOF
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--package-dir) PACKAGE_DIR="${2:-}"; shift 2 ;;
|
||||
--warn-only) WARN_ONLY=1; shift ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "::error::unknown argument: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
cleanup() {
|
||||
if [ -n "$TMPDIR_CLONE" ]; then rm -rf "$TMPDIR_CLONE"; fi
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
# Identical to Dockerfile.variant's tree_sha256(): relative paths + per-file
|
||||
# sha256 over a sorted `find`, folded into one digest. Deterministic, never
|
||||
# readdir order.
|
||||
tree_sha256() {
|
||||
( cd "$1" && find . -type f -print | LC_ALL=C sort | xargs -r sha256sum ) \
|
||||
2>/dev/null | sha256sum | cut -d' ' -f1
|
||||
}
|
||||
|
||||
if [ ! -d "$FLOOR_DIR" ]; then
|
||||
echo "::error::floor directory is missing: ${FLOOR_DIR}"
|
||||
echo "::error::rootfs/ is supposed to guarantee the skill is always in the image."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
SOURCE_DESC=""
|
||||
if [ -n "$PACKAGE_DIR" ]; then
|
||||
if [ ! -d "$PACKAGE_DIR" ]; then
|
||||
echo "::error::--package-dir does not exist: ${PACKAGE_DIR}"
|
||||
exit 2
|
||||
fi
|
||||
SOURCE_DESC="local directory ${PACKAGE_DIR}"
|
||||
else
|
||||
command -v git >/dev/null 2>&1 || { echo "::error::git not found; cannot obtain the package copy."; exit 2; }
|
||||
TMPDIR_CLONE=$(mktemp -d)
|
||||
# Fetch the single ref shallowly. `git fetch <ref>` accepts a branch, a tag
|
||||
# and (on Gitea) a reachable commit, which is why this is not `clone --branch`
|
||||
# — CI resolves PI_EXTENSIONS_REF to a 40-hex SHA before the build.
|
||||
if ! ( cd "$TMPDIR_CLONE" \
|
||||
&& git init -q . \
|
||||
&& git remote add origin "$PI_EXTENSIONS_REPO" \
|
||||
&& git fetch -q --depth 1 origin "$PI_EXTENSIONS_REF" \
|
||||
&& git checkout -q FETCH_HEAD ) 2>/dev/null; then
|
||||
echo "::error::could not fetch ${PI_EXTENSIONS_REF} from ${PI_EXTENSIONS_REPO}"
|
||||
echo "::error::Cannot determine whether the floor is stale, so this is exit 2, not a pass."
|
||||
echo "::error::For an offline run, pass --package-dir /opt/pi-extensions/skill"
|
||||
exit 2
|
||||
fi
|
||||
PACKAGE_SHA=$( cd "$TMPDIR_CLONE" && git rev-parse --short HEAD )
|
||||
PACKAGE_DIR="${TMPDIR_CLONE}/skill"
|
||||
SOURCE_DESC="${PI_EXTENSIONS_REPO} @ ${PI_EXTENSIONS_REF} (${PACKAGE_SHA})"
|
||||
fi
|
||||
|
||||
# A ref with no skill/ is the documented fallback case: Dockerfile.variant keeps
|
||||
# the vendored snapshot and the build succeeds. Nothing to compare, so this is
|
||||
# not drift — but it IS the exact condition under which the floor ships, so say
|
||||
# so loudly rather than printing a silent green tick.
|
||||
if [ ! -d "$PACKAGE_DIR" ]; then
|
||||
echo "::warning::package has no skill/ at this ref — the vendored floor is what will ship."
|
||||
echo " source : ${SOURCE_DESC}"
|
||||
echo " floor : $(tree_sha256 "$FLOOR_DIR")"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
FLOOR_HASH=$(tree_sha256 "$FLOOR_DIR")
|
||||
PKG_HASH=$(tree_sha256 "$PACKAGE_DIR")
|
||||
|
||||
if [ "$FLOOR_HASH" = "$PKG_HASH" ]; then
|
||||
echo "OK: vendored pi-extensions floor matches the package."
|
||||
echo " source : ${SOURCE_DESC}"
|
||||
echo " tree_sha256: ${FLOOR_HASH}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# `set -e` interacts badly with `[ … ] && x` as a bare statement, so both of
|
||||
# these are explicit if-blocks rather than AND-lists.
|
||||
LEVEL="error"
|
||||
if [ "$WARN_ONLY" -eq 1 ]; then LEVEL="warning"; fi
|
||||
|
||||
echo "::${LEVEL}::vendored pi-extensions skill floor has DRIFTED from the package."
|
||||
echo " source : ${SOURCE_DESC}"
|
||||
echo " floor tree_sha256 : ${FLOOR_HASH}"
|
||||
echo " pkg tree_sha256 : ${PKG_HASH}"
|
||||
echo ""
|
||||
echo " per-file differences:"
|
||||
diff -rq "$FLOOR_DIR" "$PACKAGE_DIR" 2>&1 | sed 's/^/ /' || true
|
||||
echo ""
|
||||
echo " Remedy — re-sync the floor and commit it:"
|
||||
echo " cp -a <pi-extensions>/skill/. ${FLOOR_DIR}/"
|
||||
echo " git add ${FLOOR_DIR#"${REPO_ROOT}/"} && git commit"
|
||||
echo ""
|
||||
echo " NOTE this forces one full base rebuild: base_tag hashes Dockerfile.base"
|
||||
echo " + rootfs/, and that rebuild is what re-bakes the refreshed floor."
|
||||
|
||||
if [ "$WARN_ONLY" -eq 1 ]; then exit 0; fi
|
||||
exit 1
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user