Files
pi-devbox/rootfs/usr/local/share/pi-devbox/skills/pi-devbox-firstboot/SKILL.md
T
joakimp b06941d714
Lint / skill-floor (push) Successful in 10s
Lint / hadolint (push) Successful in 16s
Lint / doc-drift (push) Successful in 16s
Lint / actionlint (push) Successful in 20s
Lint / skill-frontmatter (push) Successful in 21s
firstboot skill: say what to do when a command gate blocks a probe
erdc-guard (fleet extension, not in the image) judges the text of every
bash statement. Measured with its own assess(): ERDC_GUARD=off on a
child pi does not help (the parent blocks the prompt text, 0.95); pi -p
@file is not flagged; cat heredocs are flagged depending on contents;
/guard off is volume-wide and keeps confirm-destructive stood down.
Order: write tool, @file, /tmp scratch, /guard off last. A blocked probe
is a cannot-run.
2026-10-24 16:16:10 +02:00

13 KiB
Raw Blame History

name, description
name description
pi-devbox-firstboot 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.