Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 7737156aad | |||
| 644be67ea2 | |||
| f7e35962d6 | |||
| b06941d714 | |||
| 00345d3536 | |||
| 057827a84b | |||
| 206fd1ec35 | |||
| 5d6ff537c4 | |||
| 779efbf4fe | |||
| e0611c787f | |||
| 12e66aa52e | |||
| fb2cb2305c | |||
| 8ae1659273 |
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+374
@@ -11,6 +11,380 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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
|
## v1.12.1 — 2026-10-19
|
||||||
|
|
||||||
Patch release. Headline: **`ssh-lan.conf` is Included even if it is created
|
Patch release. Headline: **`ssh-lan.conf` is Included even if it is created
|
||||||
|
|||||||
+1
-1
@@ -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-19 (v1.12.1 — setup-lan-access.sh always Includes ssh-lan.conf; apt and base tools re-resolve (upstream on 2026-10-19: gitea-mcp 1.10.0; uv 0.13.1, Node v24.22.0, nvim 0.12.6, fzf 0.74.5 unchanged); previous marker 2026-10-18 / v1.12.0)
|
# 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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1297,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:
|
||||||
@@ -1336,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` |
|
||||||
|
|||||||
@@ -196,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.
|
||||||
Executable
+189
@@ -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
|
||||||
Executable
+141
@@ -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
|
||||||
+42
-2
@@ -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" \
|
||||||
@@ -719,6 +737,28 @@ exec_test "unreadable ssh-lan.conf left out (ssh would abort)" '
|
|||||||
! grep -q "ssh-lan.conf$" $T/.ssh-local/config &&
|
! grep -q "ssh-lan.conf$" $T/.ssh-local/config &&
|
||||||
HOME=$T ssh -F $T/.ssh-local/config -G lanpeer >/dev/null 2>&1
|
HOME=$T ssh -F $T/.ssh-local/config -G lanpeer >/dev/null 2>&1
|
||||||
r=$?; rm -rf $T; exit $r'
|
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
|
||||||
@@ -789,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 ;;
|
||||||
@@ -803,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'
|
||||||
|
|||||||
Reference in New Issue
Block a user