Compare commits

..

16 Commits

Author SHA1 Message Date
joakimp 47b450694c release: v1.13.1 — pi-extensions 7cb808e (aws-sso ignores a Bedrock tier with a non-Bedrock task fallback)
Lint / hadolint (push) Successful in 13s
Lint / doc-drift (push) Successful in 13s
Lint / skill-floor (push) Successful in 14s
Lint / actionlint (push) Successful in 19s
Lint / skill-frontmatter (push) Successful in 21s
Publish Docker Image / lint-gate (push) Successful in 33s
Publish Docker Image / resolve-versions (push) Successful in 11s
Publish Docker Image / base-decide (push) Successful in 12s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke-studio (push) Successful in 5m26s
Publish Docker Image / build-variant-studio (push) Successful in 19m40s
Publish Docker Image / smoke (push) Successful in 10m45s
Publish Docker Image / build-variant (push) Successful in 20m26s
Publish Docker Image / promote-base-latest (push) Successful in 6s
Publish Docker Image / update-description (push) Successful in 9s
2026-10-25 15:33:46 +01:00
joakimp 7737156aad release: v1.13.0 — CHANGELOG heading, components table, BASE_REBUILD_DATE
Lint / hadolint (push) Successful in 10s
Lint / skill-floor (push) Successful in 11s
Lint / doc-drift (push) Successful in 17s
Lint / actionlint (push) Successful in 34s
Lint / skill-frontmatter (push) Successful in 15s
Publish Docker Image / lint-gate (push) Successful in 26s
Publish Docker Image / resolve-versions (push) Successful in 14s
Publish Docker Image / base-decide (push) Successful in 15s
Publish Docker Image / build-base (push) Successful in 44m19s
Publish Docker Image / smoke (push) Successful in 5m26s
Publish Docker Image / smoke-studio (push) Successful in 11m12s
Publish Docker Image / build-variant (push) Successful in 19m25s
Publish Docker Image / update-description (push) Successful in 15s
Publish Docker Image / promote-base-latest (push) Successful in 17s
Publish Docker Image / build-variant-studio (push) Successful in 17m51s
2026-10-25 13:45:18 +01:00
joakimp 644be67ea2 docs(changelog): aws-sso keeper idle without Bedrock (pi-extensions f032195)
Lint / hadolint (push) Successful in 10s
Lint / skill-floor (push) Successful in 13s
Lint / doc-drift (push) Successful in 15s
Lint / actionlint (push) Successful in 23s
Lint / skill-frontmatter (push) Successful in 13s
2026-10-25 13:29:29 +01:00
joakimp f7e35962d6 docs(changelog): mailbox owed-list fix from mempalace-toolkit c04d488
Lint / hadolint (push) Successful in 10s
Lint / skill-floor (push) Successful in 13s
Lint / doc-drift (push) Successful in 14s
Lint / skill-frontmatter (push) Successful in 18s
Lint / actionlint (push) Successful in 32s
2026-10-25 13:10:00 +01:00
joakimp b06941d714 firstboot skill: say what to do when a command gate blocks a probe
Lint / skill-floor (push) Successful in 10s
Lint / hadolint (push) Successful in 16s
Lint / doc-drift (push) Successful in 16s
Lint / actionlint (push) Successful in 20s
Lint / skill-frontmatter (push) Successful in 21s
erdc-guard (fleet extension, not in the image) judges the text of every
bash statement. Measured with its own assess(): ERDC_GUARD=off on a
child pi does not help (the parent blocks the prompt text, 0.95); pi -p
@file is not flagged; cat heredocs are flagged depending on contents;
/guard off is volume-wide and keeps confirm-destructive stood down.
Order: write tool, @file, /tmp scratch, /guard off last. A blocked probe
is a cannot-run.
2026-10-24 16:16:10 +02:00
joakimp 00345d3536 changelog: confirm-destructive steps aside for another bash gate (pi-extensions 7237855)
Lint / hadolint (push) Successful in 15s
Lint / skill-floor (push) Successful in 14s
Lint / doc-drift (push) Successful in 17s
Lint / actionlint (push) Successful in 21s
Lint / skill-frontmatter (push) Successful in 13s
The next image bakes pi-extensions main, which now has 7237855. With a second
command gate loaded (the fleet's erdc-guard), confirm-destructive still asked
about every command that gate let through, with no time limit. It now skips its
bash check while another extension holds the claim
Symbol.for("pi-extensions.bash-gate"). Its /new, /resume and /fork questions
are unchanged, and with no claim it behaves as before.

check-doc-drift.sh check 9 (every moved component named above the last release
heading) passes: pi-extensions 4c1d28d -> 7237855.
2026-10-24 15:39:10 +02:00
pi@mbp-m1-2020 057827a84b skills: trim two descriptions to a routing sentence, and assert their pointers
Lint / hadolint (push) Successful in 10s
Lint / skill-floor (push) Successful in 14s
Lint / doc-drift (push) Successful in 18s
Lint / actionlint (push) Successful in 32s
Lint / skill-frontmatter (push) Successful in 15s
Every skill's name and description sit in the system prompt of EVERY request --
that block is the routing index -- while bodies are fetched on demand through
the read tool. Measured on this device: 24 discoverable skills cost 10,609
description chars (~2,652 tokens) per request, against 474,656 chars of bodies
(~119k tokens) that cost nothing until used. Bodies are 45x the descriptions,
which is what the spec's 1024-char description limit is protecting.

credential-incident-response (1022, two characters under the limit) and
pi-devbox-environment (923) were each carrying a table of contents of what
their body covers. Trimmed to what a description is actually for -- answering
"should I load this?" -- reclaiming 1,087 chars (~271 tokens) of permanent
context. 4,154 -> 3,067 across the five baked skills.

WHY ONLY THESE TWO. Both are named BY PATH from the always-loaded managed
block, so neither depends on description-matching to be found. pi-extensions is
left alone deliberately: check-skill-floor.sh compares the vendored copy to the
package repo by DIRECTORY HASH, so trimming ours alone registers as drift and
turns that gate red; and it is the one skill that also ships in the skillset
repo, where it runs on hosts with no pi-devbox managed block and genuinely needs
its description. pi-devbox-firstboot stays fuller than needed because it is the
skill a device with nothing else has to find.

NOTHING WAS DELETED, ONLY RELOCATED -- and that was checked rather than assumed:
all 18 enumerated items were confirmed present in the bodies BEFORE cutting
(frontmatter stripped, so the description's own line could not score its own
claim), and `git diff --stat` confirms both bodies are byte-identical.

THE TRIM CREATES A DEPENDENCY, SO IT IS NOW ASSERTED. smoke-test.sh checks both
pointers by path, next to the firstboot one. The premise of the cut is "the
block names them", which holds only while those lines exist -- reword the block,
drop a pointer, and the skill silently stops being reachable for a device that
did not already know it was there. Verified discriminating: a reworded block
fails the grep.

An instrument failure caught on the way, and the reason the coverage check was
re-run: my first pass used `grep -E 'a\|b'`, where \| is a LITERAL pipe in ERE,
so 8 of 18 items read as 0 hits. Every zero was a pattern containing \| and
every non-zero lacked one -- that correlation, not the content, was the finding.
With real alternation all 18 are present.

Loader re-verified on both trimmed skills: rc=0, no skill diagnostics.
2026-10-24 11:26:26 +02:00
pi@mbp-m1-2020 206fd1ec35 ci: gate baked skill frontmatter against the loader's own limits
Lint / skill-floor (push) Successful in 9s
Lint / hadolint (push) Successful in 10s
Lint / doc-drift (push) Successful in 18s
Lint / skill-frontmatter (push) Successful in 19s
Lint / actionlint (push) Successful in 32s
Found while adding pi-devbox-firstboot, whose description was drafted at 1056
characters against pi's MAX_DESCRIPTION_LENGTH of 1024. pi does not reject it:
dist/core/skills.js pushes a diagnostic and then loads the skill anyway --
"Still load the skill even with warnings, unless description is missing or
empty". So the skill works, the violation is real, and the only thing that
would ever report it is a diagnostic inside a live pi session. Nothing in CI
starts one, so the warning is invisible here by construction. A spec violation
that still works is the most durable kind of rot: nothing forces the fix.

Unlike check-skill-floor.sh, this was NOT free to add. Measured headroom on the
five baked skills: credential-incident-response 1022 (2 chars spare),
pi-extensions 1003 (21), pi-devbox-firstboot 952, pi-devbox-environment 923,
mempalace 254. Two of five are within one sentence of the limit, so "nobody
would write a description that long" is already false here.

It also catches the sibling failure that is a silent DROP rather than a
warning: a missing or empty description returns skill: null, so the file ships,
the symlink resolves, smoke-test's `test -f` passes, pi-devbox-version lists it
-- and no agent can ever see it. Name rules are checked too, including against
the directory, because the loader falls back to parentDirName and a mismatch
silently renames the skill while every path-based pointer (the managed AGENTS
block included) keeps naming the directory.

Truth table, each mutation verified to differ materially from the shipped tree:
shipped 0; over-long description 1; missing description 1 (reported as fatal,
not a mere violation); name != directory 1; no frontmatter 1; directory with no
SKILL.md 1; absent skills tree 2. Exit codes match lint-shell.sh,
check-skill-floor.sh and check-doc-drift.sh -- a gate that cannot run must not
pass.

python3-yaml rather than grep/awk because `description: >-` is a folded block
scalar whose value spans lines and joins with spaces, so a line-oriented length
check measures the wrong string. Same package the actionlint job already
installs, same parser check-doc-drift.sh already uses.

The vendored copies (pi-extensions, mempalace) are deliberately in scope: an
upstream edit over the limit turns this red on a file this repo does not author,
which is precedented by check-skill-floor.sh's coupling and correct because the
image ships those bytes. The fix in that case is to refresh the snapshot, not to
exempt the file.
2026-10-24 00:32:13 +02:00
pi@mbp-m1-2020 5d6ff537c4 skills: add pi-devbox-firstboot, and point the managed AGENTS block at it
The fleet's first-boot acceptance round is now scoped by published state
(policy v2, artifact art_20261023T204306_b388e93c3d64): a booting container
reads two claim keys -- firstboot-release-<TAG> and
firstboot-image-<TAG>-<ARCH> -- and derives whether it owes the release tier,
the per-architecture image tier, or only the device tier.

Nothing delivered that procedure to a device that does not already have a
coordination chain. The gate deliberately needs no roster, because fleet
membership changes in both directions, so no message addresses a new device and
nothing enrolls it. The consequence is that a BRAND-NEW device -- empty
mailbox, no chain, and the mempalace skill correctly telling it that an empty
mailbox is a fine answer -- had no path to the round at all. Historically a
peer planted the newcomer's first ask cross-device, which is exactly the roster
dependency the state design removes. This skill is how a device learns to plant
its own.

The skill is a FINDER, not the policy: it locates the newest policy.update on
topic=firstboot-acceptance and fetches the artifact, and says plainly that the
artifact wins on disagreement. A policy baked into an image is pinned to that
image; keeping only the lookup here means a revision needs no release and a
device on an older image still reaches the current policy.

Baked canonical, like pi-devbox-environment, rather than skillset-owned. The
round verifies the image, so pinning it to the image is right -- and a
skillset-owned skill needs the vendoring machinery (ARG SKILLSET_SNAPSHOT_REF,
the manifest/label pair, a per-skill refresh script) that today exists only for
mempalace. No wiring was needed: entrypoint-user.sh and pi-devbox-version both
enumerate the baked skills directory.

smoke-test.sh asserts the AGENTS.md pointer BY PATH, not by prose. A new device
reaches the round only through that line, so rewording the paragraph must not
be able to cut it silently. Both skill-resolution loops gain the new name.
2026-10-24 00:31:15 +02:00
joakimp 779efbf4fe ci: pin the smoke image with a running container during the test
Lint / hadolint (push) Successful in 12s
Lint / skill-floor (push) Successful in 13s
Lint / doc-drift (push) Successful in 17s
Lint / actionlint (push) Successful in 26s
The label only protects against reclaims that honour it. The runner
hosts also run jobs we do not control: runner-2 has a second act_runner
registered with gitea.egl.lan on the same Docker daemon, and
opencode-devbox's workflows run raw `docker system prune -af` in 7
places. No prune removes an image a running container uses, so the
smoke step now starts a holder (`ci-reclaim-docker.sh hold`: --rm,
--network none, sleep 3600) right after the preflight, and the
always() cleanup step runs `release` (holder + image). Unprotected now:
only the seconds between the build step's load and the holder starting.

Verified on an isolated docker:29-dind: an unlabelled held image survives
raw system prune and image prune -af (control without holder: deleted);
hold fails on a missing image; holder is AutoRemove + network none;
release removes holder and image; reclaim behaviour unchanged.
2026-10-23 12:46:39 +02:00
joakimp e0611c787f ci docs: the shared daemon is per runner (runner-a1 runs several jobs at once)
Lint / skill-floor (push) Successful in 11s
Lint / hadolint (push) Successful in 12s
Lint / doc-drift (push) Successful in 13s
Lint / actionlint (push) Successful in 32s
Task logs of run 740: runner-a1 received task 2721 (smoke-studio) at
22:53:02Z and task 2722 (build-variant) at 22:58:38Z while 2721 was still
running. Other runners (runner-1/2/3) have capacity 1. 12e66aa said all
jobs share one daemon; corrected in CHANGELOG, AGENTS.md, the script
header and the lint.yml comment. No behaviour change.
2026-10-23 11:43:48 +02:00
joakimp 12e66aa52e ci: stop disk reclaim from deleting a smoke image mid-test
Lint / skill-floor (push) Successful in 9s
Lint / hadolint (push) Successful in 15s
Lint / actionlint (push) Successful in 22s
Lint / doc-drift (push) Successful in 12s
v1.12.2 run 740: build-variant's `docker system prune -af --volumes`
untagged pi-devbox:smoke-studio 30 ms after smoke-studio loaded it, so
every studio assertion failed and the -studio tags were not published.
All jobs share the runner's Docker daemon, and a smoke image is unused
between smoke-test.sh's one-shot `docker run`s.

- scripts/ci-reclaim-docker.sh: the same reclaim, per object type, but
  never prunes an image labelled se.jordbo.pi-devbox.ci-smoke-image;
  labelled orphans are swept after 6h. `assert-protected IMG` is the
  smoke preflight; `lint WORKFLOW...` rejects raw system/image prunes.
- smoke jobs: label their image, assert it, tag it per run
  (pi-devbox:smoke[-studio]-<run_id>), remove it in an always() step.
- all five reclaim sites call the script; lint.yml runs the guard.
- Job order unchanged: studio jobs still gate only the -studio tags.

Verified on an isolated docker:29-dind (old prune deletes a labelled
image; new reclaim keeps it, removes unlabelled + 1970-created labelled
images, spares an in-use image, no errors); actionlint, shellcheck,
lint-shell, check-workflow-shell, check-doc-drift all clean.
2026-10-23 08:34:39 +02:00
joakimp fb2cb2305c release(v1.12.2): pi 1.4.0, pi-studio v0.11.1, Node 24.22.1 + git-lfs 3.8.1 security
Lint / skill-floor (push) Successful in 8s
Lint / hadolint (push) Successful in 10s
Lint / actionlint (push) Successful in 23s
Lint / doc-drift (push) Successful in 14s
Publish Docker Image / lint-gate (push) Successful in 24s
Publish Docker Image / resolve-versions (push) Successful in 12s
Publish Docker Image / base-decide (push) Successful in 10s
Publish Docker Image / build-base (push) Successful in 42m52s
Publish Docker Image / smoke (push) Successful in 5m33s
Publish Docker Image / build-variant (push) Successful in 21m39s
Publish Docker Image / promote-base-latest (push) Successful in 11s
Publish Docker Image / update-description (push) Successful in 12s
Publish Docker Image / smoke-studio (push) Successful in 5m19s
Publish Docker Image / build-variant-studio (push) Successful in 22m9s
Patch release. pi 1.3.0 -> 1.4.0 (audited, notes next to ARG PI_VERSION in
Dockerfile.variant). pi-studio v0.10.2 -> v0.11.1 (-studio tags; pane echo
now defaults to Summary, see Upgrade notes). The base rebuilds because
rootfs/ changed (8ae1659), so Node v24.22.1 (8 CVEs), git-lfs v3.8.1
(4 CVEs), uv 0.13.5 and agent-browser 0.39.2 come in from latest.
BASE_REBUILD_DATE marker refreshed while the base rebuilds anyway.
check-doc-drift.sh: OK (pi-version and pi-studio named above v1.12.1).
2026-10-23 00:07:49 +02:00
joakimp 8ae1659273 setup-lan-access: Include ~/.ssh/config even if it appears after start
Lint / skill-floor (push) Successful in 11s
Lint / hadolint (push) Successful in 17s
Lint / actionlint (push) Successful in 22s
Lint / doc-drift (push) Successful in 13s
Same rule as c693aa3 (ssh-lan.conf). `Include ~/.ssh/config` went into
~/.ssh-local/config only if ~/.ssh/config was readable at container start.
A file that appeared later, e.g. on a host provisioned after its first
`docker compose up`, was ignored until a restart. Entries ADDED to a file that
already existed were never affected. Measured on tor-ms22 2026-10-19,
expectations written first:
- ssh reads Included files on every connection. A Host added after the config
  was generated resolved with no rerun.
- the bind mount is live: on the Mac, a created file, an atomic replace
  (write + mv) and an append each showed in the container within a second.
So this fixes only the file-appears-later case. The line is now always
written. A file that exists but is unreadable is still left out (ssh would
abort every connection), now with a warning on stderr.

Generated config compared with v1.12.1's: byte-identical when ~/.ssh/config
exists at start (DEVBOX_LAN_ACCESS=jump and auto, with and without
ssh-lan.conf), and stderr is the same too. When the file is absent, the only
difference is the added Include block.
scripts/smoke-test.sh: two new checks, run here the way exec_test runs them
(sh -c). Against v1.12.1, "Included even if created after start" FAILS and
"unreadable left out" PASSES. Against this script both pass, and so do the two
ssh-lan.conf checks.

CHANGELOG Unreleased also names pi-studio v0.10.4 (c83940d), because
check-doc-drift (and so lint on main) had gone red: upstream released 0.10.3
and 0.10.4 right after v1.12.1. 0.10.4 fixes a 0.10.2 Stop regression for
macOS uid -2. Not reachable in the image: 0.10.2's own readStopProcessTable
parsed this container's live ps output (9 rows, min uid 1000). Control: a
macOS-style -2 row throws. The lockfile packages are unchanged, and 0.10.4
loads into pi 1.3.0 with the same 4 studio_* tools, rc 0.

check-doc-drift rc 0, lint-shell rc 0, shellcheck warnings 0 before and after.
2026-10-19 08:25:14 +02:00
joakimp 3b1c67a9bf release(v1.12.1): ssh-lan.conf Include fix, pi-studio v0.10.2, gitea-mcp v1.10.0
Lint / skill-floor (push) Successful in 11s
Lint / hadolint (push) Successful in 27s
Lint / doc-drift (push) Successful in 28s
Lint / actionlint (push) Successful in 37s
Publish Docker Image / lint-gate (push) Successful in 22s
Publish Docker Image / resolve-versions (push) Successful in 12s
Publish Docker Image / base-decide (push) Successful in 9s
Publish Docker Image / build-base (push) Successful in 42m39s
Publish Docker Image / smoke (push) Successful in 5m24s
Publish Docker Image / smoke-studio (push) Successful in 8m23s
Publish Docker Image / build-variant (push) Successful in 16m59s
Publish Docker Image / update-description (push) Successful in 16s
Publish Docker Image / promote-base-latest (push) Successful in 18s
Publish Docker Image / build-variant-studio (push) Successful in 17m33s
Patch release. The fix itself is c693aa3: setup-lan-access.sh now writes the
ssh-lan.conf Include whether or not the file exists at container start.

Upstream survey on 2026-10-19 (check-doc-drift check 9, npm, PyPI, tags, and
the GitHub/Gitea release APIs for every base tool pinned to `latest`):
- moved: pi-studio v0.10.1 -> v0.10.2 (4b4abd1), pi-toolkit 3760d90 -> 7c05901,
  gitea-mcp v1.9.0 -> v1.10.0
- unchanged: pi 1.3.0, mempalace 3.11.0, pi-atelier v0.16.0, pi-obsmem 022da5e,
  pi-extensions, pi-fork, mempalace-toolkit, skillset snapshot, and every
  other base tool
Audits, all on tor-ms22:
- pi-studio 0.10.2: lockfile packages identical to 0.10.1's. Loaded into
  pi 1.3.0, it registers the same 4 studio_* tools, rc 0.
- pi-toolkit: the new model IDs are in pi 1.3.0's bedrock registry, and they
  reach fresh volumes only (the settings merge fills only missing keys).
- gitea-mcp 1.10.0: stdio tools/list gives 58 -> 61 tools, none removed, and
  stdout carries JSON only.

CHANGELOG: Unreleased -> v1.12.1 with the components table and the audits.
README: "Since v1.12.1". Dockerfile.base: BASE_REBUILD_DATE marker refreshed;
the base rebuilds anyway because rootfs changed.
check-doc-drift rc 0, lint-shell, the link-pi contract tests, skill-floor and
the Dockerfile directives check: all clean.
2026-10-19 00:37:51 +02:00
joakimp c693aa341d setup-lan-access: Include ssh-lan.conf even if it is created after start
Lint / skill-floor (push) Successful in 13s
Lint / doc-drift (push) Failing after 13s
Lint / hadolint (push) Successful in 17s
Lint / actionlint (push) Successful in 20s
The Include for ~/.config/devbox-shell/ssh-lan.conf was written only if the
file was readable at container start. A file created afterwards was visible in
the container and silently ignored. Hit on EMB-3HXGNXJ0 on 2026-10-18: the
tor-ms22 override was skipped, ssh fell through to the Mac's passphrase key,
and BatchMode got "Permission denied (publickey,...)" until the script was
rerun by hand.

The line is now always written. Measured with this image's OpenSSH 10.0p2,
expectations written before each run:
- file missing, and also its parent directory missing: Include skipped, rc 0
- file created after the config was written: read on the next ssh, no rerun
- a directory at that path: ignored, rc 0
- file present but unreadable: ssh aborts EVERY connection ("Can't open user
  config file"). So that case is still left out, now with a warning on stderr.
- world-writable file: "Bad owner or permissions". The old code Included that
  case too, so nothing changes there.
When the file exists at start, the generated config is byte-identical to
v1.12.0's. When it is absent, the only difference is the added Include block.

scripts/smoke-test.sh: two new checks, "Included even if created after start"
and "unreadable left out". Run here, the way exec_test runs them: against the
v1.12.0 script the first FAILs and the second PASSes; against this one both
PASS. README "Naming LAN peers": creating the file no longer needs a restart.
CHANGELOG: Unreleased > Fixed.

lint-shell: clean. check-doc-drift still fails, as it already did on HEAD:
pi-toolkit 3760d90->7c05901 and pi-studio 76db01f->4b4abd1 have moved upstream
since v1.12.0 and are not named in the CHANGELOG. That is release prep and is
not addressed here.
2026-10-19 00:17:23 +02:00
16 changed files with 1366 additions and 46 deletions
+49 -14
View File
@@ -90,6 +90,12 @@ env:
IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/pi-devbox IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/pi-devbox
RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || inputs.release_tag }} RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || inputs.release_tag }}
PROMOTE_LATEST: ${{ github.ref_type == 'tag' && 'true' || inputs.promote_latest }} PROMOTE_LATEST: ${{ github.ref_type == 'tag' && 'true' || inputs.promote_latest }}
# Label that protects a smoke job's loaded image from the disk reclaim every
# job runs on the shared runner daemon (scripts/ci-reclaim-docker.sh says
# why: v1.12.2 run 740 lost pi-devbox:smoke-studio to build-variant's prune
# 30 ms after loading it). Set once here; the smoke builds apply it, the
# reclaim script honours it, the smoke preflight asserts it.
SMOKE_IMAGE_LABEL: se.jordbo.pi-devbox.ci-smoke-image
jobs: jobs:
# ── Phase 1: decide whether base needs rebuilding ────────────────── # ── Phase 1: decide whether base needs rebuilding ──────────────────
@@ -538,8 +544,7 @@ jobs:
/usr/lib/jvm 2>/dev/null || true /usr/lib/jvm 2>/dev/null || true
apt-get clean || true apt-get clean || true
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
docker system prune -af --volumes || true bash scripts/ci-reclaim-docker.sh
docker builder prune -af || true
df -h / || true df -h / || true
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
@@ -597,6 +602,10 @@ jobs:
needs.resolve-versions.result == 'success' && needs.resolve-versions.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped') (needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
# Per run, so two runs on different refs cannot test or delete each
# other's image on the shared daemon. run_id is stable across re-runs.
SMOKE_IMAGE: pi-devbox:smoke-${{ github.run_id }}
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
steps: steps:
@@ -610,8 +619,7 @@ jobs:
/usr/local/lib/android /usr/local/share/powershell \ /usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \ /usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true /usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true bash scripts/ci-reclaim-docker.sh
docker builder prune -af || true
- uses: docker/setup-buildx-action@v4 - uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host} with: {driver-opts: network=host}
- uses: docker/login-action@v3 - uses: docker/login-action@v3
@@ -626,7 +634,8 @@ jobs:
platforms: linux/amd64 platforms: linux/amd64
push: false push: false
load: true load: true
tags: pi-devbox:smoke tags: ${{ env.SMOKE_IMAGE }}
labels: ${{ env.SMOKE_IMAGE_LABEL }}=true
build-args: | build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }} PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
@@ -648,7 +657,19 @@ jobs:
# Asserting the BUILT image matches it also catches a stale cached layer. # Asserting the BUILT image matches it also catches a stale cached layer.
EXPECTED_NODE_MAJOR=$(sed -n 's/^ARG NODE_VERSION=\([0-9][0-9]*\).*/\1/p' Dockerfile.base) EXPECTED_NODE_MAJOR=$(sed -n 's/^ARG NODE_VERSION=\([0-9][0-9]*\).*/\1/p' Dockerfile.base)
export EXPECTED_NODE_MAJOR export EXPECTED_NODE_MAJOR
bash scripts/smoke-test.sh pi-devbox:smoke bash scripts/ci-reclaim-docker.sh assert-protected "$SMOKE_IMAGE"
# Pin the image with a running container for the whole test, so a
# raw prune from a job we do not control (other repos, the other
# Gitea instance's runner on the same VM) cannot remove it either.
bash scripts/ci-reclaim-docker.sh hold "$SMOKE_IMAGE"
bash scripts/smoke-test.sh "$SMOKE_IMAGE"
# The label keeps this image from every job's reclaim and the holder pins
# it, so both are removed here. A job killed before this step leaves an
# orphan: the holder exits after 1 h, and the reclaim's age sweep deletes
# the image once it is older than SMOKE_IMAGE_MAX_AGE.
- name: Remove smoke image
if: always()
run: bash scripts/ci-reclaim-docker.sh release "$SMOKE_IMAGE"
# ── Phase 3b: amd64 smoke for the studio variant ──────────────────── # ── Phase 3b: amd64 smoke for the studio variant ────────────────────
# Additive + independent of the core `smoke` job: gates ONLY # Additive + independent of the core `smoke` job: gates ONLY
@@ -662,6 +683,10 @@ jobs:
needs.resolve-versions.result == 'success' && needs.resolve-versions.result == 'success' &&
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped') (needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
runs-on: ubuntu-latest runs-on: ubuntu-latest
env:
# Per run, so two runs on different refs cannot test or delete each
# other's image on the shared daemon. run_id is stable across re-runs.
SMOKE_IMAGE: pi-devbox:smoke-studio-${{ github.run_id }}
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
steps: steps:
@@ -675,8 +700,7 @@ jobs:
/usr/local/lib/android /usr/local/share/powershell \ /usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \ /usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true /usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true bash scripts/ci-reclaim-docker.sh
docker builder prune -af || true
- uses: docker/setup-buildx-action@v4 - uses: docker/setup-buildx-action@v4
with: {driver-opts: network=host} with: {driver-opts: network=host}
- uses: docker/login-action@v3 - uses: docker/login-action@v3
@@ -691,7 +715,8 @@ jobs:
platforms: linux/amd64 platforms: linux/amd64
push: false push: false
load: true load: true
tags: pi-devbox:smoke-studio tags: ${{ env.SMOKE_IMAGE }}
labels: ${{ env.SMOKE_IMAGE_LABEL }}=true
build-args: | build-args: |
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }} PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
@@ -716,7 +741,19 @@ jobs:
# Asserting the BUILT image matches it also catches a stale cached layer. # Asserting the BUILT image matches it also catches a stale cached layer.
EXPECTED_NODE_MAJOR=$(sed -n 's/^ARG NODE_VERSION=\([0-9][0-9]*\).*/\1/p' Dockerfile.base) EXPECTED_NODE_MAJOR=$(sed -n 's/^ARG NODE_VERSION=\([0-9][0-9]*\).*/\1/p' Dockerfile.base)
export EXPECTED_NODE_MAJOR export EXPECTED_NODE_MAJOR
bash scripts/smoke-test.sh pi-devbox:smoke-studio bash scripts/ci-reclaim-docker.sh assert-protected "$SMOKE_IMAGE"
# Pin the image with a running container for the whole test, so a
# raw prune from a job we do not control (other repos, the other
# Gitea instance's runner on the same VM) cannot remove it either.
bash scripts/ci-reclaim-docker.sh hold "$SMOKE_IMAGE"
bash scripts/smoke-test.sh "$SMOKE_IMAGE"
# The label keeps this image from every job's reclaim and the holder pins
# it, so both are removed here. A job killed before this step leaves an
# orphan: the holder exits after 1 h, and the reclaim's age sweep deletes
# the image once it is older than SMOKE_IMAGE_MAX_AGE.
- name: Remove smoke image
if: always()
run: bash scripts/ci-reclaim-docker.sh release "$SMOKE_IMAGE"
# ── Phase 4: multi-arch publish ───────────────────────────────────── # ── Phase 4: multi-arch publish ─────────────────────────────────────
build-variant: build-variant:
@@ -741,8 +778,7 @@ jobs:
/usr/local/lib/android /usr/local/share/powershell \ /usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \ /usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true /usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true bash scripts/ci-reclaim-docker.sh
docker builder prune -af || true
- uses: docker/setup-qemu-action@v3 - uses: docker/setup-qemu-action@v3
with: {platforms: arm64} with: {platforms: arm64}
- uses: docker/setup-buildx-action@v4 - uses: docker/setup-buildx-action@v4
@@ -834,8 +870,7 @@ jobs:
/usr/local/lib/android /usr/local/share/powershell \ /usr/local/lib/android /usr/local/share/powershell \
/usr/local/share/chromium /usr/local/share/boost \ /usr/local/share/chromium /usr/local/share/boost \
/usr/lib/jvm 2>/dev/null || true /usr/lib/jvm 2>/dev/null || true
docker system prune -af --volumes || true bash scripts/ci-reclaim-docker.sh
docker builder prune -af || true
- uses: docker/setup-qemu-action@v3 - uses: docker/setup-qemu-action@v3
with: {platforms: arm64} with: {platforms: arm64}
- uses: docker/setup-buildx-action@v4 - uses: docker/setup-buildx-action@v4
+55
View File
@@ -90,6 +90,15 @@ jobs:
# actionlint so the more precise diagnostic surfaces first. # actionlint so the more precise diagnostic surfaces first.
run: bash scripts/check-workflow-shell.sh .gitea/workflows run: bash scripts/check-workflow-shell.sh .gitea/workflows
- name: No raw docker prune in workflows (shared runner daemon)
# Everything on one runner host shares its Docker daemon (runner-a1
# runs several jobs at once; runner-2 also serves gitea.egl.lan), and
# a bare `docker system prune -a` in one job deletes the smoke image
# another job is testing (v1.12.2 run 740 shipped without its -studio
# tags that way). Reclaim goes through scripts/ci-reclaim-docker.sh,
# which spares labelled smoke images; this keeps it that way.
run: bash scripts/ci-reclaim-docker.sh lint .gitea/workflows/*.yml
- name: link-pi-models.sh contract (two-sided, no docker needed) - name: link-pi-models.sh contract (two-sided, no docker needed)
# The entrypoint links ~/.pi/agent/models.json to a mounted shared file # The entrypoint links ~/.pi/agent/models.json to a mounted shared file
# and must never replace a real file or a user's own link. Tested here # and must never replace a real file or a user's own link. Tested here
@@ -240,3 +249,49 @@ jobs:
- name: Doc claims match the build files - name: Doc claims match the build files
run: bash scripts/check-doc-drift.sh run: bash scripts/check-doc-drift.sh
skill-frontmatter:
# Gate the image-baked skills' frontmatter against the spec pi's own loader
# measures. Its own job, for the same reason as skill-floor and doc-drift:
# "a shipped skill violates the spec" should be a distinct red name, not a
# line buried in a job about workflow syntax.
#
# The gap it closes, measured 2026-10-23 while adding pi-devbox-firstboot:
# that skill's description was drafted at 1056 chars against the loader's
# MAX_DESCRIPTION_LENGTH of 1024. pi does NOT reject it — dist/core/skills.js
# pushes a diagnostic and then loads the skill anyway ("Still load the skill
# even with warnings, unless description is missing or empty"). So the only
# thing that would ever report the violation is a diagnostic inside a live pi
# session, and nothing in CI starts one. A spec violation that still works is
# the most durable kind of rot: nothing forces the fix.
#
# Not free in the way skill-floor was free: two of the five baked skills are
# already within ~20 chars of the limit (credential-incident-response 1022,
# pi-extensions 1003), so this fires on a plausible next edit rather than on
# a hypothetical one.
#
# The sibling failure it also catches is a silent DROP rather than a warning:
# a missing/empty description returns skill: null, so the file ships, the
# symlink resolves, smoke-test's `test -f` passes, pi-devbox-version lists
# it — and no agent can ever see it.
#
# Exit codes 0 valid / 1 violation / 2 cannot-run, matching lint-shell.sh,
# check-skill-floor.sh and check-doc-drift.sh. A missing skills tree or a
# python3 without the yaml module is a red 2, not a green tick.
runs-on: ubuntu-latest
container:
image: catthehacker/ubuntu:act-latest
steps:
- uses: actions/checkout@v4
# python3-yaml because `description: >-` is a folded block scalar: the
# value spans lines and joins with spaces, so a line-oriented length
# check measures the wrong string. Same package the actionlint job
# installs, same parser check-doc-drift.sh already relies on.
- name: Install python3-yaml
run: |
apt-get update
apt-get install -y --no-install-recommends python3-yaml
- name: Baked skill frontmatter is valid
run: bash scripts/check-skill-frontmatter.sh
+10
View File
@@ -45,6 +45,16 @@ re-brand of opencode-devbox's `pi-only` variant.
update-description). The `-studio` variant adds independent update-description). The `-studio` variant adds independent
`smoke-studio` + `build-variant-studio` jobs that gate only the `smoke-studio` + `build-variant-studio` jobs that gate only the
`-studio` tags (never the core `:latest` release). `-studio` tags (never the core `:latest` release).
- `scripts/ci-reclaim-docker.sh` — the ONLY way a CI job frees disk. Everything
on one runner host shares its Docker daemon (runner-a1 runs several jobs at
once; runner-2 also hosts a runner for gitea.egl.lan), so a bare `docker system prune -a` in one
job deletes the smoke image another job is mid-test on (v1.12.2 run 740 lost
its `-studio` tags that way). The script spares images labelled
`se.jordbo.pi-devbox.ci-smoke-image`; the smoke jobs apply that label,
assert it (`assert-protected`), pin the image with a running container for
the test (`hold`, against raw prunes from jobs we do not control) and
`release` it at the end.
`lint.yml` fails on any raw `docker system|image prune` in a workflow.
## Versioning scheme ## Versioning scheme
+482
View File
@@ -11,6 +11,488 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
--- ---
## v1.13.1 — 2026-10-25
Patch release with one change since v1.13.0, the pi-extensions AWS SSO keeper
fix below. Nothing else moved: pi-extensions `f032195` → **`7cb808e`** is the
only component the build bakes differently. The base image is not rebuilt
(no base input changed), so the base tools are byte-identical to v1.13.0.
> **Note:** v1.13.1 exists because v1.13.0 was built minutes before
> pi-extensions `7cb808e` landed. CI resolves pi-extensions `main` when a build
> starts, so v1.13.0 shipped `f032195`, whose keeper still counts a Bedrock
> `deep` fork profile. With `deep` on Bedrock, an ERDC session on v1.13.0 can
> still ask for an AWS login. Prefer v1.13.1 over v1.13.0.
### Fixed
- **The AWS SSO keeper ignores a Bedrock fork tier that has a non-Bedrock
fallback** (pi-extensions `7cb808e`). If `pi-task.fallbackProfiles.<tier>`
names a non-Bedrock model, that tier no longer makes the keeper log in. Then a
setup like `deep` = Bedrock Opus, falling back to `erdc/glm-5.3`, never asks
for an AWS login in an ERDC session. While the login is valid, `deep` tasks
run on Opus. Once it has expired, the `task` tool retries once on the
fallback (only after an auth error before any tool call), and its report says
`[FALLBACK]`. Measured 2026-10-25 on tor-ms22 with a deliberately invalid AWS
key: Bedrock answered `UnrecognizedClientException`, and the task finished on
glm-5.3. The `fork` tool has no fallback: a `deep` fork still fails until you
log in (`/aws-sso now`).
## v1.13.0 — 2026-10-25
Minor release. Headline: the **`pi-devbox-firstboot` skill** (first-boot
acceptance scoped by published state) and two fixes our own repos carry: the
**mailbox no longer lists long-answered asks as owed** against the 3.11.0
central palace (mempalace-toolkit `c04d488`), and the **AWS SSO keeper stays
idle when nothing in the session uses Bedrock** (pi-extensions `f032195`). Also
`confirm-destructive` steps aside for another bash gate, a skill-frontmatter
lint job, and the CI smoke-image reclaim fix.
Surveyed 2026-10-25, each upstream queried directly. Unchanged since v1.12.2:
pi `1.4.0` (npm `latest`), mempalace `3.11.0` (PyPI `latest`), pi-atelier
`v0.16.0` (`3b42825`, newest tag), pi-observational-memory `022da5e`
(= `master`), pi-fork `e69725c` (= `master`), pi-studio `v0.11.1` (`8a410b4`,
= `main`, newest tag), pi-toolkit `7c05901`, Node `v24.22.1` (newest 24.x; Node
26 is not LTS yet). The skillset snapshot needs no refresh
(`vendor-mempalace-skill.sh --check`: OK, still `96b840e`). The base image is
rebuilt, because `rootfs/` changed and the mempalace-toolkit SHA is part of the
base hash, so apt and the base tools pinned to `latest` re-resolve. Of those,
gosu, gitea-mcp and tealdeer moved upstream (table below). nvim `0.12.6`, fzf
`0.74.5`, git-lfs `3.8.1`, gitleaks `8.30.1`, bat `0.26.1`, eza `0.23.5`, zoxide
`0.10.0`, uv `0.13.5`, micro `2.0.15`, typst `0.15.1`, yq `4.54.1`,
agent-browser `0.39.2` and playwright `1.64.1` match their newest releases.
Each was compared against the v1.12.2 image on tor-ms22. The new download
assets for both architectures exist (HTTP 200).
### Components that move with this release
| Component | Baked in v1.12.2 | v1.13.0 | Why |
|---|---|---|---|
| **mempalace-toolkit** | `d5a03f4` | **`c04d488`** | mailbox fix, see *Fixed* below |
| **pi-extensions** | `4c1d28d` | **`f032195`** | `7237855` (bash gate, *Changed*) + `f032195` (aws-sso, *Fixed*) |
| gosu (base) | `1.19` | **`1.20`** (or later: `latest` at build time) | Go security update (GO-2026-6686); upstream sees no realistic attack path in gosu |
| gitea-mcp (base) | `v1.10.0` | **`v1.11.0`** (or later) | additive: `project_write` column options, `get_file_contents` `ref`/`path` optional; Go 1.27.3 security fixes |
| tealdeer (base) | `1.9.0` | **`1.9.1`** (or later) | patch release |
### Added
- **`pi-devbox-firstboot` skill + a managed-block pointer to it, so a new
device can find the fleet's first-boot acceptance round at all.** The round
is now *scoped by published state* (policy v2, artifact
`art_20261023T204306_b388e93c3d64`): a booting container reads two claim
keys — `firstboot-release-<TAG>` and `firstboot-image-<TAG>-<ARCH>` — and
derives whether it owes the release tier, the per-architecture image tier, or
only the device tier. Previously every device re-ran the entire sequence on
every release, which was drift rather than a decision: each device plants its
own successor ask carrying the previous round's checklist, so scope only grew
(4 checks at v1.10.2 → B1–B5 at v1.11.2 → B1–B7 at v1.12.2) while a peer's
PASS on *identical image bytes* was recorded as context rather than as grounds
to skip. The v1.8.10-era "image half — already settled, do not repeat" split
was never retired; it simply lapsed.
Two properties the skill exists to protect. First, **the gate needs no
roster**: fleet membership changes in both directions, so nothing addresses a
device and nothing enrolls it — state is read, not pushed. The case that
therefore has no delivery path at all is a *brand-new* device, whose mailbox
is legitimately empty and whose coordination chain does not exist yet; it must
plant its own first ask, and this skill is how it learns that. Second, the
skill is a **finder, not the policy**: it locates the current policy artifact
in the logstream instead of restating it, because a policy baked into an image
is pinned to that image — a device on an older release still reaches the
current policy, and a revision needs no release.
Baked canonical (like `pi-devbox-environment`), not skillset-owned: the round
verifies the image, so pinning it to the image is correct, and a skillset-owned
skill would need the vendoring machinery (`ARG SKILLSET_SNAPSHOT_REF`, the
manifest/label pair, a per-skill refresh script) that today exists only for
`mempalace`. `entrypoint-user.sh` and `pi-devbox-version` both enumerate the
baked skills directory, so no wiring change was needed for either to pick it
up. `scripts/smoke-test.sh` asserts the baked file, both resolution loops, and
the `AGENTS.md` pointer **by path rather than by prose** — a new device reaches
the round only through that line, so rewording the paragraph must not be able
to break the link silently.
The skill also says what to do when **a command gate on the device blocks a
probe**. The gate today is `erdc-guard`, the fleet extension that
docker-compose-repo mounts at `/opt/fleet-pi`, not part of this image. On
2026-10-24 it blocked tor-ms22's own probe-style commands, each after its
120 s wait: a child `pi -p` whose prompt mentioned `rm -rf`, heredoc test
fixtures, and `git rebase`. Unattended, that is a stalled round. Read
carelessly, it is a false defect or a false pass.
The guard's own `assess()`, run against each probe shape, showed that the
obvious workarounds don't work:
- `ERDC_GUARD=off` on the child is no help, because the parent judges the
prompt text first (0.95). `pi -p @file` is not flagged.
- A `cat` heredoc is flagged or not depending on its contents (0.93 vs not
flagged for the same shape).
- `/guard off` is volume-wide, not scoped to the round: the setting is
re-read before every bash call. And the guard keeps its bash-gate claim
while off, so confirm-destructive stays stood down too.
So the skill orders the fixes: the `write` tool for fixtures and prompt
files, `@file` for child prompts, scratch under `/tmp`, and `/guard off` last,
read first and switched back on afterwards. A blocked probe is a cannot-run.
This is device-environment method, not policy, so it belongs in the finder
rather than the policy artifact. Until this ships, the same advice reaches
tor-ms22 only through its own ask (seq 289).
- **`scripts/check-skill-frontmatter.sh` + a `skill-frontmatter` lint job:
baked skill frontmatter is now gated against the limits pi's own loader
measures.** Found while adding the skill above, whose `description` was
drafted at 1056 characters against the loader's `MAX_DESCRIPTION_LENGTH` of
1024. pi does **not** reject that — `dist/core/skills.js` pushes a diagnostic
and then loads the skill anyway ("Still load the skill even with warnings,
unless description is missing or empty"), so the only thing that would ever
report the violation is a diagnostic inside a live pi session — and nothing in
CI starts one. A spec violation that still works is the most durable kind of
rot, because nothing forces the fix.
Unlike `check-skill-floor.sh`, this one was **not** free to add: two of the
five baked skills are already within ~20 characters of the limit
(`credential-incident-response` 1022, `pi-extensions` 1003), so it fires on a
plausible next edit rather than a hypothetical one. It also catches the
sibling failure that is a silent **drop** rather than a warning — missing or
empty `description` returns `skill: null`, so the file ships, the symlink
resolves, smoke-test's `test -f` passes, `pi-devbox-version` lists it, and no
agent can ever see it. Exit codes 0 valid / 1 violation / 2 cannot-run, and
the vendored copies are deliberately in scope: the image ships those bytes,
so the violation is ours to carry whoever typed it.
- **Two baked skills' `description` fields trimmed to a routing sentence,
reclaiming ~1,087 characters of permanently-loaded context.** Every skill's
name and description sit in the system prompt of *every* request — that block
is the routing index — while bodies are fetched on demand through the read
tool. Measured here: 24 discoverable skills cost 10,609 description chars
(~2,652 tokens) on every request, against 474,656 chars of bodies (~119k
tokens) that cost nothing until used. `credential-incident-response` (1022,
i.e. two characters under the spec limit) and `pi-devbox-environment` (923)
were each carrying a table of contents of what their body covers.
Both are named **by path** from the always-loaded managed block, so neither
depends on description-matching to be found — which is what makes the cut
safe, and is why only these two were cut. `pi-extensions` was left alone for
two reasons: `check-skill-floor.sh` compares the vendored copy to the package
repo by directory hash, so trimming ours alone registers as drift; and it is
the one skill that also ships in the `skillset` repo, where it runs on hosts
with no pi-devbox managed block and so genuinely needs its description to be
found. All 18 enumerated items were verified present in the bodies first —
the cut relocates nothing, and both bodies are byte-identical.
`smoke-test.sh` now asserts both pointers by path, alongside the firstboot
one. The trim's premise is "the block names them", and that premise holds only
while those lines exist: reword the block, drop a pointer, and the skill
silently stops being reachable for a device that did not already know it was
there. Verified discriminating with a reworded negative control.
### Changed
- **`confirm-destructive` steps aside when another extension claims the bash
check** (pi-extensions `7237855`; CI resolves `main` at build time). pi runs
`tool_call` hooks one after another until one blocks. With a second command
gate loaded, `confirm-destructive` still asked about every command that
gate let through, and its question has no time limit. So an unattended
session could stop at it anyway. Every new `devbox-pi-config` volume gets
`confirm-destructive` switched on. Such a second gate exists: the fleet's
`erdc-guard` (private, mounted from `/opt/fleet-pi`).
- **How the claim works.** An extension sets
`globalThis[Symbol.for("pi-extensions.bash-gate")] = { owner: "<name>" }`.
While it is set, `confirm-destructive` skips its bash check. Its `/new`,
`/resume` and `/fork` questions are unchanged. With no claim it behaves
exactly as before. `confirm-destructive.ts` now also exports
`BASH_GATE_KEY`, which tells a claimer this version honours the claim.
- **An outside user of `matchesDangerous()`.** The file now notes that
`erdc-guard` imports it, as its fallback, from
`/opt/pi-extensions/extensions/confirm-destructive.ts`. That path and both
names must stay.
- **Tests.** `node --test test/confirm-destructive.test.mjs` in pi-extensions
`7237855` runs the real default export with a stand-in for pi: 4 tests, 4
pass. Without the line that steps aside, 3 pass and 1 fails. Whole suite:
51 pass.
- **Measured in pi 1.4.0 (2026-10-24),** with both extensions loaded in
either order: `sudo -n true` ran with `7237855` and was blocked by
v1.12.2's `4c1d28d`.
- **On v1.12.2 and older images** `confirm-destructive` ignores the claim.
There, switch it off once per volume with `/ext`; the image keeps a
`.ts.off` link off at every start.
### Fixed
- **AWS SSO login prompt in sessions that don't use Bedrock (pi-extensions
`f032195`).** The `aws-sso` keeper logged in whenever it was enabled, so
`pi --model glm-5.3` (ERDC) still asked for AWS approval once the SSO session
had ended. It now acts only when the session model, the observational-memory
model or a fork/task profile is `amazon-bedrock`, and otherwise shows
`AWS · idle (no Bedrock)`. For a session with no AWS at all, point
`observational-memory.model` and `pi-fork.effortProfiles` at ERDC models in
`settings.json` too.
- **Mailbox: asks this device answered long ago came back as owed
(mempalace-toolkit `c04d488`).** mempalace 3.11.0 stopped treating
`from_agent` as a filter on `event_list`; the filter is now `writer`. The
baked bridge still sent `from_agent` to find this device's own replies, so
against the central palace (on 3.11.0 since 2026-10-24T22:20Z) it got the
newest 100 events from every writer, and older replies dropped out. On
tor-ms22 the wake-up listed 22 owed asks; all 20 threads already had a
closing reply. The bridge now sends `writer`, and falls back to `from_agent`
only when a 3.10.0-or-older server rejects `writer`. Measured against the
live palace: 0 owed with the fix, 22 with `d5a03f4`. Until a device runs this
release, its wake-up keeps listing them; don't re-close them.
- **CI: a smoke job's image could be deleted mid-test by another job's disk
reclaim.** This is why v1.12.2's `-studio` tags were not published by its
tag run. Everything on one runner host shares its Docker daemon: concurrent
jobs of a runner with capacity > 1 (in run 740, runner-a1 ran `smoke-studio`
and `build-variant` at the same time), and the jobs of other act_runner
containers on the same VM (runner-2 has one for gitea.jordbo.se and one for
gitea.egl.lan). Each pi-devbox job began with `docker system prune -af --volumes`, which removes every
image that no container is using at that moment. A smoke image is unused
between `smoke-test.sh`'s one-shot `docker run`s. In run 740, `smoke`
passed and `build-variant` started. Its prune logged
`untagged: pi-devbox:smoke-studio` at 22:58:49.11Z, 30 ms after
`smoke-studio` had loaded that image. Every studio assertion then failed with
"Unable to find image … locally", and `build-variant-studio` was skipped.
`v1.12.2` and `latest` were published; `v1.12.2-studio` and
`latest-studio` were not, until the failed jobs were re-run. Now every job
reclaims disk through the new `scripts/ci-reclaim-docker.sh`. It does the
same work as before (stopped containers, unused images, networks, anonymous
volumes, build cache) but never prunes an image labelled
`se.jordbo.pi-devbox.ci-smoke-image`. The smoke builds apply that label, a
preflight fails if it is missing, and each smoke job removes its own image at
the end. Jobs we do not control can still run a raw prune on the same
daemon: opencode-devbox's workflows do, in 7 places, and so may whatever
gitea.egl.lan runs. So the smoke step also pins its image with a running
container (`ci-reclaim-docker.sh hold`) for the whole test, because no prune
removes an image a running container uses. Only the few seconds between the
build step loading the image and the pin starting are left unprotected. The
holder exits by itself after 1 h, and smoke images left by a killed job are
swept once they are older than 6 h. Smoke tags carry the run id (`pi-devbox:smoke-<run_id>`), so two
runs on different refs cannot test or delete each other's image. `lint.yml`
fails on any raw `docker system|image prune` in a workflow. Job order is
unchanged: the studio jobs still gate only the `-studio` tags. Reordering
could not have closed the race anyway, since the two smoke jobs also prune
each other whenever their start times drift. Measured 2026-10-23 on an
isolated `docker:29-dind` (engine 29.9.2; the runner has 29.7.2). The old
prune deletes an unused labelled image (the bug). The new reclaim keeps a
fresh labelled image, deletes an unlabelled one and a labelled one created
in 1970, keeps an image a running container uses, and prints no errors.
`assert-protected` passes the labelled image and fails both an unlabelled
and a missing one. With `hold`, an unlabelled image survives both a raw
`docker system prune -af --volumes` and `docker image prune -af` (control:
without the holder, the same prune deletes it). `hold` fails on a missing
image, and `release` removes the holder and the image.
## v1.12.2 — 2026-10-22
Patch release. Headline: **pi `1.4.0`** (audited, see *Changed* below), plus
two upstream **security releases** baked into the base image: **Node
`v24.22.1`** (8 CVEs) and **git-lfs `v3.8.1`** (4 CVEs). Also **pi-studio
`v0.11.1`** (`-studio` tags only), uv `0.13.5`, agent-browser `0.39.2`, and
`~/.ssh/config` is Included even if it appears after start (*Fixed*, below).
Surveyed 2026-10-22. Unchanged since v1.12.1: mempalace `3.11.0` (PyPI
`latest`), pi-atelier `v0.16.0` (`3b42825`, newest tag),
pi-observational-memory `022da5e` (= `master`), pi-toolkit `7c05901`,
pi-extensions `4c1d28d`, pi-fork `e69725c`, mempalace-toolkit `d5a03f4`. The
skillset snapshot needs no refresh (`vendor-mempalace-skill.sh --check`: OK,
still `96b840e`). The base image is rebuilt because `rootfs/` changed, so apt
and the base tools pinned to `latest` re-resolve. Of those, Node, git-lfs, uv
and agent-browser moved upstream (*Changed*, below). gitea-mcp `v1.10.0`, nvim
`0.12.6`, fzf `0.74.5`, gitleaks `8.30.1`, bat `0.26.1`, eza `0.23.5`, zoxide
`0.10.0`, gosu `1.19`, micro `2.0.15`, tealdeer `1.9.0`, typst `0.15.1`, yq
`4.54.1` and playwright `1.64.1` match their newest releases. Each was compared
against the v1.12.1 image on tor-ms22.
### Components that move with this release
| Component | Baked in v1.12.1 | v1.12.2 | Why |
|---|---|---|---|
| **pi** | `1.3.0` | **`1.4.0`** | audited, see *Changed* below |
| **pi-studio** (`-studio` tags) | `v0.10.2` (`4b4abd1`) | **`v0.11.1`** (`8a410b4`) | newest tag, see *Changed* below |
| **Node** (base) | `v24.22.0` | **`v24.22.1`** (or later: NodeSource `node_24.x` at build time) | security release, see *Changed* below |
| **git-lfs** (base) | `v3.8.0` | **`v3.8.1`** (or later: `latest` at build time) | security release, see *Changed* below |
| uv (base) | `0.13.1` | **`0.13.5`** (or later: `latest` at build time) | see *Changed* below |
| agent-browser (base) | `0.39.1` | **`0.39.2`** (or later: `latest` at build time) | see *Changed* below |
### Upgrade notes
- **pi-studio's REPL pane now echoes each send by default** (`-studio` tags
only). From 0.11.0, *Pane echo* defaults to **Summary**, as in pi-repl: the
tmux pane shows each submitted block (up to 20 lines or 2,000 characters)
above its output. Before, the default was **Off**. The same default applies
to the agent's `studio_repl_send` tool when it passes no `echoMode`. A choice
of **Full** made in Studio is kept. To get the old quiet pane back, choose
**Off** under the REPL pane's **More**, or set
`PI_STUDIO_REPL_ECHO_MODE=off` for the agent tool. What Studio *records* is
unchanged: only the code and its output.
### Changed
- **pi `1.3.0` → `1.4.0`** (npm `latest`, published 2026-10-21). No breaking
changes listed. Fixes that land on how this fleet uses pi: compaction and
branch summaries no longer fail with "generation hit the token cap" when
reasoning uses most of the old fixed summary limit (#9512, #9075); resumed
sessions and `/tree` no longer leave extension tools inactive (#11062); a
session no longer breaks after recovering from a reply cut off at the output
limit (#11227); codemode scripts wait up to 60 s for a free worker instead
of failing (#11184); the terminal is restored when pi exits without its
normal teardown (#11170); concurrent extension dialogs no longer lose a
promise (#11166); the `grep` tool runs `rg --no-config` (#11203); and
extensions can import `@earendil-works/pi-ai/models` (#11156). Audited
2026-10-22 on tor-ms22. The only API changes that could reach us are the
attribution headers that `before_provider_headers` handlers no longer see,
`generateSummary()` ignoring `reserveTokens`, and two removed exports
(`mergeProviderAttributionHeaders`, `getPiUserAgent`). None is used by any
companion, fleet extension or mempalace-toolkit. pi-agent-core changes only
by the new optional `identity` field, so obsmem's `finishTurn` turn cap is
untouched. All 58 names our extensions import from pi resolve in 1.4.0.
With a copy of tor-ms22's real `~/.pi/agent`, `pi -p` exits 0 with nothing
on stderr. The TUI loads the same extensions as on 1.3.0, with no
extension issues. Atelier paints in both renderers, and the bridge registers
its 45 tools. Full audit next to `ARG PI_VERSION` in `Dockerfile.variant`.
- **pi-studio `v0.10.2` → `v0.11.1`** (`8a410b4`, `-studio` tags only). Five
upstream releases, 2026-10-19 to 2026-10-22. 0.10.3: IPython prints a value
once (`Out[5]: …`), not twice, and after a Quick send the cursor returns to
the input box. 0.10.4: Stop works again on a Mac that has a process running
as `nobody`. That was a 0.10.2 regression: macOS lists that uid as `-2`, and
the 0.10.2 parser refused to stop anything. **It does not affect the
image**: inside the container, `ps` shows only the container's own
processes, and none has a negative uid. Measured 2026-10-19 on tor-ms22.
0.10.2's own `readStopProcessTable` parsed the live process table (9 rows,
min uid 1000). Control: a macOS-style `-2` row made it throw "Could not parse
process ownership data". 0.10.5: the Quick-send result shows while the
cursor stays in the box, and the Prompt toolbar fits on one row on a
13-inch screen. 0.11.0: the REPL pane runs Ruby (irb), Java (JShell), Octave,
MATLAB, gnuplot and C++ (Cling) when they are on `PATH` (none is baked into
this image), a send ends within seconds when its runtime crashes instead of
waiting out the 20 s timeout, and the pane-echo default changes (see
*Upgrade notes*). 0.11.1: a shorter run status beside **Stop**, and the line
above a response shows which one you are on ("9/9"). Dependencies are
unchanged: `package-lock.json` differs from 0.10.2's only in the package's
own version, and `ws` is still the only runtime dependency. Loaded into pi
`1.4.0` with `pi --no-extensions -e <clone>/index.ts -p`, it registers the
same four tools (`studio_repl_status`, `studio_repl_send`,
`studio_export_pdf`, `studio_export_html`), rc 0, nothing on stderr.
Control: without `-e`, none.
- **Node `v24.22.0` → `v24.22.1`** (base, NodeSource `node_24.x`). An LTS
security release with 8 CVEs in http, dns, tls, permission, crypto and
brotli. On 2026-10-21 NodeSource still served only `24.22.0`, so a rebuild
then would not have picked it up. On 2026-10-22 it serves
`24.22.1-1nodesource1` for both arm64 and amd64.
- **git-lfs `v3.8.0` → `v3.8.1`** (base). A security release (2026-10-22)
with 4 CVEs. Among them: remote commands sent over SSH are now fully quoted
(CVE-2026-69260), and Cookie headers are no longer forwarded to another host
after a redirect (CVE-2026-69261).
- **uv `0.13.1` → `0.13.5`** (base). Bundled root certificates updated,
dropped GET and HEAD requests retried, `uv export` faster with extras, lower
peak memory when resolving, and new CPython builds (3.15.1, 3.14.9, 3.13.17).
No breaking changes.
- **agent-browser `0.39.1` → `0.39.2`** (base). Bug fixes only. The ones an
agent notices: a click or double-click on a hidden, disabled or off-viewport
element now returns an error instead of reporting success, an ambiguous
stale element reference fails instead of picking another element, and an
unreadable or invalid policy file denies actions instead of being ignored.
### Fixed
- **`~/.ssh/config` is Included even if it appears after the container
started.** Same rule as the `ssh-lan.conf` fix in v1.12.1. `setup-lan-access.sh`
wrote `Include ~/.ssh/config` into `~/.ssh-local/config` only if
`~/.ssh/config` was readable at container start, so a file that appeared
later (a host provisioned after its first `docker compose up`) was ignored
until a restart. Entries *added* to a file that already existed were never
affected: ssh reads Included files on every connection, and the bind mount is
live. Measured 2026-10-19 on tor-ms22: on the Mac, a newly created file, an
atomic replace (write + `mv`, as editors do) and an append each showed up in
the container within a second. The line is now written whether or not the
file exists. A file that exists but is unreadable is still left out, with a
warning on stderr, because ssh would abort every connection. When the file
exists at start the generated config is byte-identical to v1.12.1's (checked
with and without `ssh-lan.conf`). When it is absent, the only difference is
the added Include block. `scripts/smoke-test.sh` gains two checks, Included
when created after start and left out when unreadable. Against the v1.12.1
script the first fails and the second passes; against this one both pass.
## v1.12.1 — 2026-10-19
Patch release. Headline: **`ssh-lan.conf` is Included even if it is created
after the container started** (*Fixed*, below). Also **pi-studio `v0.10.2`**,
pi-toolkit `7c05901` (new Bedrock defaults for fresh volumes) and gitea-mcp
`v1.10.0`. Surveyed 2026-10-19; no other upstream moved. Unchanged since v1.12.0:
pi `1.3.0` (npm `latest`), mempalace `3.11.0` (PyPI `latest`), pi-atelier `v0.16.0`
(`3b42825`, newest tag), pi-observational-memory `022da5e` (= `master`),
pi-extensions `4c1d28d`, pi-fork `e69725c`, mempalace-toolkit `d5a03f4`. The
skillset snapshot needs no refresh (`vendor-mempalace-skill.sh --check`: OK,
still `96b840e`). The base image is rebuilt because `rootfs/` changed, so apt
and the base tools pinned to `latest` re-resolve. Of those, only gitea-mcp moved
upstream: `v1.9.0` → `v1.10.0`. uv `0.13.1`, Node `v24.22.0`, nvim `0.12.6`,
fzf `0.74.5`, gitleaks `8.30.1`, bat `0.26.1`, eza `0.23.5`, zoxide `0.10.0`,
git-lfs `3.8.0`, gosu `1.19`, micro `2.0.15`, tealdeer `1.9.0`, typst `0.15.1`,
yq `4.54.1`, agent-browser `0.39.1` and playwright `1.64.1` match their newest
releases. Each was compared against the v1.12.0 image on tor-ms22.
### Components that move with this release
| Component | Baked in v1.12.0 | v1.12.1 | Why |
|---|---|---|---|
| **pi-studio** (`-studio` tags) | `v0.10.1` (`76db01f`) | **`v0.10.2`** (`4b4abd1`) | newest tag, see *Changed* below |
| pi-toolkit | `3760d90` | **`7c05901`** (or later: CI resolves `main` at build time) | `settings.example.json` defaults move to Claude 5.5 on Bedrock, see *Changed* below |
| gitea-mcp (base) | `v1.9.0` | **`v1.10.0`** (or later: `latest` at build time) | additive, see *Changed* below |
### Changed
- **pi-studio `v0.10.1` → `v0.10.2`** (`-studio` tags only). Windows fixes. The
REPL waits for the runtime's prompt before saying it started. Stopping a
session ends its processes. Code goes only to the exactly named session. The
selected session follows what you choose. One behaviour change for agents: a
`studio_repl_send` that names no session now fails when Studio shows none
selected ("No REPL session is selected in Studio …"). Before, it picked a
session. Audited 2026-10-19 on tor-ms22. Dependencies are unchanged:
`package-lock.json` packages are identical to 0.10.1's, `ws` is the only
runtime dependency, and pi peers are `*`. Loaded into this image's pi `1.3.0`
with `pi --no-extensions -e <clone>/index.ts -p`, it registers the same four
tools as 0.10.1 (`studio_repl_status`, `studio_repl_send`, `studio_export_pdf`,
`studio_export_html`), with no extension error and rc 0. Control: the same
run against the baked 0.10.1.
- **pi-toolkit `3760d90` → `7c05901`:** `settings.example.json` now defaults to
`eu.anthropic.claude-opus-5-5`. `enabledModels` becomes opus/sonnet/haiku `-5-5`,
and the observational-memory and pi-fork `fast`/`balanced`/`deep` models move
to the same family. Also a `*.bak-*` gitignore. **Only a fresh
`devbox-pi-config` volume gets these defaults.** On an existing volume, the
entrypoint's settings merge fills in only *missing* keys and keeps arrays
verbatim, so a model you already set stays. All three IDs are in pi
`1.3.0`'s built-in `amazon-bedrock` registry, and `eu.anthropic.claude-opus-5-5`
is this session's default model on tor-ms22.
- **gitea-mcp `v1.9.0` → `v1.10.0`** (base, `latest` at build time). Additive: three
new tools (`compare_commits`, `get_commit_status`, `create_commit_status`) and
new methods on existing ones. The fix that matters for stdio clients: logs go
to stderr, and `-d` no longer writes to stdout, where it corrupted the protocol.
Measured 2026-10-19 with a stdio `initialize` + `tools/list` against
gitea.jordbo.se: 1.9.0 lists 58 tools, 1.10.0 lists 61. None were removed,
and stdout carried JSON only. The image only bakes the binary; nothing in it
configures it.
### Fixed
- **`~/.config/devbox-shell/ssh-lan.conf` is Included even if it is created
after the container started.** `setup-lan-access.sh` wrote the
`Include ~/.config/devbox-shell/ssh-lan.conf` line only if the file was
readable at container start. A file created afterwards was visible in the
container and silently ignored, so ssh fell through to `~/.ssh/config`. Hit on
EMB-3HXGNXJ0 on 2026-10-18: its `tor-ms22` override (container key, no
passphrase) was skipped. ssh then used the Mac's passphrase-protected key, and
`BatchMode` failed with `Permission denied (publickey,…)`. Rerunning the script
inside the container fixed it. The line is now written whether or not the file
exists. With this image's OpenSSH 10.0p2, an `Include` whose file (or parent
directory) is missing is skipped. A file created later is read on the next
connection, and a directory at that path is ignored. A file that **exists but
is unreadable** makes ssh abort every connection (`Can't open user config
file`), so that one case is still left out, now with a warning on stderr.
Output is byte-identical to v1.12.0 when the file exists at start.
`scripts/smoke-test.sh` gains two checks: Included when created after start,
and left out when unreadable. Against the v1.12.0 script the first fails and
the second passes. README *Naming LAN peers* no longer says that creating the
file needs a restart.
## v1.12.0 — 2026-10-18 ## v1.12.0 — 2026-10-18
Minor release. Headlines: **pi 1.3.0**, **mempalace 3.11.0**, **pi-atelier Minor release. Headlines: **pi 1.3.0**, **mempalace 3.11.0**, **pi-atelier
+1 -1
View File
@@ -14,7 +14,7 @@
# content-addressed over this file, so any byte change invalidates the # content-addressed over this file, so any byte change invalidates the
# cache. Recommended cadence: once per release for security updates. # cache. Recommended cadence: once per release for security updates.
# #
# BASE_REBUILD_DATE: 2026-10-18 (v1.12.0 — mempalace 3.11.0, link-pi-extensions.sh, studio-expose and skill doc fixes; apt and base tools re-resolve (upstream on 2026-10-18: uv 0.13.1, gitea-mcp 1.9.0, Node v24.22.0; nvim 0.12.6, fzf 0.74.5 unchanged); previous marker 2026-10-14 / v1.11.1) # BASE_REBUILD_DATE: 2026-10-25 (v1.13.0 — rootfs gains the pi-devbox-firstboot skill and mempalace-toolkit moves to c04d488; apt and base tools re-resolve (upstream on 2026-10-25: gosu 1.20, gitea-mcp 1.11.0, tealdeer 1.9.1; Node v24.22.1, git-lfs 3.8.1, uv 0.13.5 unchanged); previous marker 2026-10-22 / v1.12.2)
# #
# ── Lineage note ───────────────────────────────────────────────────── # ── Lineage note ─────────────────────────────────────────────────────
# Adapted from opencode-devbox/Dockerfile.base (commit before v1.16.2). # Adapted from opencode-devbox/Dockerfile.base (commit before v1.16.2).
+36 -1
View File
@@ -317,7 +317,42 @@ ARG USER_NAME=developer
# extension with no [Extension issues], the bridge registers its 45 tools, # extension with no [Extension issues], the bridge registers its 45 tools,
# /om:status, /om:view and the /studio commands are offered, and atelier # /om:status, /om:view and the /studio commands are offered, and atelier
# paints ACTIVITY/READY/TOOLS in both renderers (controls with -ne: none). # paints ACTIVITY/READY/TOOLS in both renderers (controls with -ne: none).
ARG PI_VERSION=1.3.0 #
# v1.12.2: 1.3.0 -> 1.4.0 (npm latest, published 2026-10-21T22:48Z). MEASURED
# 2026-10-22 on tor-ms22 from isolated npm prefixes for 1.3.0 and 1.4.0, with
# the published tarballs unpacked side by side:
# 1. NO `### Breaking Changes` section in 1.4.0. Behaviour changes, none of
# which reaches us: attribution headers come from pi-ai's client identity,
# so `before_provider_headers` handlers no longer see them, and
# `generateSummary()` ignores `reserveTokens`. 0 uses of either
# (`before_provider_headers`, `generateSummary`, `reserveTokens`) in
# pi-extensions, pi-fork, obsmem, pi-atelier and pi-studio. Control: the
# same grep finds `registerTool` in 18 files.
# 2. pi-agent-core 1.3.0 -> 1.4.0 is NOT byte-identical this time, but every
# changed line in dist (4 files) is the new optional `identity` field and
# `DEFAULT_AGENT_IDENTITY`; 0 changed lines mention finishTurn, so obsmem's
# turn cap is untouched. pi-tui adds only exit-restore methods
# (`restoreOnExit`, `getExitRestorePrefix/Suffix`, #11170); atelier's
# internals TuiMainScreen / renderLayoutFrame are in the same 10 dist
# files. The extension loader only gains the `@earendil-works/pi-ai/models`
# alias (#11156). pi-mcp and pi-codemode dist are byte-identical.
# 3. Public API: the only removed .d.ts exports are
# `mergeProviderAttributionHeaders` (pi-coding-agent) and `getPiUserAgent`
# (pi-ai). 0 consumers. All 58 runtime-imported names across pi-toolkit,
# pi-extensions, pi-fork, obsmem, pi-studio (0.10.2 and 0.11.1),
# pi-atelier, mempalace-toolkit, ~/.pi/agent/extensions and /opt/fleet-pi
# resolve in 1.4.0, and in 1.3.0 (control: an injected fake name is
# reported missing).
# 4. engines.node is still `>=22.19.0`. Peer ranges: atelier >=0.84.0, fork
# >=0.75.0, obsmem and studio `*`.
# Exercised before tagging, with a copy of tor-ms22's real ~/.pi/agent
# (pi-studio swapped for v0.11.1): `pi -p` answers PONG, rc 0 in 10 s (1.3.0:
# 9 s), nothing on stderr. In the TUI the [Extensions] list is the same as on
# 1.3.0, with no [Extension issues]. The bridge registers its 45 tools, and
# TOOLS shows 58 / 64 on both. /om:status, /om:view and the /studio commands
# are offered, and atelier paints ACTIVITY/READY/TOOLS in both renderers
# (control with -ne: none).
ARG PI_VERSION=1.4.0
ARG PI_TOOLKIT_REF=main ARG PI_TOOLKIT_REF=main
ARG PI_EXTENSIONS_REF=main ARG PI_EXTENSIONS_REF=main
# Repo URLs default to the canonical gitea origin but are overridable so a # Repo URLs default to the canonical gitea origin but are overridable so a
+25 -9
View File
@@ -804,7 +804,14 @@ directory, and they compose:
mechanisms, the interactive-vs-tool-shell alias gotcha (`dssh`/`dscp`), mechanisms, the interactive-vs-tool-shell alias gotcha (`dssh`/`dscp`),
tmux 0-indexing, uv-first Python, and pi-studio reachability, all as tmux 0-indexing, uv-first Python, and pi-studio reachability, all as
*mechanisms* (deployment-specific hostnames/domains/nameservers are *mechanisms* (deployment-specific hostnames/domains/nameservers are
discovered at runtime, never hardcoded). discovered at runtime, never hardcoded). **`pi-devbox-firstboot`** ships the
same way and is the entry point to the fleet's first-boot acceptance round:
it carries the *state-based scope gate* (two claim lookups, keyed by release
tag and by architecture, deciding which tiers of checks this device still
owes) rather than the policy itself, which it locates in the logstream so a
policy revision needs no release. Baked rather than skillset-owned on purpose
— the device that most needs it is a brand-new one with an empty mailbox and
no coordination chain, so nothing would deliver it.
- **Vendored fallback skills.** The pi-toolkit global `AGENTS.md` tells every - **Vendored fallback skills.** The pi-toolkit global `AGENTS.md` tells every
pi session to read `~/.agents/skills/pi-extensions/SKILL.md` at start (to fix pi session to read `~/.agents/skills/pi-extensions/SKILL.md` at start (to fix
fork/recall under-utilisation). That pointer would dangle in a container fork/recall under-utilisation). That pointer would dangle in a container
@@ -1124,6 +1131,7 @@ pi-devbox v1.8.14
credential-incident-response baked credential-incident-response baked
mempalace live /workspace/skillset @ 4d7c0ea (identical to baked snapshot) mempalace live /workspace/skillset @ 4d7c0ea (identical to baked snapshot)
pi-devbox-environment baked pi-devbox-environment baked
pi-devbox-firstboot baked
pi-extensions baked pi-extensions baked
``` ```
@@ -1174,12 +1182,20 @@ published image (they are a fact about your LAN, not the image). Alternatively,
set `DEVBOX_LAN_AUTOJUMP_PRIVATE=1` to ProxyJump *any* RFC1918 address through set `DEVBOX_LAN_AUTOJUMP_PRIVATE=1` to ProxyJump *any* RFC1918 address through
the host without naming peers (see `.env.example`). the host without naming peers (see `.env.example`).
Once the file exists it is re-read on every connection, so *edits* take effect ssh reads the file on every connection, so creating it and editing it both take
immediately — no container or session restart. **Creating it for the first time effect immediately — no container or session restart. Since v1.12.1,
does need one restart**, because `setup-lan-access.sh` only emits the `setup-lan-access.sh` writes the `Include ~/.config/devbox-shell/ssh-lan.conf`
`Include ~/.config/devbox-shell/ssh-lan.conf` line when the file is already line whether or not the file exists yet (ssh skips an `Include` whose file is
readable at container start (`if [ -r "$SSH_LAN_CONF" ]`). Until then ssh never missing). Images up to and including v1.12.0 wrote that line only if the file
looks at it — which reads exactly like "my override is being ignored". was readable at container start. There, a file created afterwards is ignored
until a restart, or until you run `bash /usr/local/lib/pi-devbox/setup-lan-access.sh`
in the container.
That looks exactly like "my override is being ignored". One case still needs
action: a file that exists but the container cannot read is left out, because
ssh would otherwise refuse every connection. The script then prints a warning at
start. Two other permission traps apply to this file as to any ssh config.
ssh rejects it if it is world-writable (`Bad owner or permissions`). And on native
Linux, the host user's uid must match the container's `developer` (1000).
#### Giving the container its own key for a peer #### Giving the container its own key for a peer
@@ -1289,7 +1305,7 @@ persisted volumes survived, and pi runtime wiring is intact:
```bash ```bash
./scripts/recreate-sanity-check.sh # auto-detects variant ./scripts/recreate-sanity-check.sh # auto-detects variant
./scripts/recreate-sanity-check.sh --expected-image-version 1.8.9 # assert the pi-devbox release tag ./scripts/recreate-sanity-check.sh --expected-image-version 1.8.9 # assert the pi-devbox release tag
./scripts/recreate-sanity-check.sh --expected-version 1.3.0 # assert the pi coding agent version ./scripts/recreate-sanity-check.sh --expected-version 1.4.0 # assert the pi coding agent version
``` ```
Those are **two different versions**, and the flags are not interchangeable: Those are **two different versions**, and the flags are not interchangeable:
@@ -1328,7 +1344,7 @@ resolved to `latest` at build time:
| Component | Pin | Where | | Component | Pin | Where |
|---|---|---| |---|---|---|
| pi | `1.3.0` | `ARG PI_VERSION` — `Dockerfile.variant` | | pi | `1.4.0` | `ARG PI_VERSION` — `Dockerfile.variant` |
| pi-obsmem | `022da5e5554d04dfdc876ed7dac4a42c6b83dfd4` | `ARG PI_OBSMEM_REF` — `Dockerfile.variant` | | pi-obsmem | `022da5e5554d04dfdc876ed7dac4a42c6b83dfd4` | `ARG PI_OBSMEM_REF` — `Dockerfile.variant` |
| pi-atelier | `v0.16.0` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` | | pi-atelier | `v0.16.0` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
| mempalace | `3.11.0` | `ARG MEMPALACE_VERSION` — `Dockerfile.base` | | mempalace | `3.11.0` | `ARG MEMPALACE_VERSION` — `Dockerfile.base` |
@@ -51,8 +51,8 @@
# HOST-OWNED PEER POLICY (portable; keeps this image generic) # HOST-OWNED PEER POLICY (portable; keeps this image generic)
# Named LAN peers are facts about a *specific* host's network, not about the # Named LAN peers are facts about a *specific* host's network, not about the
# image — a roaming laptop sees different LANs. So we never bake peer names # image — a roaming laptop sees different LANs. So we never bake peer names
# here. Instead, if the host bind-mounts ~/.config/devbox-shell/ssh-lan.conf # here. Instead we Include ~/.config/devbox-shell/ssh-lan.conf (the host
# (the same devbox-shell bridge dir used for shared aliases), we Include it # bind-mounts the devbox-shell bridge dir, also used for shared aliases)
# *before* ~/.ssh/config. That file holds the host's own jump overrides, e.g. # *before* ~/.ssh/config. That file holds the host's own jump overrides, e.g.
# Host pve pve-2 pbs-vm # Host pve pve-2 pbs-vm
# ProxyJump host # ProxyJump host
@@ -151,8 +151,22 @@ EOF
# Optional host-owned named-peer jump overrides (portable: lives on the host, # Optional host-owned named-peer jump overrides (portable: lives on the host,
# not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins. # not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins.
#
# Included even when the file does not exist yet. ssh skips an Include whose
# file is missing, and reads it on the next connection once it appears.
# Through v1.12.0 the Include was written only if the file was readable at
# container start. A file created later was then visible in the container but
# ignored, until a restart (EMB-3HXGNXJ0, 2026-10-18: its tor-ms22 override
# was skipped, so ssh fell back to a passphrase key and BatchMode failed).
# Only a file that EXISTS but cannot be read is left out. ssh aborts every
# connection on one ("Can't open user config file"), which would break all
# ssh, git included. A directory at that path is harmless (ssh ignores it).
# Measured with this image's OpenSSH 10.0p2; scripts/smoke-test.sh checks
# both cases.
SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf" SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf"
if [ -r "$SSH_LAN_CONF" ]; then if [ -e "$SSH_LAN_CONF" ] && [ ! -r "$SSH_LAN_CONF" ]; then
echo "setup-lan-access: $SSH_LAN_CONF exists but is not readable, so it is not Included (ssh would refuse every connection). Fix its permissions, then rerun this script or restart the container." >&2
else
LAN_CONF_BLOCK=$(cat <<'EOF' LAN_CONF_BLOCK=$(cat <<'EOF'
# Host-owned named-peer jump overrides (bind-mounted; edit on the host). # Host-owned named-peer jump overrides (bind-mounted; edit on the host).
@@ -182,8 +196,17 @@ EOF
fi fi
fi fi
# Same rule as ssh-lan.conf above: Included even when ~/.ssh/config does not
# exist yet, so a file that appears after container start (a host provisioned
# after its first `docker compose up`) is read on the next ssh. Entries added to
# a file that already existed were always picked up: ssh reads Includes on every
# connection and the mount is live. Through v1.12.1 the line was written only if
# the file was readable at start. Only a file that EXISTS but cannot be read is
# left out, because ssh would then abort every connection, git included.
INCLUDE_BLOCK="" INCLUDE_BLOCK=""
if [ -r "${HOME}/.ssh/config" ]; then if [ -e "${HOME}/.ssh/config" ] && [ ! -r "${HOME}/.ssh/config" ]; then
echo "setup-lan-access: ${HOME}/.ssh/config exists but is not readable, so it is not Included (ssh would refuse every connection). Fix its permissions, then rerun this script or restart the container." >&2
else
INCLUDE_BLOCK=$(cat <<'EOF' INCLUDE_BLOCK=$(cat <<'EOF'
# Your own target hosts. Scope reset to match-all so this Include applies to # Your own target hosts. Scope reset to match-all so this Include applies to
@@ -71,6 +71,26 @@ rather than merely confusing you:
the MCP tools while both look correct. Use the MCP tools for the central the MCP tools while both look correct. Use the MCP tools for the central
palace; the CLI only for a local one. palace; the CLI only for a local one.
## First boot on a new release: scope the acceptance round
If you are in a pi-devbox container that was just recreated, or one running a
release this device has not accepted before — or a `firstboot-acceptance` ask is
in your mailbox, or your mailbox is **empty and this device has never run that
round** — read `~/.agents/skills/pi-devbox-firstboot/SKILL.md` before starting.
That round is **scoped by published state, not run in full by every device**:
two claim lookups, keyed by release tag and by architecture, decide whether you
owe the release tier, the per-architecture image tier, or only the device tier.
Three rules are load-bearing enough to repeat here. **Derive the release from
`/etc/pi-devbox/build-manifest.json`; never predict it** — four tags in this
image's history published nothing. **Honour a peer's claim only if its keys
match your own first-hand readings**, or one stale claim silently switches off
verification across the whole fleet while every device still reports PASS. And
**the device tier always runs**, because the same image reads differently per
device and both readings are correct (a warn count is not a device property
unless the invocation is pinned). An empty mailbox on a device with no chain
means *you* plant the first ask — nobody is watching for new devices.
## Before you file a finding: second measurement, different route ## Before you file a finding: second measurement, different route
This is here rather than in a skill because it has to fire *without* a matching This is here rather than in a skill because it has to fire *without* a matching
@@ -10,6 +10,7 @@ one", which was a bug).
|-------|-------|------------------| |-------|-------|------------------|
| `pi-devbox-environment` | pi-devbox (this repo) | authored here; the canonical copy | | `pi-devbox-environment` | pi-devbox (this repo) | authored here; the canonical copy |
| `credential-incident-response` | pi-devbox (this repo) | authored here; the canonical copy | | `credential-incident-response` | pi-devbox (this repo) | authored here; the canonical copy |
| `pi-devbox-firstboot` | pi-devbox (this repo) | authored here; the canonical copy |
| `pi-extensions` | the `pi-extensions` package repo (`skill/`) | **vendored fallback** + refreshed at build | | `pi-extensions` | the `pi-extensions` package repo (`skill/`) | **vendored fallback** + refreshed at build |
| `mempalace` | the `skillset` repo | **vendored fallback** (snapshot only) | | `mempalace` | the `skillset` repo | **vendored fallback** (snapshot only) |
@@ -27,6 +28,20 @@ actually picks it up rather than relying on description-matching.
`pi-extensions`'s directive already ships in pi-toolkit's `AGENTS.md`, so only `pi-extensions`'s directive already ships in pi-toolkit's `AGENTS.md`, so only
its skill file needed baking. its skill file needed baking.
`pi-devbox-firstboot` is the third case: authored here, baked canonical, **and**
pointed at from the managed block, for a reason specific to what it does. It is
the entry point to the fleet's first-boot acceptance round, and the device that
most needs it is a **brand-new** one whose mailbox is empty and whose chain does
not exist yet — so nothing will deliver the procedure to it, and
description-matching on an empty mailbox is exactly the case that fails. It is
deliberately a *finder*: it locates the current policy artifact in the logstream
rather than restating it, so a policy revision does not require a release and a
device booting an older image still reaches the current policy. It is **not**
skillset-owned, because the round verifies the image and should therefore be
pinned to the image — and because a skillset-owned skill needs the vendoring
machinery (`ARG SKILLSET_SNAPSHOT_REF`, the manifest/label pair, and a
per-skill refresh script), which today exists only for `mempalace`.
## Freshness model (layered — see Dockerfile.variant) ## Freshness model (layered — see Dockerfile.variant)
- **`pi-extensions`** — Option 1 + Option 2. The committed copy here is the - **`pi-extensions`** — Option 1 + Option 2. The committed copy here is the
@@ -6,13 +6,7 @@ description: >-
agent-authored note. Load this whenever a task involves a leaked/exposed agent-authored note. Load this whenever a task involves a leaked/exposed
secret, a token rotation, a "is this credential still live?" question, deciding secret, a token rotation, a "is this credential still live?" question, deciding
whether to delete or scrub stored content, proving a corpus is clean, or whether to delete or scrub stored content, proving a corpus is clean, or
choosing scopes for a new API token. Covers the mandatory order of operations choosing scopes for a new API token.
(probe the issuer FIRST — severity before cleanliness), leak-free identity via
sha256[:8] fingerprints and when publishing one is safe,
why revocation beats deletion for anything already replicated, scopes derived
from measured consumers, the three places a secret hides in a Chroma palace, how to prove ABSENCE rather than assume it (instrument strength,
census vs class passes, the tokenisation trap where quoting decides detectability, why git filters never run on symlinks, self-tests that abort),
where this fleet's secrets live, and what rotation does NOT fix.
--- ---
# Credential incident response # Credential incident response
@@ -6,14 +6,7 @@ description: >-
shell prompt is prefixed `[devbox]`, or `~/.ssh-local/config` is present) and shell prompt is prefixed `[devbox]`, or `~/.ssh-local/config` is present) and
the task touches any of: reaching the Docker host or its LAN, SSH, DNS name the task touches any of: reaching the Docker host or its LAN, SSH, DNS name
resolution, what survives container recreate (persistence vs ephemerality), resolution, what survives container recreate (persistence vs ephemerality),
running Python or other REPLs, tmux, or the pi-studio browser UI. Covers the running Python or other REPLs, tmux, or the pi-studio browser UI.
persistence model, the interactive-vs-tool-shell alias gotcha
(dssh/dscp/cat=bat exist only in interactive bash), host + LAN SSH
reachability and ControlMaster, split-horizon DNS mechanisms, the tmux
0-index constraint, uv-first Python, and pi-studio reachability. This skill
teaches MECHANISMS only — concrete hostnames, usernames, internal domains,
nameservers, and even the host OS vary per deployment and MUST be discovered
at runtime, never assumed or hardcoded.
--- ---
# pi-devbox environment # pi-devbox environment
@@ -0,0 +1,249 @@
---
name: pi-devbox-firstboot
description: >-
Run and correctly scope the fleet's first-boot acceptance round for a
pi-devbox container. Load this when a container was just recreated or runs a
release it has not accepted before, when a firstboot-acceptance ask is in
your mailbox, when verifying a new pi-devbox release, or when your mailbox is
EMPTY and this device has never run the round — the self-enrollment case,
where no chain exists and nobody will plant one for you. Covers the
state-based scope gate (two claim lookups keyed by release tag and
architecture), which checks settle once per release, which settle once per
architecture, and which must be re-measured per device whatever a peer
proved; validating a claim against your own readings before honouring it;
planting and perpetuating your own chain; and the invariants that keep it
alive (derive the release from the manifest instead of predicting it, reply
terminal before planting a successor, never score a cannot-run as a pass).
---
# First-boot acceptance for a pi-devbox container
**This skill is a FINDER, not the policy.** The authoritative procedure lives in
the logstream as a versioned artifact, because a policy baked into an image is
pinned to that image and cannot be corrected without a release. Your first job
is therefore to fetch the current policy, not to follow this file from memory.
```
mempalace_event_list(topic="firstboot-acceptance", type="policy.update", limit=5)
```
Take the newest, read `metadata.policy_artifact`, and fetch it:
```
mempalace_artifact_get(artifact_id="<policy_artifact>")
```
**If that artifact and this file disagree, the artifact wins** — it is newer by
construction. Treat this file as the thing that tells you the artifact exists.
(As of writing: policy v2, `art_20261023T204306_b388e93c3d64`, sha256
`40048d5d…`, 11252 B. Verify rather than trust that id; policies get revised.)
## Why there is a gate at all
Every device used to run the entire check sequence on every release. That was
not a decision — it was drift. Each device plants its own successor ask carrying
whatever the previous round contained, so scope only ever grew (4 checks at
v1.10.2 → B1–B5 at v1.11.2 → B1–B7 at v1.12.2), while a peer's PASS on the
*identical image bytes* was recorded as interesting context rather than as
grounds to skip anything. The fleet had actually decided the opposite in
2026-08 (v1.8.10: "IMAGE HALF — ALREADY SETTLED, DO NOT REPEAT"), and nothing
ever retired that decision; it simply lapsed.
The gate restores it, in a form that needs **no roster**. Nothing addresses you,
nothing enrolls you, and a device that leaves the fleet leaves nothing behind to
clean up. You derive your own scope by reading published state.
## The gate
```
release_tag, source_revision <- /etc/pi-devbox/build-manifest.json
arch, my_arch_digest <- your architecture, your PER-ARCH manifest digest
R_claim <- event_list(correlation_id="firstboot-release-<TAG>", type="firstboot.release.settled")
I_claim <- event_list(correlation_id="firstboot-image-<TAG>-<ARCH>", type="firstboot.image.settled")
```
| R valid | I valid | you run | meaning |
|---|---|---|---|
| no | no | **R + I + D** | first device on this release, anywhere |
| yes | no | **I + D** | first device on this *architecture* for this release |
| yes | yes | **D only** | cite the claim ids you inherited |
Emit a claim for each tier you newly settle. **Tier D always runs.**
The `correlation_id` *is* the state key, which makes this two exact server-side
lookups — no scanning, no metadata filtering, and the same cost whether the
fleet has three devices or thirty.
### RULE ZERO: derive the release, never predict it
Read `release_tag` from the manifest at boot. Do not write an expected release
into an ask, a drawer or an expectation file. Seven plants on this fleet have
died of predicting a version: v1.9.0, v1.10.0, v1.10.1 and v1.11.1 were all
tagged and then published nothing, so any ask naming them was wrong before it
was read.
### Validate before you honour — the one way this fails unsafely
A claim is honoured **only if its keys equal your own first-hand readings**:
- release claim: `release_tag` **and** `source_revision`
- image claim: `release_tag`, `arch` **and the arch digest you measured**
A mismatch means **absent**: run the tier. Without this check, a single
mis-keyed or stale claim silently switches off real verification across the
whole fleet, and the failure is invisible because every device still reports
PASS. A `firstboot.release.disputed` / `firstboot.image.disputed` event newer
than the newest claim on that key **voids** it — run the tier yourself.
Duplicate claims are harmless and expected: two devices booting the same release
before replication catches up both run full scope and both claim. The gate wants
**at least one** valid claim, not exactly one. There is deliberately no
reservation protocol, because a reservation can strand a tier if its holder dies
mid-round.
### The digest is the per-arch one
Your image key is the **per-architecture registry manifest digest**. On OrbStack
`docker image inspect` reports `Id` == `RepoDigests` == the multi-arch **index**
digest, while the `com.docker.compose.image` label carries the arch digest.
Comparing an index digest with an arch digest is a category error, not a
mismatch. An arm64 pass licenses **nothing** on amd64 — different bytes.
## The three tiers
**Tier R — release-level, architecture-independent.** The seven component refs
are resolved once per build and handed to every variant job, and the harness
lives in the repo rather than the image: component refs, CI run health,
`Dockerfile.variant` harness wiring, the directives truth table, host buildx,
changelog-vs-repo. Key: `release_tag` + `source_revision`.
**Tier I — image-level, per architecture.** Different bytes per arch: the obsmem
turn cap (two-sided, in both `src` and `dist` forms), dormancy-bridge
byte-identity against the manifest commit, base tool versions as installed, the
component `/opt` HEADs. Key: `release_tag` + `arch` + arch digest.
**Tier D — device-level, always, never inheritable.** Recreate timeline,
is-the-release-really-here, your own digest reading, the sanity script on every
invocation you report, ControlPath, volume survival, palace wiring and
extensions, `.env` and credential probes, KG and fleet triples, chain
bookkeeping — and the **config-coupled runtime checks**.
That last group is *why* D can never be inherited. The same image reads
differently per device and **both readings are correct**:
- `showSidebarOnStartup` is FALSE on some devices and TRUE on others, so an
absent startup sidebar is a defect on one device and by-design on another.
- Sanity warn counts differ by device **and by invocation** — bare vs
`--expected-image-version` gives 2 vs 1 on the same image in the same minute.
**A warn count is not a device property unless the invocation is pinned.**
Record the invocation next to every number.
- ControlPath warnings track the *host's* ssh config, not any image defect.
Carrying a peer's number across has already manufactured false defects here.
## Self-enrollment: if your mailbox is empty
The mempalace skill tells you to check your mailbox and that an empty result is
a fine answer. For this round, empty has two very different causes:
1. **You have a chain and its ask is dormant** — correct, nothing is owed.
2. **You have no chain at all** — you are a new device. Nothing is owed and
nothing ever will be, because asks are planted per device *by that device*.
In case 2, **plant your own first ask** rather than waiting for a peer to notice
you joined. Historically a peer did plant it cross-device (one device's ask was
authored by another), which is exactly the roster dependency the state design
removes. Query your own chain before concluding:
```
mempalace_event_list(correlation_id="firstboot-acceptance-<device>", limit=5)
```
## Chain invariants — the ways this chain dies quietly
- **Reply terminal FIRST, then plant the successor.** `isAnswered` joins on
`correlation_id` with a strictly-after comparison, so planting first silently
retires your own successor and the chain dies with no error at the next
recreate.
- **Dormancy baselines must be ISO-with-Z**, and verified against the *real*
`evaluateDormancy` from the booted bridge before you append. UTC digits
without the `Z` parse 7200 s low on a CEST host; an epoch (string or number)
is `NaN` and makes the condition unevaluable, so the ask nags forever while
looking like a broken feature. Import the function; do not regex a copy of it.
- **A cannot-run is never a pass.** Gates here deliberately separate rc=1
(violation) from rc=2 (cannot run). An empty file is a violation; an absent
path is a cannot-run. Scoring a cannot-run as a pass is how a gate that never
ran reports green.
- **Only the original requester can withdraw an ask.** `isWithdrawn` requires
the terminal event to be addressed to the exact mailbox, so you cannot retire
another device's ask and no broadcast can empty every mailbox at once.
- **Claims are `status=ready`.** Not `open` — the owed-work query selects
`status=open` and a `to_agent="*"` broadcast matches every mailbox, so an open
claim would appear as an owed ask on every device in the fleet. Not terminal
either, because terminal events join `isAnswered` and could clear real work.
## A command gate on the device can fail your probes
A device may run a bash gate that the image does not ship. Today that is
`erdc-guard`, a fleet extension mounted from docker-compose-repo at
`/opt/fleet-pi`. It scores every bash statement with an ERDC classifier and
asks before running anything at or above its threshold (0.8 by default). In
the TUI it waits 120 s, then blocks; under `pi -p` it blocks at once.
A probe it stops failed for a reason that has nothing to do with the image.
**That is a cannot-run: never a pass, and never a defect either.** Read
`/guard status` before you probe, so you know whether the gate is on.
The gate judges the **text** of a command, not what will actually run.
Measured on tor-ms22 on 2026-10-24 with the guard's own `assess()`:
| probe shape | flagged |
|---|---|
| `ERDC_GUARD=off pi -p "… rm -rf data …"` | yes, 0.95 |
| the same prompt, as `pi -p @/tmp/…/prompt.md` | no |
| `cat >> f <<'EOF'` with test lines holding `bash("rm -rf build/")` | yes, 0.93 |
| `cat > f <<'EOF'` with one `"rm -rf build/"` string | no |
| `rm -rf /tmp/<scratch>` | no (skipped as scratch) |
| `git rebase origin/main` | yes, 0.89 |
Try these in order:
1. **Write fixture files and prompt files with the `write` tool**, which the
gate does not watch. Whether a heredoc is flagged depends on its contents,
so you cannot predict it. Do not route file content through bash.
2. **Give a child pi its prompt as `@file`.** `ERDC_GUARD=off` in the child's
environment switches off only the *child's* gate. The parent has already
judged the prompt text and blocked it.
3. **Keep scratch under `/tmp`.** The gate skips removals confined to `/tmp`
without sending a request.
4. **Use `/guard off` only as a last resort.** It is not scoped to your round:
- The setting lives in `~/.pi/agent/erdc-guard.json` and is re-read before
every bash call. Switching it off turns off the gate in every pi session on
the volume at once, and it stays off if your round dies.
- While off, erdc-guard keeps its bash-gate claim, so confirm-destructive
stays stood down too. The result is no gate at all.
- It also changes the Tier D reading of `/guard status`.
If you must use it: take that reading first, switch the gate off, switch it
back on afterwards, and record both switches in the evidence.
## When you copy a probe forward, grep it for your own recorded landmines
Not just stale paths and the previous run's parameters. This fleet has a
documented instance of an agent re-importing a probe carrying the exact
`set -u` bug that its *own* retraction list named as having already killed that
probe. **Reading a retraction is not the same as applying it to the artefact the
retraction is about.**
## Limits of this file
- It tells you the procedure exists and where the real one lives. It cannot be
the procedure, because it ships inside the image it would be verifying — if
this file were wrong you would validate a bad image with its own instructions.
- It arrives only with a release. A device booting an older image gets an older
copy of this finder, which is the main reason it carries the *lookup* rather
than the policy: the lookup is stable across policy revisions.
- Nothing here is automatic. A session must load this skill (the pi-devbox
managed block in the global `AGENTS.md` points at it), and the device must
then act on it.
+189
View File
@@ -0,0 +1,189 @@
#!/usr/bin/env bash
# check-skill-frontmatter.sh — fail when an image-baked skill's frontmatter
# violates the Agent Skills spec that pi's own loader measures against.
#
# THE DEFECT THIS EXISTS TO CATCH, measured 2026-10-23 while adding the
# pi-devbox-firstboot skill. Its `description` was drafted at 1056 characters.
# pi's shipped loader (dist/core/skills.js) defines MAX_DESCRIPTION_LENGTH =
# 1024 and pushes a diagnostic when a description is longer — and then says, in
# a comment directly above the return:
#
# // Still load the skill even with warnings, unless description is
# // missing or empty.
#
# So an over-long description does NOT break the skill. That is exactly why it
# needs a gate rather than trust: the skill works, the violation is real, and
# the only thing that would ever tell you is a diagnostic emitted inside a pi
# session. Nothing in CI starts a pi session and reads skill diagnostics, so the
# warning is invisible here by construction. A spec violation that still works
# is the most durable kind of rot — nothing ever forces the fix.
#
# One failure in this family is NOT a warning but a silent drop: a missing or
# empty description returns `{ skill: null }`, so the skill is never offered to
# the model at all. The skill file ships, `test -f` passes in smoke-test.sh, the
# symlink resolves, `pi-devbox-version` lists it — and the skill does not exist
# as far as any agent is concerned. This gate separates the two cases in its
# output, because they need different urgency even though both are red.
#
# WHY THE BAKED TREE SPECIFICALLY. These skills are the FALLBACK layer (see
# skills/VENDORED.md): they are what a container resolves with no skillset repo
# mounted, which is also the case of a brand-new device that has nothing else.
# A skillset-owned skill has its own upstream review; a baked one has only this.
#
# MEASURED HEADROOM when this gate was added, which is why it is worth having:
# credential-incident-response 1022 (2 chars spare)
# pi-extensions 1003 (21)
# pi-devbox-firstboot 952 (72)
# pi-devbox-environment 923 (101)
# mempalace 254 (770)
# Two of five are within one sentence of the limit, so "nobody would write a
# description that long" is already false here.
#
# A KNOWN COUPLING, stated rather than discovered later: `pi-extensions` and
# `mempalace` are VENDORED copies, so an upstream edit that pushes one over the
# limit turns this gate red on a file this repo does not author. That is
# deliberate and precedented — check-skill-floor.sh already couples to the
# pi-extensions package — because the image SHIPS those bytes, so the violation
# is ours to carry regardless of who typed it. The fix in that case is to
# refresh the snapshot after upstream trims, not to exempt the file.
#
# WHY NAME IS CHECKED TOO, including against the directory. The loader falls
# back to the PARENT DIRECTORY NAME when frontmatter has no usable `name`
# (`frontmatterName || parentDirName`), so a mismatch does not fail — it
# silently renames the skill, and every pointer written as
# `~/.agents/skills/<dir>/SKILL.md` then refers to a skill the model knows under
# a different name. The managed AGENTS.md block points at skills by path, so
# that divergence is load-bearing here.
#
# EXIT CODES (same contract as lint-shell.sh / check-skill-floor.sh /
# check-doc-drift.sh — a gate that cannot run must not pass):
# 0 every baked skill's frontmatter is valid
# 1 VIOLATION — at least one skill breaks the spec
# 2 cannot run — no skills directory, or no python3 with a YAML module
#
# WHY PYTHON AND NOT grep/awk. `description: >-` is a YAML folded block scalar:
# its value spans lines and is joined with spaces, so a line-oriented length
# check measures the wrong string. python3-yaml is already installed in the
# actionlint job and already used by check-doc-drift.sh, so this adds no new
# dependency. NB the heredoc form below is `python3 - "$arg"` (script on stdin,
# argument on the command line); `echo x | python3 <<'EOF'` cannot work, which
# is the SC2259 defect the shellcheck gate was added for.
set -euo pipefail
REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
SKILLS_DIR="${REPO_ROOT}/rootfs/usr/local/share/pi-devbox/skills"
usage() {
cat <<'USAGE'
Usage: scripts/check-skill-frontmatter.sh [options]
Options:
--skills-dir DIR Validate DIR instead of the repo's baked skills tree
(used by the truth-table self-test).
-h, --help This text.
Exit: 0 valid | 1 violation | 2 cannot run
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
--skills-dir) shift; [ $# -gt 0 ] || { echo "::error::--skills-dir needs a value" >&2; exit 2; }; SKILLS_DIR="$1" ;;
-h|--help) usage; exit 0 ;;
*) echo "::error::unknown option: $1" >&2; usage >&2; exit 2 ;;
esac
shift
done
if [ ! -d "$SKILLS_DIR" ]; then
echo "::error::skills directory not found: ${SKILLS_DIR}"
echo "A gate that cannot run must not pass — this is a red 2, not a green tick."
exit 2
fi
if ! command -v python3 >/dev/null 2>&1; then
echo "::error::python3 not found; cannot parse YAML frontmatter"
exit 2
fi
if ! python3 -c 'import yaml' >/dev/null 2>&1; then
echo "::error::python3 has no yaml module (apt: python3-yaml); cannot parse frontmatter"
exit 2
fi
python3 - "$SKILLS_DIR" <<'PY'
import os, re, sys
import yaml
# Mirrors pi's dist/core/skills.js. If pi changes these, change them here.
MAX_NAME_LENGTH = 64
MAX_DESCRIPTION_LENGTH = 1024
skills_dir = sys.argv[1]
fatal, violations, checked = [], [], 0
for name in sorted(os.listdir(skills_dir)):
d = os.path.join(skills_dir, name)
if not os.path.isdir(d):
continue
path = os.path.join(d, "SKILL.md")
rel = os.path.relpath(path, skills_dir)
if not os.path.isfile(path):
fatal.append(f"{rel}: directory has no SKILL.md — the loader cannot see it")
continue
checked += 1
raw = open(path, encoding="utf-8").read()
m = re.match(r"^---\n(.*?)\n---\n", raw, re.S)
if not m:
fatal.append(f"{rel}: no YAML frontmatter block (must open on line 1 with ---)")
continue
try:
fm = yaml.safe_load(m.group(1)) or {}
except Exception as e:
fatal.append(f"{rel}: frontmatter is not valid YAML: {e}")
continue
if not isinstance(fm, dict):
fatal.append(f"{rel}: frontmatter is not a mapping")
continue
desc = fm.get("description")
if not isinstance(desc, str) or desc.strip() == "":
# The one hard drop: the loader returns skill: null for this.
fatal.append(f"{rel}: description missing or empty — THE SKILL IS NEVER LOADED")
elif len(desc) > MAX_DESCRIPTION_LENGTH:
violations.append(
f"{rel}: description is {len(desc)} chars, exceeds {MAX_DESCRIPTION_LENGTH} "
f"(loader warns and still loads, so nothing else will tell you)"
)
fm_name = fm.get("name") if isinstance(fm.get("name"), str) else None
eff = fm_name or name
if len(eff) > MAX_NAME_LENGTH:
violations.append(f"{rel}: name is {len(eff)} chars, exceeds {MAX_NAME_LENGTH}")
if not re.fullmatch(r"[a-z0-9-]+", eff):
violations.append(f"{rel}: name {eff!r} must be lowercase a-z, 0-9 and hyphens only")
if eff.startswith("-") or eff.endswith("-"):
violations.append(f"{rel}: name {eff!r} must not start or end with a hyphen")
if "--" in eff:
violations.append(f"{rel}: name {eff!r} must not contain a double hyphen")
if fm_name is not None and fm_name != name:
violations.append(
f"{rel}: frontmatter name {fm_name!r} != directory {name!r} — the loader "
f"would use {fm_name!r} while every path-based pointer says {name!r}"
)
print(f"checked {checked} baked skill(s) in {skills_dir}")
if not fatal and not violations:
print("OK: every baked skill's frontmatter is valid.")
sys.exit(0)
for f in fatal:
print(f"::error::{f}")
for v in violations:
print(f"::error::{v}")
print(f"FAIL: {len(fatal)} fatal, {len(violations)} spec violation(s).")
sys.exit(1)
PY
+141
View File
@@ -0,0 +1,141 @@
#!/usr/bin/env bash
# ci-reclaim-docker.sh — free disk on the CI runner's Docker daemon WITHOUT
# deleting a smoke image that another job is still testing.
#
# Used by every docker-publish.yml job that reclaims disk (build-base, smoke,
# smoke-studio, build-variant, build-variant-studio). Not shipped in the image.
#
# bash scripts/ci-reclaim-docker.sh # reclaim (default)
# bash scripts/ci-reclaim-docker.sh assert-protected IMG # smoke preflight
# bash scripts/ci-reclaim-docker.sh hold IMG # pin IMG while testing
# bash scripts/ci-reclaim-docker.sh release IMG # drop the pin + IMG
# bash scripts/ci-reclaim-docker.sh lint WORKFLOW... # lint.yml guard
#
# WHY THIS EXISTS. Everything that runs on one runner host shares its Docker
# daemon: concurrent jobs of a runner with capacity > 1 (runner-a1 had
# smoke-studio and build-variant going together in run 740), and the jobs of
# other act_runner containers on the same VM (runner-2 has one registered with
# gitea.jordbo.se and one with gitea.egl.lan). Each pi-devbox job used to start
# with `docker system prune -af --volumes`. That removes every image no
# container is using at that instant, and a smoke image is
# exactly that for most of its life: smoke-test.sh runs one `docker run --rm`
# per assertion, so between assertions nothing holds the image. v1.12.2 run 740
# (2026-10-22): smoke passed, build-variant started and pruned, and its log
# shows `untagged: pi-devbox:smoke-studio` at 22:58:49.11Z, 30 ms after
# smoke-studio had imported that image (22:58:49.08Z). Every smoke-studio
# assertion then failed with "Unable to find image 'pi-devbox:smoke-studio'
# locally", build-variant-studio was skipped, and the release went out without
# its -studio tags. Job ORDER cannot close this: smoke and smoke-studio prune
# each other too whenever their start times drift apart, and other runs share
# the daemon. Two defences, because we only control our own jobs:
# - the smoke images carry a label, and this script never prunes an image
# that has it (covers pi-devbox's own jobs, before and during the test);
# - `hold` starts a container from the image for the whole smoke step. No
# prune removes an image a running container uses, so this also covers
# jobs that never call this script (opencode-devbox's raw prunes,
# anything gitea.egl.lan runs). What is left is the few seconds between
# the build step loading the image and `hold` starting.
#
# WHAT IT DOES. The same reclaim as `docker system prune -af --volumes` +
# `docker builder prune -af`, done per object type so the image step can take
# a filter:
# 1. stopped containers (running ones are never touched)
# 2. unused images WITHOUT the label (the old `system prune -a` image step)
# 3. unused images WITH the label, older than SMOKE_IMAGE_MAX_AGE
# — orphans of a smoke job that died before its own cleanup step
# 4. unused networks, anonymous volumes (what `system prune --volumes` did)
# 5. the build cache
# Each step is `|| true`, as before: failing to reclaim disk must not fail the
# job. A filter the daemon rejected would show up as an error line in the log.
#
# `assert-protected IMG` is the smoke jobs' preflight: it fails unless IMG is in
# the daemon AND carries the label. Without it, a build that silently lost the
# label would look fine until the next time a sibling job's prune landed
# mid-test; and a missing image gets one clear error line instead of every
# assertion failing with "Unable to find image ... locally".
#
# `lint WORKFLOW...` fails if any workflow runs `docker system prune` or
# `docker image prune` itself. One such line in any job reopens the race, so
# lint.yml runs this on every push rather than relying on review.
#
# Environment:
# SMOKE_IMAGE_LABEL label key that protects an image (default below; the
# workflow sets it once in its top-level env)
# SMOKE_IMAGE_MAX_AGE `until=` value for the orphan sweep (default 6h; a
# smoke job takes ~10 min). Compared against the image's
# Created time, which is the build time: nothing here
# sets SOURCE_DATE_EPOCH.
set -u
SMOKE_IMAGE_LABEL="${SMOKE_IMAGE_LABEL:-se.jordbo.pi-devbox.ci-smoke-image}"
SMOKE_IMAGE_MAX_AGE="${SMOKE_IMAGE_MAX_AGE:-6h}"
step() { printf '+ %s\n' "$*"; "$@" || true; }
reclaim() {
step docker container prune -f
step docker image prune -af --filter "label!=${SMOKE_IMAGE_LABEL}"
step docker image prune -af --filter "label=${SMOKE_IMAGE_LABEL}" \
--filter "until=${SMOKE_IMAGE_MAX_AGE}"
step docker network prune -f
step docker volume prune -f
step docker builder prune -af
}
assert_protected() {
local img="${1:?usage: $0 assert-protected <image>}" val
if ! val=$(docker image inspect \
--format "{{ index .Config.Labels \"${SMOKE_IMAGE_LABEL}\" }}" "$img" 2>&1); then
echo "::error::${img} is not in the runner's Docker daemon (${val}). Was it removed by a concurrent prune that does not honour ${SMOKE_IMAGE_LABEL}?"
return 1
fi
if [ "$val" != "true" ]; then
echo "::error::${img} lacks the label ${SMOKE_IMAGE_LABEL}=true (got '${val}'), so a sibling job's reclaim could delete it mid-test. Check the build step's labels: input."
return 1
fi
echo "OK: ${img} is present and carries ${SMOKE_IMAGE_LABEL}=true"
}
holder_name() { printf 'ci-hold-%s' "$(printf '%s' "$1" | tr -c 'A-Za-z0-9_.-' '_')"; }
# The holder exits on its own after an hour (--rm), so a job killed before
# `release` does not pin its image forever; the age sweep then removes it.
hold() {
local img="${1:?usage: $0 hold <image>}" name
name=$(holder_name "$img")
docker rm -f "$name" >/dev/null 2>&1 || true
if ! docker run -d --rm --name "$name" --network none --entrypoint "" \
"$img" sleep 3600 >/dev/null; then
echo "::error::could not start holder ${name} for ${img}; is the image still in the daemon?"
return 1
fi
echo "OK: ${img} pinned by running container ${name} (exits by itself after 1 h)"
}
release() {
local img="${1:?usage: $0 release <image>}"
docker rm -f "$(holder_name "$img")" >/dev/null 2>&1 || true
docker image rm "$img" >/dev/null 2>&1 || true
echo "released ${img}"
}
lint_workflows() {
[ "$#" -gt 0 ] || { echo "usage: $0 lint <workflow.yml>..." >&2; return 2; }
local hits
hits=$(grep -nE '^[^#]*docker[[:space:]]+(system|image)[[:space:]]+prune' "$@" || true)
if [ -n "$hits" ]; then
echo "::error::raw docker prune in a workflow. It deletes smoke images other jobs are testing on the shared runner daemon; call 'bash scripts/ci-reclaim-docker.sh' instead."
printf '%s\n' "$hits"
return 1
fi
echo "OK: no raw 'docker system|image prune' in $# workflow file(s)"
}
case "${1:-reclaim}" in
reclaim) reclaim ;;
assert-protected) shift; assert_protected "$@" ;;
hold) shift; hold "$@" ;;
release) shift; release "$@" ;;
lint) shift; lint_workflows "$@" ;;
*) echo "usage: $0 [reclaim | assert-protected <image> | hold <image> | release <image> | lint <workflow.yml>...]" >&2; exit 2 ;;
esac
+65 -2
View File
@@ -291,12 +291,30 @@ run "image-baked pi-devbox-environment skill" \
"test -f /usr/local/share/pi-devbox/skills/pi-devbox-environment/SKILL.md" "test -f /usr/local/share/pi-devbox/skills/pi-devbox-environment/SKILL.md"
run "image-baked credential-incident-response skill" \ run "image-baked credential-incident-response skill" \
"test -f /usr/local/share/pi-devbox/skills/credential-incident-response/SKILL.md" "test -f /usr/local/share/pi-devbox/skills/credential-incident-response/SKILL.md"
run "image-baked pi-devbox-firstboot skill" \
"test -f /usr/local/share/pi-devbox/skills/pi-devbox-firstboot/SKILL.md"
run "global-AGENTS append snippet present" \ run "global-AGENTS append snippet present" \
"test -f /usr/local/share/pi-devbox/pi-global-AGENTS.append.md" "test -f /usr/local/share/pi-devbox/pi-global-AGENTS.append.md"
run "pi-devbox block merged into pi-global-AGENTS.md" \ run "pi-devbox block merged into pi-global-AGENTS.md" \
"grep -q 'pi-devbox:managed-block' /opt/pi-toolkit/pi-global-AGENTS.md" "grep -q 'pi-devbox:managed-block' /opt/pi-toolkit/pi-global-AGENTS.md"
run "mempalace session-start pointer merged into global AGENTS.md" \ run "mempalace session-start pointer merged into global AGENTS.md" \
"grep -q 'load the mempalace skill' /opt/pi-toolkit/pi-global-AGENTS.md" "grep -q 'load the mempalace skill' /opt/pi-toolkit/pi-global-AGENTS.md"
# The firstboot pointer is asserted by PATH, not prose: a brand-new device with
# an empty mailbox reaches the acceptance round only through this line, and
# rewording the paragraph must not be able to silently break the link.
run "firstboot skill pointer merged into global AGENTS.md" \
"grep -q 'pi-devbox-firstboot/SKILL.md' /opt/pi-toolkit/pi-global-AGENTS.md"
# Same path assertion for the two skills whose descriptions were deliberately
# TRIMMED to a routing sentence. Their frontmatter no longer enumerates what
# they cover, on the explicit premise that this block names them by path and so
# they never depend on description-matching to be found. That premise is only
# true while these lines exist, which is exactly why it is asserted rather than
# trusted: reword the block, drop a pointer, and the skill silently stops being
# reachable for a device that did not already know it was there.
run "pi-devbox-environment pointer merged into global AGENTS.md" \
"grep -q 'pi-devbox-environment/SKILL.md' /opt/pi-toolkit/pi-global-AGENTS.md"
run "credential-incident-response pointer merged into global AGENTS.md" \
"grep -q 'credential-incident-response/SKILL.md' /opt/pi-toolkit/pi-global-AGENTS.md"
# Vendored fallback skills (so a no-skillset container still resolves the # Vendored fallback skills (so a no-skillset container still resolves the
# AGENTS.md 'read the pi-extensions skill' pointer). # AGENTS.md 'read the pi-extensions skill' pointer).
run "image-baked pi-extensions fallback skill" \ run "image-baked pi-extensions fallback skill" \
@@ -696,6 +714,51 @@ exec_test "git core.sshCommand matches sidecar presence" '
else else
[ -z "$cmd" ] || exit 1; echo "no sidecar, correctly unset" [ -z "$cmd" ] || exit 1; echo "no sidecar, correctly unset"
fi' fi'
# ssh-lan.conf is Included whether or not it exists at container start. Until
# v1.12.0 the Include was written only if the file was readable at start, so a
# file created later was silently ignored (EMB-3HXGNXJ0, 2026-10-18). ssh skips
# an Include whose file is missing, and picks the file up once it appears, with no
# restart. The exception is a file that exists but cannot be read: ssh then
# aborts every connection, so that case is still left out. 644 is set
# explicitly because ssh rejects a world-writable config file.
exec_test "ssh-lan.conf Included even if created after start" '
T=$(mktemp -d) && mkdir -p $T/.config/devbox-shell &&
HOME=$T DEVBOX_LAN_ACCESS=jump bash /usr/local/lib/pi-devbox/setup-lan-access.sh >/dev/null 2>&1 &&
grep -qx "Include ~/.config/devbox-shell/ssh-lan.conf" $T/.ssh-local/config &&
HOME=$T ssh -F $T/.ssh-local/config -G lanpeer 2>/dev/null | grep -qx "hostname lanpeer" &&
printf "Host lanpeer\n HostName 10.9.9.9\n" > $T/.config/devbox-shell/ssh-lan.conf &&
chmod 644 $T/.config/devbox-shell/ssh-lan.conf &&
HOME=$T ssh -F $T/.ssh-local/config -G lanpeer 2>/dev/null | grep -qx "hostname 10.9.9.9"
r=$?; rm -rf $T; exit $r'
exec_test "unreadable ssh-lan.conf left out (ssh would abort)" '
T=$(mktemp -d) && mkdir -p $T/.config/devbox-shell && : > $T/.config/devbox-shell/ssh-lan.conf &&
chmod 000 $T/.config/devbox-shell/ssh-lan.conf &&
HOME=$T DEVBOX_LAN_ACCESS=jump bash /usr/local/lib/pi-devbox/setup-lan-access.sh >/dev/null 2>&1 &&
! grep -q "ssh-lan.conf$" $T/.ssh-local/config &&
HOME=$T ssh -F $T/.ssh-local/config -G lanpeer >/dev/null 2>&1
r=$?; rm -rf $T; exit $r'
# ~/.ssh/config gets the same rule as ssh-lan.conf above: Included whether or
# not it exists at container start, except when it exists but cannot be read.
# Entries added to a file that already existed at start were always picked up
# (ssh reads Includes on every connection, and the mount is live). This covers
# only a file that appears after start, e.g. a host provisioned after its first
# `docker compose up`. Through v1.12.1 that file was ignored until a restart.
exec_test "~/.ssh/config Included even if created after start" '
T=$(mktemp -d) &&
HOME=$T DEVBOX_LAN_ACCESS=jump bash /usr/local/lib/pi-devbox/setup-lan-access.sh >/dev/null 2>&1 &&
grep -qx "Include ~/.ssh/config" $T/.ssh-local/config &&
HOME=$T ssh -F $T/.ssh-local/config -G ownpeer 2>/dev/null | grep -qx "hostname ownpeer" &&
mkdir -p $T/.ssh && printf "Host ownpeer\n HostName 10.8.8.8\n" > $T/.ssh/config &&
chmod 600 $T/.ssh/config &&
HOME=$T ssh -F $T/.ssh-local/config -G ownpeer 2>/dev/null | grep -qx "hostname 10.8.8.8"
r=$?; rm -rf $T; exit $r'
exec_test "unreadable ~/.ssh/config left out (ssh would abort)" '
T=$(mktemp -d) && mkdir -p $T/.ssh && printf "Host x\n" > $T/.ssh/config &&
chmod 000 $T/.ssh/config &&
HOME=$T DEVBOX_LAN_ACCESS=jump bash /usr/local/lib/pi-devbox/setup-lan-access.sh >/dev/null 2>&1 &&
! grep -qx "Include ~/.ssh/config" $T/.ssh-local/config &&
HOME=$T ssh -F $T/.ssh-local/config -G ownpeer >/dev/null 2>&1
r=$?; chmod 600 $T/.ssh/config; rm -rf $T; exit $r'
# The vendored mempalace snapshot is refreshed MANUALLY per release (see # The vendored mempalace snapshot is refreshed MANUALLY per release (see
# rootfs/usr/local/share/pi-devbox/skills/VENDORED.md). Through v1.8.4 it also # rootfs/usr/local/share/pi-devbox/skills/VENDORED.md). Through v1.8.4 it also
# silently SHADOWED the live skillset copy, so staleness was invisible — and the # silently SHADOWED the live skillset copy, so staleness was invisible — and the
@@ -766,7 +829,7 @@ exec_test "mempalace skill snapshot is current" 'f=$HOME/.agents/skills/mempa
# Link TARGETS, not just link existence: with no skillset mounted (as here) the # Link TARGETS, not just link existence: with no skillset mounted (as here) the
# baked tree must be what resolves, for all four vendored skills. # baked tree must be what resolves, for all four vendored skills.
exec_test "vendored skills resolve to the baked tree (no skillset mounted)" \ exec_test "vendored skills resolve to the baked tree (no skillset mounted)" \
'for s in mempalace pi-extensions pi-devbox-environment credential-incident-response; do 'for s in mempalace pi-extensions pi-devbox-environment credential-incident-response pi-devbox-firstboot; do
case "$(readlink -f $HOME/.agents/skills/$s)" in case "$(readlink -f $HOME/.agents/skills/$s)" in
/usr/local/share/pi-devbox/skills/$s) ;; /usr/local/share/pi-devbox/skills/$s) ;;
*) echo "$s resolves to $(readlink -f $HOME/.agents/skills/$s)" >&2; exit 1 ;; *) echo "$s resolves to $(readlink -f $HOME/.agents/skills/$s)" >&2; exit 1 ;;
@@ -780,7 +843,7 @@ exec_test "vendored skills resolve to the baked tree (no skillset mounted)" \
exec_test "pi-devbox-version reports skill sources (all baked, no skillset here)" \ exec_test "pi-devbox-version reports skill sources (all baked, no skillset here)" \
'out=$(pi-devbox-version) 'out=$(pi-devbox-version)
echo "$out" | grep -q "skills:" || { echo "no skills section" >&2; exit 1; } echo "$out" | grep -q "skills:" || { echo "no skills section" >&2; exit 1; }
for s in mempalace pi-extensions pi-devbox-environment credential-incident-response; do for s in mempalace pi-extensions pi-devbox-environment credential-incident-response pi-devbox-firstboot; do
echo "$out" | grep -qE "^ $s +baked( \([^)]*\))?$" \ echo "$out" | grep -qE "^ $s +baked( \([^)]*\))?$" \
|| { echo "$s not reported as baked" >&2; exit 1; } || { echo "$s not reported as baked" >&2; exit 1; }
done; echo ok' done; echo ok'