Skip to content

fix(admin): console and panel sentences follow the venue a run used (#712) #1458

fix(admin): console and panel sentences follow the venue a run used (#712)

fix(admin): console and panel sentences follow the venue a run used (#712) #1458

# REQ-UPSTREAM-CONTRACT-TESTS — the pi upgrade gate.
#
# Asserts the assumptions pi-dispatch pins about pi, and fails the build when one stops holding.
# CONST-PI-VERSION-PINNED makes an upgrade an explicit commit that edits a version string, so this
# workflow is what turns that commit into a gate rather than a hope.
#
# Every assertion below covers a failure that is SILENT. A crash reports itself and needs no test;
# these do not. Each maps to a point where a claim about pi was verified, believed, and later refuted:
# - "appendSystemPrompt composes with file discovery" -> it REPLACES it, via a `??`
# - "createAgentSession reloads the loader you pass" -> only one it built itself
# - "-nc is a flag you remember" -> it is an object you forget to build
# - "pi never throws" -> its own JSDoc says otherwise
# Each was found by reading source, not by running code. These tests are what catch the next one.
name: pi upgrade check
on:
# MAIN ONLY. Without a `branches:` key this fired on every branch, so a commit on a PR branch ran the
# whole suite TWICE -- once here, once as `pull_request` -- and both runs emitted the same five required
# context names for the same sha. That is exactly the failure the comment below rejects a companion
# workflow for ("two check runs then share one context name -- a green stand-in that can mask a red real
# run"), reached through a different door. A PR branch now gets one run, from `pull_request`; main gets
# one, from here. The cost is that pushing a branch with no PR open runs nothing, which is the intended
# trade: checks report when there is something for them to gate.
push:
branches: [main]
paths:
- "image/**"
- "worker/**"
- "receiver/**"
- "admin/**"
- "guardrails/**"
- "package.json"
- "pi-packages.example.json"
- "triggers.example.json"
- ".github/workflows/pi-upgrade-check.yml"
- ".github/scripts/admin-pi-canary.mjs"
- ".github/scripts/host-pi-canary.mjs"
- ".github/scripts/job-user-e2e.mjs"
- ".github/scripts/fake-key-smoke.mjs"
- ".github/scripts/pi-pin-check.mjs"
- ".github/scripts/pi-derived.mjs"
- ".github/scripts/pi-bump.mjs"
- ".github/workflows/pi-bump.yml"
# DELIBERATELY UNFILTERED, unlike the push trigger above. Five of the jobs below are REQUIRED status
# checks on main (issue #105), and a required check that never reports blocks the merge FOREVER -- a
# docs-only PR under the old path filter reported nothing at all (PR #106: "no checks reported") and
# would now be unmergeable. Re-adding a `paths:` here re-opens that deadlock.
#
# The rejected alternative was a companion workflow carrying the complementary `paths-ignore:` list and
# emitting same-named stand-in jobs: a PR touching BOTH docs/ and worker/ fires both workflows, because
# paths-ignore runs whenever ANY changed file falls outside its list, and two check runs then share one
# context name -- a green stand-in that can mask a red real run. The whole suite is ~90s; buying that
# back is not worth a mechanism that can hide a failure.
pull_request:
schedule:
# Weekly: pi ships breaking changes between minors and its HEAD moved within 24h of this
# project's design being written. A pin that is never exercised rots silently.
- cron: "0 6 * * 1"
workflow_dispatch:
# A BARE LITERAL GROUP WOULD BE WRONG HERE, which is why this departs from the four publisher workflows
# (image, receiver-image, release, repo-release). Those are `push: branches: [main]` only, so `group: image`
# names one queue that genuinely should be serial. This workflow runs on `pull_request`, and a literal
# would put EVERY open PR in one group -- each new PR cancelling the last one's checks.
#
# `cancel-in-progress` is safe here and is not safe there: a superseded run of this workflow is one whose
# commit nobody is merging any more, while a superseded run of `release` is a publish halfway through.
# Ten pushes to a branch used to leave ten sets of runs queued against each other, and the newest commit --
# the only one that matters -- waited behind its own predecessors.
# Read-only for every job: none of them writes, and a pi bump's pull request runs this workflow on pi code nobody has
# reviewed yet (issue #587). A job that needs more names it on itself.
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
version-pin:
name: pins are exact (CONST-PI-VERSION-PINNED)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: No floating ranges on pi packages
run: |
# A floating range turns a silent upstream minor into every queued job becoming a
# no-op with no signal, because the queue still reports success.
if grep -rnE '@earendil-works/pi-[a-z-]+@[\^~]|"@earendil-works/pi-[a-z-]+":[[:space:]]*"[\^~]' image/ package.json; then
echo "::error::Floating range on a pi package. CONST-PI-VERSION-PINNED requires an exact pin."
exit 1
fi
echo "OK: pi pinned exactly"
- name: The image's pi-version LABEL matches the runner's pin
run: |
# The worker reads this label to decide whether a persisted transcript was written by the same
# pi (REQ-RESUMABLE-SESSION). If it drifts from the runner's actual pin the failure is silent
# in the worst direction: the label says the schema matches, the schema does not, and a resumed
# job hands pi tool-call arguments it no longer accepts. A stale label is worse than no label,
# because no label means "never resume" and a wrong one means "resume anyway".
pinned=$(node -p "require('./image/runner/package.json').dependencies['@earendil-works/pi-coding-agent']")
labelled=$(grep -oE 'ARG PI_VERSION=[0-9][^[:space:]]*' image/Dockerfile | cut -d= -f2)
if [ "$pinned" != "$labelled" ]; then
echo "::error::image/Dockerfile PI_VERSION=$labelled but image/runner/package.json pins $pinned"
exit 1
fi
echo "OK: image label and runner pin agree ($pinned)"
- name: Every pi package is the runner's pin, in the overrides and the lockfile
run: |
# Issue #587. pi 1.0.1 dropped its npm-shrinkwrap.json and depends on its sibling packages by a
# range (^1.0.3), so a lockfile regenerated on another day can resolve pi-tui or pi-agent-core to
# a release nobody verified while every package.json still shows an exact pin. The root
# package.json's overrides pin every @earendil-works package to the runner's pin; this holds the
# overrides, every pi entry in package-lock.json and the hand-written pins (worker, admin,
# SUPPORTED_PI_VERSION, the Dockerfile's PI_VERSION) to that one version. Its rule is tested in
# worker/test/pi-pin-check.test.mjs.
node .github/scripts/pi-pin-check.mjs
- name: Base image is pinned by digest
run: |
# Same reasoning, different vendor. A floating base tag is the identical silent break.
if ! grep -qE '^FROM .*@sha256:[0-9a-f]{64}' image/Dockerfile; then
echo "::error::Base image is not digest-pinned in image/Dockerfile."
exit 1
fi
echo "OK: base pinned by digest"
- name: The egress proxy image is pinned by digest
run: |
# Same reasoning as the base image above, and one step sharper. deploy/docker-compose.yml names
# valkey/valkey:8, a floating minor, and that is FINE for a queue: a bad pull breaks loudly and
# spends nothing. The egress proxy IS the allowlist (REQ-EGRESS-ALLOWLIST), so a floating tag
# would let an upstream rebuild change what every job container may reach, with no commit
# anywhere and nothing failing.
if ! grep -qE 'image: ubuntu/squid@sha256:[0-9a-f]{64}' deploy/docker-compose.yml; then
echo "::error::The egress proxy image in deploy/docker-compose.yml is not digest-pinned."
exit 1
fi
echo "OK: egress proxy pinned by digest"
- name: The example pi-packages.json pins exact versions
run: |
# Third-party pi packages an operator stages into the global overlay are pinned by the SAME
# reasoning as pi itself (CONST-PI-VERSION-PINNED, INT-PI-PACKAGES-FILE-CONTRACT). The grep
# above is deliberately NOT extended to them: the operator's real pi-packages.json lives on
# their host, not in this repo, so there is nothing here to grep. The REAL enforcement is
# import-pi's load-time refusal (EXACT_VERSION_RE in worker/src/packages.mjs), which stages
# nothing at all when a version is a range, a tag, or a wildcard. This step only keeps the
# EXAMPLE we ship honest -- an example carrying "^1.4.2" would teach the failure mode the
# refusal exists to prevent.
bad=$(grep -oE '"version"[[:space:]]*:[[:space:]]*"[^"]*"' pi-packages.example.json \
| grep -vE '"version"[[:space:]]*:[[:space:]]*"[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?(\+[0-9A-Za-z.-]+)?"' || true)
if [ -n "$bad" ]; then
echo "::error::pi-packages.example.json carries a non-exact version: $bad"
exit 1
fi
echo "OK: example package pins are exact"
- name: The example triggers.json names only an image this repo produces
run: |
# Same reasoning again, and the same honest limit. A trigger's run.image is operator data
# (INT-TRIGGERS-FILE-CONTRACT): the real file lives on the operator's host, so there is nothing
# here to grep, and the REAL enforcement is parseTriggers' load-time refusal plus the worker's
# pre-spend `docker image inspect`. This step only keeps the EXAMPLE we ship honest -- an example
# naming "my-python:latest" would teach a tag nobody has, and turn a first run into a refusal.
# The list grew when this repo started producing a second image: Azure DevOps' only CLI is the
# Azure CLI plus its devops extension (~1 GB of Python), which does not belong in the image every
# job runs, so it ships as image/Dockerfile.azure and an azure trigger names it via run.image
# (DES-IMAGE-DECLARES-ITS-FORGES). Any OTHER tag here is still refused.
bad=$(grep -oE '"image"[[:space:]]*:[[:space:]]*"[^"]*"' triggers.example.json \
| grep -vE '"image"[[:space:]]*:[[:space:]]*"pi-job:(latest|azure)"' || true)
if [ -n "$bad" ]; then
echo "::error::triggers.example.json names an image this repo does not produce: $bad"
exit 1
fi
echo "OK: the example names only images this repo produces"
- name: glab is pinned by version AND checksum
run: |
# `gh` installs from an apt channel and is this image's one unpinned dependency. A second CLI
# must not repeat that: an unattended upstream change would land inside a container holding a
# repo credential, which is CONST-PI-VERSION-PINNED's reasoning one layer out. The version and
# both per-architecture digests are literals in the Dockerfile, and this asserts they stay.
for name in GLAB_VERSION GLAB_SHA256_AMD64 GLAB_SHA256_ARM64; do
if ! grep -qE "^ARG ${name}=[0-9a-f.]+$" image/Dockerfile; then
echo "::error::${name} is missing or not a literal pin in image/Dockerfile."
exit 1
fi
done
if ! grep -q 'sha256sum -c -' image/Dockerfile; then
echo "::error::The glab download is not checksum-verified."
exit 1
fi
echo "OK: glab pinned by version and verified by checksum"
- name: tea is pinned by version AND checksum
run: |
# Identical reasoning to glab's, and issue #61 asks for it by name -- "a second CLI should not
# repeat that". `gh` remains this image's one unpinned dependency; every CLI added since is
# pinned to an exact version and verified against a per-architecture digest.
for name in TEA_VERSION TEA_SHA256_AMD64 TEA_SHA256_ARM64; do
if ! grep -qE "^ARG ${name}=[0-9a-f.]+$" image/Dockerfile; then
echo "::error::${name} is missing or not a literal pin in image/Dockerfile."
exit 1
fi
done
echo "OK: tea pinned by version and verified by checksum"
- name: The forge's MCP servers are pinned by version AND checksum or lockfile
run: |
# DES-FORGE-MCP, and the reasoning glab's and tea's steps give: each runs inside a container holding the job's
# forge token. github-mcp-server is a release tarball, pinned by an exact version and both per-architecture
# digests; mcp-gitlab is an npm package, pinned by an exact version in its own package.json and installed with
# `npm ci` from the lockfile committed beside it. worker/test/forge-mcp-pins.test.mjs holds the same, and
# more.
for name in GITHUB_MCP_SERVER_VERSION GITHUB_MCP_SERVER_SHA256_AMD64 GITHUB_MCP_SERVER_SHA256_ARM64; do
if ! grep -qE "^ARG ${name}=[0-9a-f.]+$" image/Dockerfile; then
echo "::error::${name} is missing or not a literal pin in image/Dockerfile."
exit 1
fi
done
if ! grep -qE '"@zereight/mcp-gitlab": "[0-9]+\.[0-9]+\.[0-9]+"' image/forge-mcp/gitlab/package.json; then
echo "::error::image/forge-mcp/gitlab/package.json does not pin @zereight/mcp-gitlab to an exact version."
exit 1
fi
if [ ! -f image/forge-mcp/gitlab/package-lock.json ] || ! grep -q 'npm ci --omit=dev --ignore-scripts' image/Dockerfile; then
echo "::error::mcp-gitlab is not installed with npm ci from its committed lockfile."
exit 1
fi
echo "OK: github-mcp-server pinned by version and checksum, mcp-gitlab by version and lockfile"
- name: The azure image variant pins the Azure CLI and declares its forges
run: |
# The variant is built from the default image, so it inherits the base digest, the pi pin and the
# pi-version label rather than re-declaring them -- a second copy of those is a second thing to
# forget to bump. What it MUST declare itself is the Azure CLI version and the forge list the
# worker's pre-spend preflight reads: a label naming a forge whose CLI is absent would turn a
# loud refusal into a paid container that fails at step 3 (DES-IMAGE-DECLARES-ITS-FORGES).
if ! grep -qE '^ARG AZURE_CLI_VERSION=[0-9]' image/Dockerfile.azure; then
echo "::error::AZURE_CLI_VERSION is missing or not a literal pin in image/Dockerfile.azure."
exit 1
fi
if ! grep -q 'dev.pi-dispatch.forges=.*azure' image/Dockerfile.azure; then
echo "::error::image/Dockerfile.azure does not declare azure in dev.pi-dispatch.forges."
exit 1
fi
if ! grep -q 'dev.pi-dispatch.forges=' image/Dockerfile; then
echo "::error::image/Dockerfile does not declare dev.pi-dispatch.forges."
exit 1
fi
echo "OK: the azure variant pins its CLI and declares its forges"
no-automatic-merge:
name: no automatic merge (CONST-MERGE-NEVER-AUTOMATIC)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: No merge API call anywhere
run: |
# CONST-MERGE-NEVER-AUTOMATIC: "grep is the test". A match is a merge symbol reaching
# the code — the human review step is the last line and is not negotiable.
#
# The pattern has TWO surfaces since GitLab landed (issue #42). A grep that still only knew
# GitHub's would pass while `PUT /projects/:id/merge_requests/:iid/merge` sat in the worker:
# the constraint would read as enforced and would not be.
#
# The scope grew with the automated pi bump (issue #587): .github/workflows/pi-bump.yml and the scripts it runs
# hold a write token, which is exactly where an automatic merge, an auto-merge switch or a self-approval would
# go. This file stays OUT of scope, because it holds the patterns themselves. Approval joins the list because on
# a branch that required an approving review it would be a merge by another name. Reading a pull request's
# reviews is refused only in the bump's own scope: the receiver's poller GETs them on purpose (issue #66),
# and a GET there is not the hazard.
#
# grep exits 0 on a match, 1 on none and 2 on an error, a missing path among them. Only 1 is clean: an `if grep`
# would read a renamed or deleted path in scope as "no match" and pass. worker/test/no-automatic-merge.test.mjs
# runs this script on a planted tree and on the real one.
runtime=(worker/src image/runner receiver/src)
bump=(.github/workflows/pi-bump.yml .github/scripts)
found=0
check() {
set +e
grep -rn --exclude-dir=node_modules "$@"
rc=$?
set -e
if [ "$rc" -ne 1 ]; then found=1; fi
}
check -E 'pulls\.merge|gh[[:space:]]+pr[[:space:]]+merge|mergePullRequest|merge_pull_request|/pulls/.*/merge|glab mr merge|merge_requests/[^ ]*/merge|acceptMergeRequest' "${runtime[@]}" "${bump[@]}"
check -iE 'auto[-_]?merge' "${runtime[@]}" "${bump[@]}"
check -E 'gh[[:space:]]+pr[[:space:]]+review.*(--approve|[[:space:]]-a([[:space:]]|$))|addPullRequestReview|createReview|event"?[[:space:]]*[=:]?[[:space:]]*"?APPROVE' "${runtime[@]}" "${bump[@]}"
check -E '/pulls/[^ ]*/reviews' "${bump[@]}"
if [ "$found" -ne 0 ]; then
echo "::error::A merge or approval symbol is listed above, or a path in scope is missing. CONST-MERGE-NEVER-AUTOMATIC forbids any merge call, and the scope must exist to be checked."
exit 1
fi
echo "OK: no merge or approval symbols (both forges, and the pi bump workflow)"
- name: No test may steal the runner's stdout
run: |
# Issue #266, and "grep is the test" again. `node --test` runs each file in a CHILD PROCESS that
# serialises its own results over `process.stdout`. A test helper that replaces
# `process.stdout.write` and holds the replacement across an `await` destroys whatever result
# frames flush in that window: three tests in worker/test/start-wiring.test.mjs were reported as
# never existing at all -- no name, no count, `fail 0`, exit code 0.
#
# The tests still RAN, and a failure in one still turned the build red -- as `not ok <file>` with
# no test name, which is the part that makes a red build unreadable.
#
# Every entry point takes an injected `write`, so there is no legitimate reason left to reassign
# the global and this grep carries no allowlist. stderr is deliberately not matched: it is not the
# channel the runner reports on.
if grep -rnE '^[^/]*process\.stdout\.write *=' worker/test receiver/test admin/test image/runner/test; then
echo "::error::A test reassigns process.stdout.write. Inject the code under test's own `write` seam instead (issue #266)."
exit 1
fi
echo "OK: no test steals the runner's results channel"
contract-tests:
name: pinned assumptions still hold (offline, no API key)
runs-on: ubuntu-latest
# Issue #657: a hung test once held this job for five hours, Actions' own cap being six. A healthy run takes about
# fifteen minutes; forty-five leaves room for a slow runner and still frees the check the same hour.
timeout-minutes: 45
services:
# A real Valkey backs the queue integration test (enqueue + dedup). AOF is not needed for a
# test run; the point is a genuine Redis-protocol server, not the fake.
valkey:
image: valkey/valkey:8
ports:
- 6379:6379
options: >-
--health-cmd "valkey-cli ping" --health-interval 10s --health-timeout 3s --health-retries 5
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc" # pi requires >=22.19.0; engine-strict enforces it
- run: npm ci --no-audit --no-fund
# A second, not two minutes: the fast half of issue #293's guard, so an author who paired a dated
# fixture with a default clock hears about it before the suite has finished starting.
- name: No dated fixture rides a default clock (issue #293)
run: node .github/scripts/dated-fixture-check.mjs
# A revision row that renders with the wrong number of cells loses everything after the second one,
# and a diff cannot show that: the row looks whole in the source and is cut on the page (issue #388).
- name: Every revision row renders as two cells (issue #388)
run: node .github/scripts/revision-row-check.mjs
# A rebase once committed conflict markers into a spec's revision table, and every other guard was green: they
# render as plain text and no test reads a spec's history (PR #457). The worker suite runs the same scan.
- name: No merge-conflict marker in a tracked file (PR #457)
run: node .github/scripts/conflict-marker-check.mjs
# The other half of issue #351's pair. This one is the fast hint: it sees a CALL SHAPE, so it
# names the file and the fix in a second. It cannot see a missing cleanup at all -- delete an
# after() hook and it stays green -- which is what the $TMPDIR count below is for.
- name: No test makes an OS temp directory it never removes (issue #351)
run: node .github/scripts/temp-dir-check.mjs
# The scariest assertions in this project are FREE: every trap lives at the resource-loader
# boundary (pure) or at before_agent_start (which fires strictly before any provider HTTP
# call). No API key, no tokens, no flake, no excuse not to run them on every build.
#
# PI_DISPATCH_REQUIRE_*_TESTS=1 turns a skip into a hard failure. A skipped assertion is an
# UNVERIFIED assertion, and "skipped = pass" is precisely the reasoning that lets a
# guardrail-less agent ship green. VALKEY_TEST_URL activates the queue integration test.
#
# Cron/scheduler assumptions this run pins (BullMQ 5.80.4), each SILENT if it drifts:
# - no-backfill / no-overlap / deterministic repeat: jobId -> specs/design.md:206-216
# - must-handle -10/-11 (schedule edit else silent no-op) -> specs/design.md:231
# - scheduler jobs bypass maxStalledCount (stall carve-out) -> specs/constitution.md:203-216
# Exercised by worker/test/cron.integration.test.mjs (VALKEY_TEST_URL + PI_DISPATCH_REQUIRE_WORKER_TESTS gate it live).
- name: Contract tests -- guardrails, -nc, exit codes, env allowlist, queue, receiver enqueue, cron (all required)
env:
PI_DISPATCH_REQUIRE_LOADER_TESTS: "1"
PI_DISPATCH_REQUIRE_WORKER_TESTS: "1"
PI_DISPATCH_REQUIRE_RECEIVER_TESTS: "1"
VALKEY_TEST_URL: "redis://127.0.0.1:6379"
run: |
# Issue #351's ORACLE, and the reason the grep guard one step up is not enough: that one sees
# a call shape, this one measures the defect. A test file that makes a temp directory and never
# removes it leaves entries here whatever the call looked like, so a helper that forgets to
# register, or an after() hook someone deletes, fails HERE and nowhere else. Measured before
# this was armed: one local run left 954 entries, and this job runs the suite three times.
#
# ON ITS OWN TMPDIR, and that is not tidiness. The bare runner default is /tmp, which npm,
# docker and the runner's own tooling also write to while the suite runs, so counting there
# would make a REQUIRED check flake on entries no test made. A fresh directory the suite alone
# writes to makes the count exact and the failure honest.
#
# A FAILING SUITE SKIPS THIS COUNT, deliberately and unavoidably: the default shell is `bash -e`,
# so a non-zero `npm test` ends the step before the count runs. The build is red either way, and
# counting leftovers from a run that did not finish would say nothing about cleanup.
#
# ONLY THIS RUN IS MEASURED, of the three this job makes and the fourth in release.yml. That is
# enough for DETECTION: all four run the same suite, so a test that leaks leaks here. Wrapping
# the others would buy disk on the runner, which dies with the job, and cost another export
# per step.
export TMPDIR="${RUNNER_TEMP:-${TMPDIR:-/tmp}}/suite-tmpdir"
rm -rf "$TMPDIR"
mkdir -p "$TMPDIR"
npm test
# Two fixed-name caches are made by TOOLING rather than by any test: `node-compile-cache` is
# Node's own, and `jiti` is the transpiler pi's node_modules brings in for the admin tests.
# Excluded by EXACT NAME, which is why this is not the kind of allowlist `test-count-check.mjs`
# warns erodes: `mkdtemp` always appends random characters, so no test directory can ever
# match one of these, and the list cannot grow to cover test output by accident.
left=$(ls -A "$TMPDIR" | grep -vxE 'jiti|node-compile-cache' | wc -l)
echo "entries left in the suite's own TMPDIR: $left"
if [ "$left" -gt 0 ]; then
ls -A "$TMPDIR" | grep -vxE 'jiti|node-compile-cache' | head -20
echo "::error::The suite left $left entries in its own TMPDIR. Every test directory must come from the workspace helper (import { tempDir } from \"./helpers/temp-dir.mjs\"), which removes them in an after() hook. See issue #351."
exit 1
fi
# The suite above can report success while silently losing tests, which is the failure issue #266
# was: `fail 0`, exit 0, and three tests missing from the output entirely. This compares what
# `node --test` reports against what the in-process runner actually runs, per file. It runs the suite
# a second time (~2 min) and that is the price of the count meaning something.
- name: Every declared test is actually reported (issue #266)
env:
PI_DISPATCH_REQUIRE_LOADER_TESTS: "1"
PI_DISPATCH_REQUIRE_WORKER_TESTS: "1"
PI_DISPATCH_REQUIRE_RECEIVER_TESTS: "1"
VALKEY_TEST_URL: "redis://127.0.0.1:6379"
run: node .github/scripts/test-count-check.mjs
# Issue #293. A test that judges a fixed fixture against an un-injected clock passes until the wall
# clock drifts past that subject's own window, then fails in CI on a tree nobody touched -- issue
# #284 was exactly that, and `main` froze until it was fixed. Running the suite 399 days from now
# converts that latent fuse into a failure on the commit that introduces it.
#
# A STEP rather than a job, deliberately: a new job is a new required-check CONTEXT, which only a
# branch-protection edit can make blocking, and until someone remembers to make it so the guard is
# advisory. Here it blocks on day one at no protection cost.
#
# Blocking rather than `continue-on-error`, for this workflow's own stated reason two steps up:
# "skipped = pass is precisely the reasoning that lets a guardrail-less agent ship green". An
# advisory red nobody must fix is that reasoning wearing a different hat.
#
# Cost, stated: this is the suite's third full run in this job (the count check runs it twice).
# Measured green in this posture at +399 days before it was armed.
- name: The suite survives a clock 399 days from now (issues #284, #293)
env:
PI_DISPATCH_REQUIRE_LOADER_TESTS: "1"
PI_DISPATCH_REQUIRE_WORKER_TESTS: "1"
PI_DISPATCH_REQUIRE_RECEIVER_TESTS: "1"
VALKEY_TEST_URL: "redis://127.0.0.1:6379"
TZ: "UTC"
NODE_OPTIONS: "--import file://${{ github.workspace }}/.github/scripts/shift-clock.mjs"
run: |
if ! npm test; then
echo "::error::A test failed only because the wall clock moved 399 days forward. That is issue #284's class: a fixed instant in the test and a subject built on the DEFAULT Date.now, so the fixture and the clock drift apart until the subject's own window closes over it. Two correct fixes: pass an injected clock to the subject (now: () => AT), or stamp the fixture through the same clock the subject reads. worker/test/run-mirror.test.mjs is the worked example (AT + anchored). Do NOT move the fixture date forward, which re-arms the fuse."
exit 1
fi
image:
name: the job image holds its contract
runs-on: ubuntu-latest
# CORE vs RUNNER, and the line matters. CORE is what ANY image must satisfy to be nameable in a
# trigger's `run.image` -- it lives in image/verify-image.sh, which this job calls, so the portable
# checklist has ONE definition rather than a copy here and a copy in the docs. RUNNER asserts properties
# of the runner THIS repo ships (its exact refusal strings, a path under /app a foreign image need not
# have) and stays here, because it is not portable. That distinction is the difference between
# "conformant" and "built from this repo", which docs/job-image.md and OQ-012 both have to draw.
#
# An operator verifying THEIR image runs the script directly, on the host that holds it. It cannot be a
# workflow input: jobs launch with --pull=never, so the images pi-dispatch can run are exactly the ones
# on the worker's own machine, and a GitHub runner has no access to those.
env:
IMAGE_REF: pi-job:ci
steps:
- uses: actions/checkout@v4
- name: Build
run: docker build -f image/Dockerfile -t pi-job:ci .
# --- CORE: nothing may make docker fetch an image (INT-CONTAINER-RUNTIME-CONTRACT) ---
# The argv-side proof is the frozen-intent deepEqual in worker/test/docker-run.test.mjs, which runs in
# contract-tests. THIS step asserts docker's own behaviour, and exists only because a flag an older
# docker accepts and silently disregards looks identical to a working one -- the exact silent class
# this workflow is for. Same register as the PI_OFFLINE probe further down.
- name: "CORE: --pull=never is honoured, not silently ignored"
run: |
if docker run --rm --pull=never pi-dispatch-definitely-not-present:ci true 2>/dev/null; then
echo "::error::docker ran an absent image under --pull=never -- the flag is being ignored."
exit 1
fi
echo "OK: --pull=never refuses an image that is not local"
# --- INT-SDK-SESSION-OPTIONS ---
# `pi --mode print` does not exist: --mode accepts text|json|rpc, and --print/-p is a separate
# boolean. --help rather than a real prompt, because a real prompt needs a paid API key and a
# contract test that costs money is a contract test that gets disabled.
# --entrypoint is required: ENTRYPOINT is the runner and ignores CMD, so `docker run img pi ...`
# would silently run the runner instead of pi and the assertion would test nothing.
# The CORE checklist, from the single definition an operator also runs against their own image
# (docs/job-image.md). Kept as a script rather than inlined steps so the portable half cannot drift
# from what the docs promise: pi present, CapEff=0, non-root, /job:ro enforced by the kernel, a
# writable agent dir, guardrails present AND unwritable, Chromium rendering a real page, fonts, and
# for an image claiming anyUid a writable home and a render as an arbitrary uid (issue #341), and for one
# claiming costCap an offline job with a fake key under a cap of 0 stopped before its first call (issue #501), and
# for one claiming modelPolicy an offline job whose list lacks its model stopped before its first call (issue #502).
- name: "CORE: the conformance checklist any nameable image must pass"
# VERIFY_REQUIRE_LANDLOCK (issue #683): this runner's kernel has Landlock, so pi-landlock's confinement must be
# PROVED here, never skipped as it is on a host without Landlock (Docker Desktop's LinuxKit kernel).
run: VERIFY_REQUIRE_LANDLOCK=1 ./image/verify-image.sh "$IMAGE_REF"
# --- INT-CONTAINER-RUNTIME-CONTRACT (costCap, issue #501) ---
# The checklist above proves a CLAIMED capability; it cannot notice one this image stopped claiming. Dropping
# `costCap` from the Dockerfile label would otherwise pass every check here, and the worker reads the label to
# decide whether a cost-capped job may run on this image (refusing it as job-image-cost-cap-unsupported when the token
# is missing), so the token this repo's image must carry is bound here.
- name: "RUNNER: the built image declares costCap"
run: |
docker image inspect --format '{{index .Config.Labels "dev.pi-dispatch.capabilities"}}' "$IMAGE_REF" | tr ',' '\n' | grep -qx "costCap" \
|| { echo "::error::the built image's capabilities label does not declare costCap -- a cost-capped job could not be admitted on this image."; exit 1; }
echo "OK: the label declares costCap, and the CORE checklist ran its proof"
# --- INT-CONTAINER-RUNTIME-CONTRACT (modelPolicy, issue #502) ---
# Same reason as costCap above: the checklist proves a claimed token and cannot notice a dropped one, and the
# worker sends PI_ALLOWED_MODELS only to an image that declares modelPolicy, so this repo's image must carry it.
- name: "RUNNER: the built image declares modelPolicy"
run: |
docker image inspect --format '{{index .Config.Labels "dev.pi-dispatch.capabilities"}}' "$IMAGE_REF" | tr ',' '\n' | grep -qx "modelPolicy" \
|| { echo "::error::the built image's capabilities label does not declare modelPolicy -- a job with an allowed-model list could not be admitted on this image."; exit 1; }
echo "OK: the label declares modelPolicy, and the CORE checklist ran its proof"
# --- INT-RUNNER-EXIT-CODE-PROTOCOL (exitAuth, issue #545) ---
# Same reason again: an image that dropped exitAuth would still pass, and the worker would quietly go back to
# reading an exit line the job's own tools can forge. The CORE checklist proves the claim when it is made.
- name: "RUNNER: the built image declares exitAuth"
run: |
docker image inspect --format '{{index .Config.Labels "dev.pi-dispatch.capabilities"}}' "$IMAGE_REF" | tr ',' '\n' | grep -qx "exitAuth" \
|| { echo "::error::the built image's capabilities label does not declare exitAuth -- the worker would read an exit line the job's tools can forge."; exit 1; }
echo "OK: the label declares exitAuth, and the CORE checklist ran its proof"
# --- INT-CONTAINER-RUNTIME-CONTRACT (mcp, REQ-MCP-OPERATOR-SERVERS) ---
# Same reason: the worker refuses every run.mcp job on an image without the token, so this repo's image must carry
# it, and the CORE checklist's offline stub-server job is what proves it.
- name: "RUNNER: the built image declares mcp"
run: |
docker image inspect --format '{{index .Config.Labels "dev.pi-dispatch.capabilities"}}' "$IMAGE_REF" | tr ',' '\n' | grep -qx "mcp" \
|| { echo "::error::the built image's capabilities label does not declare mcp -- a job whose trigger names MCP servers could not be admitted on this image."; exit 1; }
echo "OK: the label declares mcp, and the CORE checklist ran its proof"
# --- INT-CONTAINER-RUNTIME-CONTRACT (forgeMcp, DES-FORGE-MCP) ---
# Same reason again: a github or gitlab trigger naming `forge` in run.mcp is refused on an image without the
# token, and the CORE checklist runs both baked servers offline as the job user to prove it.
- name: "RUNNER: the built image declares forgeMcp"
run: |
docker image inspect --format '{{index .Config.Labels "dev.pi-dispatch.capabilities"}}' "$IMAGE_REF" | tr ',' '\n' | grep -qx "forgeMcp" \
|| { echo "::error::the built image's capabilities label does not declare forgeMcp -- a job whose trigger names the forge's own MCP server could not be admitted on this image."; exit 1; }
echo "OK: the label declares forgeMcp, and the CORE checklist ran its proof"
# --- INT-RUNNER-EXIT-CODE-PROTOCOL (resources and the supervisor, issue #596) ---
# Two runs, each handed a key. The first is the real offline job (a cap of 0 stops it before any call, nothing is
# dialled) at the job's real 4g: its signed exit line must carry `resources` with a peak above 0, read from the
# job's own cgroup. The second keeps the image's real entrypoint and supervisor but mounts a stand-in over
# run-job.mjs that allocates and fills anonymous memory far past a 64m limit, so the kernel kills it for memory,
# and the supervisor between the init and the runner must survive that (the runner's tree has the top OOM score)
# and write the signed line itself: `code: 137, reason: "oom-killed"`, `by: "supervisor"`, `oomKills` above 0
# and `memPeak` at 90% of the limit or more (the worker confirms an OOM only there). That line is the only thing
# that tells the worker a 137 was memory and not something a retry could pass, on Docker and Podman alike.
# A stand-in and not the real runner at 64m (review of issue #596): near the limit with a cold page cache the
# kernel reclaims and refaults pi's own files instead of killing anything (measured: rootless Podman on Fedora
# still running at 100 s, Docker Desktop stopped at 90 s), so the real runner could hang the job to its timeout.
# Filled anonymous memory cannot be reclaimed without swap (`--memory-swap` equals `--memory`), so the kill is
# prompt and certain.
- name: "RUNNER: the exit line reports resources, and a runner killed for memory is reported (issue #596)"
run: |
JOB=$(mktemp -d)
echo "Reply with the single word ok." > "$JOB/prompt.md"
cat > "$JOB/grow.mjs" <<'GROW'
// Allocate and fill 16 MiB at a time and keep every block: anonymous memory, touched, never released.
const kept = [];
for (;;) kept.push(Buffer.allocUnsafe(16 * 1024 * 1024).fill(1));
GROW
chmod -R a+rX "$JOB"
key=$(od -An -N32 -tx1 /dev/urandom | tr -d ' \n')
run_keyed() {
printf '%s\n' "$key" | timeout 300 docker run -i --rm --init --network none --cap-drop=ALL --security-opt no-new-privileges \
--pids-limit=512 "$@" -v "$JOB:/job:ro" \
-e PI_PROVIDER=anthropic -e PI_MODEL=claude-sonnet-4-5-20250929 -e PI_MAX_TURNS=1 \
-e ANTHROPIC_API_KEY=sk-ant-not-a-real-key -e PI_MAX_COST_MICROS=0 -e PI_EXIT_AUTH=stdin -e PI_JOB_ID=r596 \
"$IMAGE_REF" 2>&1
}
signed_last() {
line=$(echo "$1" | grep '"event":"exit"' | tail -1)
mac=$(echo "$line" | sed -nE 's/.*,"auth":"([0-9a-f]{64})"\}$/\1/p')
body=$(echo "$line" | sed -E 's/,"auth":"[0-9a-f]{64}"\}$/}/')
want=$(printf '%s' "$body" | openssl dgst -sha256 -hmac "$key" | awk '{print $NF}')
[ -n "$mac" ] && [ "$mac" = "$want" ] && echo "$body"
}
set +e
out=$(run_keyed --memory=4g --memory-swap=4g --cpus=2); code=$?
set -e
body=$(signed_last "$out") || { echo "$out" | tail -5; echo "::error::the job's last exit line is not signed with its key (exit $code)"; exit 1; }
echo "$body" | grep -Eq '"resources":\{"memPeak":[1-9][0-9]*,' \
|| { echo "$body"; echo "::error::the signed exit line carries no resources block with a peak: the record would say nothing about memory"; exit 1; }
set +e
out=$(run_keyed --memory=64m --memory-swap=64m -v "$JOB/grow.mjs:/app/image/runner/run-job.mjs:ro"); code=$?
set -e
rm -rf "$JOB"
[ "$code" = 137 ] || { echo "$out" | tail -5; echo "::error::at 64m the stand-in exited $code, want 137: it was not killed for memory, so this proves nothing"; exit 1; }
body=$(signed_last "$out") || { echo "$out" | tail -5; echo "::error::a runner killed for memory left no signed exit line: the supervisor did not survive it, did not write one, or the entrypoint does not start it"; exit 1; }
if ! echo "$body" | grep -q '"code":137,"reason":"oom-killed"' || ! echo "$body" | grep -q '"by":"supervisor"' || ! echo "$body" | grep -Eq '"oomKills":[1-9]'; then
echo "$body"; echo "::error::the supervisor's line does not say oom-killed, by the supervisor, with oomKills above 0"; exit 1
fi
# 90% of 64 MiB, rounded up, as the worker computes it (peakReachedLimit).
peak=$(echo "$body" | sed -nE 's/.*"memPeak":([0-9]+).*/\1/p')
[ "${peak:-0}" -ge 60397978 ] \
|| { echo "$body"; echo "::error::the supervisor's line reports a memPeak of ${peak:-none}, under 90% of the 64m limit: the worker would read this kill as the host's and retry it"; exit 1; }
echo "OK: resources on the signed line, and a runner killed for memory ends on a signed oom-killed line at its limit"
# --- DES-JOB-USER-INFERRED-READ-BACK-ON-REQUEST (issue #341) ---
# The runner's own user is uid 1001, the image's, which is exactly the one host shape that can never show
# the bug: every job mount works for it with no --user at all. So the check runs as a DIFFERENT uid, 1234
# in its own primary group and in the docker group, against this job's real daemon, which enforces
# bind-mount ownership. The script drives the real modules (the facts reader and resolver, the preparers
# and session store, the builder, the cleanup, doctor's decision and read-back) and the real runner, with no
# network and no key, and it refuses to pass unless its runner and read-back negative controls all ran and
# failed as they must. RUNNER, not CORE: it asserts this repository's runner and worker, not what any
# nameable image must do.
# PATH and the node binary are carried explicitly, because sudo's secure_path replaces PATH and setup-node's
# node lives in the tool cache, off it; -H gives pdjob its own HOME, so host git does not read the runner's.
- uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
- run: npm ci --no-audit --no-fund
- name: "RUNNER: every job mount works for a worker that is not uid 1001 (issue #341)"
run: |
sudo useradd -m --uid 1234 --user-group pdjob
sudo usermod -aG docker pdjob
sudo cp -a "$GITHUB_WORKSPACE" /srv/pd
sudo chown -R pdjob:pdjob /srv/pd
sudo install -d -o pdjob -g pdjob -m 0755 /srv/pd-jobs
sudo -H -u pdjob env "PATH=$PATH" PI_JOBS_DIR=/srv/pd-jobs "$(command -v node)" /srv/pd/.github/scripts/job-user-e2e.mjs "$IMAGE_REF"
# --- REQ-UPSTREAM-CONTRACT-TESTS (issue #587): the zero-spend provider smoke ---
# Every other in-image check runs offline against a fake provider. This one runs a real job through the entrypoint
# against Anthropic itself, with a key that has a real key's shape and is not one: it must reach the provider, get
# its 401, be metered on every method the meter wraps, exit 2 provider-auth-refused and cost $0, uncapped and
# under a dollar cap. That is what tells a pi bump (the automated ones from pi-bump.yml included) that the pinned
# pi still reaches a real provider through the meter. When a run fails, a control fetch from the same image with the
# same fake key decides: only Anthropic's 401 makes it the image's fault; a network error, a 5xx, a 529 or a 429
# fails as "infrastructure, re-run", never read as a verdict either way.
#
# A STEP IN A REQUIRED JOB, SO IT DECIDES FOR ITSELF WHETHER TO RUN. The job must always report, so it cannot be
# path-filtered; the smoke depends on a real provider, so a pull request that cannot move its result does not pay
# for it. It runs on a push to main, the schedule and a manual run, on the pi bump's own branch, and on a pull
# request that changes a pi pin site, the lockfile, image/, the smoke itself or this workflow, computed here against the base
# (fake-key-smoke.mjs's smokeWanted). On pull_request the checkout is the merge commit, so a diff against the
# base sha is exactly the pull request's change.
- name: "RUNNER: a fake-key job reaches the provider, is refused with a 401, is metered and costs nothing (issue #587)"
env:
BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: |
: > "$RUNNER_TEMP/changed.txt"
if [ "$GITHUB_EVENT_NAME" = "pull_request" ]; then
git fetch --no-tags --depth=1 origin "$BASE_SHA"
git diff --name-only --no-renames "$BASE_SHA" HEAD > "$RUNNER_TEMP/changed.txt"
fi
node .github/scripts/fake-key-smoke.mjs "$IMAGE_REF" --changed "$RUNNER_TEMP/changed.txt"
# --- INT-CONTAINER-JOB-INPUTS / REQ-GLOBAL-PI-OVERLAY ---
# A staged package that did not mount must be refused BEFORE any spend. This is the one failure
# that pi will not report for us: it SKIPS a local package source that does not resolve -- no
# error, no diagnostic -- so an unmounted package otherwise runs the flow to a clean exit 0
# without the tools it was written for, and reports success for work it could not have done.
#
# NO --entrypoint here, deliberately: the assertion is about the RUNNER, which is the entrypoint.
# The exit code alone would be vacuous (a missing /job/prompt.md is also exit 2), so the message
# is asserted too -- and the second run is the differential that proves the packages check fires
# FIRST, before the prompt is even read.
- name: "RUNNER: a staged package path that did not mount is refused pre-spend (exit 2)"
run: |
run_runner() {
docker run --rm --network none \
-e PI_PROVIDER=anthropic -e PI_MODEL=claude-sonnet-4-5-20250929 -e PI_MAX_TURNS=1 "$@" \
"$IMAGE_REF" 2>&1
}
set +e
out=$(run_runner -e PI_PACKAGES=/opt/pi-global/packages/definitely-not-mounted)
code=$?
set -e
echo "$out"
[ "$code" = "2" ] || { echo "::error::PI_PACKAGES pointing at a missing dir exited $code, want 2 (policy, not retried)."; exit 1; }
echo "$out" | grep -q "staged package path does not exist" \
|| { echo "::error::exit 2 came from something other than the staged-package check -- the assertion is vacuous."; exit 1; }
# Differential: with PI_PACKAGES unset the SAME invocation dies later, on the missing prompt.
# That is what shows the packages check runs before the job inputs are read, i.e. pre-spend.
set +e
out2=$(run_runner)
code2=$?
set -e
[ "$code2" = "2" ] || { echo "::error::control run exited $code2, want 2"; exit 1; }
echo "$out2" | grep -q "missing job input" \
|| { echo "::error::control run did not fail on the prompt -- the ordering claim is unproven."; exit 1; }
echo "OK: staged-package refusal precedes the job-input read"
# --- REQ-EGRESS-ALLOWLIST (issue #427) ---
# With egress armed the REAL runner's provider call must go through the proxy. It did not: loading pi replaced the
# env-proxy dispatcher NODE_USE_ENV_PROXY installs, so every egress-armed job went direct and died on its internal
# network, while every canary stayed green, because each was a plain fetch that never loads pi. This runs the
# entrypoint itself, with a key that is not a key, on an --internal network whose only other member is a stub
# proxy that logs each CONNECT and refuses it: nothing leaves the runner, and nothing is spent. The control run
# without NODE_USE_ENV_PROXY must reach no proxy at all, which is what shows the first run's CONNECT is the
# runner's restore and not the environment alone. Measured before the fix: the armed run's CONNECT never came.
- name: "RUNNER: with egress armed, the provider call goes through the proxy (issue #427)"
run: |
NET=pd-runner-proxy-check
PROXY=pd-runner-stub-proxy
docker network create --internal "$NET" >/dev/null
docker run -d --name "$PROXY" --network "$NET" --entrypoint node "$IMAGE_REF" -e '
require("http").createServer((q, s) => s.writeHead(405).end()).on("connect", (q, sock) => {
console.log("CONNECT " + q.url);
sock.end("HTTP/1.1 403 Forbidden\r\n\r\n");
}).listen(3128);' >/dev/null
JOB=$(mktemp -d)
echo "Reply with the single word ok." > "$JOB/prompt.md"
chmod -R a+rX "$JOB"
# Bounded, and it must reach the runner's own exit line: a container that never started (125, a name
# clash, the image) or a runner that hung would otherwise read as "no CONNECT", which the control below
# would take for success. A runner that refused before its first turn still prints an exit line; the two
# runs differ only in NODE_USE_ENV_PROXY, so such a refusal would silence the armed run too and fail above.
run_runner() {
out=$(timeout 300 docker run --rm --network "$NET" -v "$JOB:/job:ro" \
-e PI_PROVIDER=anthropic -e PI_MODEL=claude-sonnet-4-5-20250929 -e PI_MAX_TURNS=1 \
-e ANTHROPIC_API_KEY=sk-ant-not-a-real-key \
-e HTTPS_PROXY="http://$PROXY:3128" -e HTTP_PROXY="http://$PROXY:3128" -e NO_PROXY=localhost,127.0.0.1 "$@" \
"$IMAGE_REF" 2>&1) || true
echo "$out" | grep -q '"event":"exit"' \
|| { echo "$out" | tail -20; echo "::error::the runner did not run to its exit line ($*), so this run proves nothing either way."; exit 1; }
last_exit=$(echo "$out" | grep '"event":"exit"' | tail -1)
}
run_runner -e NODE_USE_ENV_PROXY=1
armed_exit=$last_exit
armed=$(docker logs "$PROXY" 2>&1)
run_runner
control=$(docker logs "$PROXY" 2>&1)
# And the stub was still listening through the control, or its silence is a dead proxy's.
[ "$(docker inspect -f '{{.State.Running}}' "$PROXY")" = "true" ] \
|| { echo "::error::the stub proxy stopped during the check, so the control run's silence proves nothing."; exit 1; }
docker rm -f "$PROXY" >/dev/null
docker network rm "$NET" >/dev/null
echo "$armed"
# The runner's own exit line says WHY when it is not the proxy: on an SELinux host, for one, an unlabelled /job
# stops the runner at `job-inputs-unreadable` before any request (a local rerun there needs `:Z` on the mount).
echo "$armed" | grep -qx "CONNECT api.anthropic.com:443" \
|| { echo "$armed_exit"; echo "::error::with egress armed the runner's provider call never reached the proxy (its exit line is above); every egress-armed job dies at its first turn."; exit 1; }
[ "$control" = "$armed" ] \
|| { echo "::error::the control run without NODE_USE_ENV_PROXY reached the proxy too, so the armed run proves nothing about the restore."; exit 1; }
echo "OK: the armed runner's provider call went through the proxy, and the unarmed one did not"
# --- INT-TRIGGERS-FILE-CONTRACT (run.excludeTools, issue #291) ---
# A name the pinned pi would silently ignore must be refused BEFORE any spend, in-container too:
# pi matches excludeTools entries by exact name or `*` pattern, so an unknown name is a no-op with no
# diagnostic -- the job would run WITH the tool the trigger says to remove and record a clean
# exit. The worker's loader makes this branch unreachable for worker-built containers; this step
# is the end-to-end proof that the ENTRYPOINT wires the env var to the refusal for the skew and
# hand-run cases. The exit code alone would be vacuous (the packages step above says why), so
# the message is asserted too -- and the valid-exclusion run is the differential proving a legal
# denylist parses clean and the run proceeds to the NEXT gate (the missing prompt), i.e. the
# refusal is the membership check and it sits pre-spend. The inspect at the end is the one place
# in CI that binds the Dockerfile's capability token: without it, dropping `excludeTools` from
# the label is caught only by every flagged job refusing in production -- loudly, but paid-for.
- name: "RUNNER: an exclusion the pinned pi does not know is refused pre-spend (exit 2)"
run: |
run_runner() {
docker run --rm --network none \
-e PI_PROVIDER=anthropic -e PI_MODEL=claude-sonnet-4-5-20250929 -e PI_MAX_TURNS=1 "$@" \
"$IMAGE_REF" 2>&1
}
set +e
out=$(run_runner -e PI_EXCLUDE_TOOLS=not-a-tool)
code=$?
set -e
echo "$out"
[ "$code" = "2" ] || { echo "::error::PI_EXCLUDE_TOOLS=not-a-tool exited $code, want 2 (policy, not retried)."; exit 1; }
echo "$out" | grep -q "is not a tool the pinned pi knows" \
|| { echo "::error::exit 2 came from something other than the exclusion membership check -- the assertion is vacuous."; exit 1; }
# Differential: a VALID exclusion dies later, on the missing prompt -- the membership check
# passed it and the run reached the next gate, which is the pre-spend ordering claim.
set +e
out2=$(run_runner -e PI_EXCLUDE_TOOLS=bash,edit)
code2=$?
set -e
[ "$code2" = "2" ] || { echo "::error::valid-exclusion control exited $code2, want 2"; exit 1; }
echo "$out2" | grep -q "missing job input" \
|| { echo "::error::a valid exclusion did not reach the job-input read -- over-validation, or the ordering claim is unproven."; exit 1; }
docker image inspect --format '{{index .Config.Labels "dev.pi-dispatch.capabilities"}}' "$IMAGE_REF" | grep -q "excludeTools" \
|| { echo "::error::the built image's capabilities label does not declare excludeTools -- the worker would refuse every flagged job on this image."; exit 1; }
echo "OK: unknown exclusions refuse pre-spend, valid ones pass, the label declares the capability"
# --- INT-CONTAINER-RUNTIME-CONTRACT (PI_OFFLINE=1) ---
# pi's resolver shells out to a REAL `npm install` for any npm:/git: source unless offline mode
# is on, and ~/.pi/agent IS writable in the container -- so without this flag an unresolved
# source is a live network install of third-party code at agent runtime. With it,
# resolvePackageSources takes the `installMissing() -> false -> continue` branch: a clean skip.
#
# ONLY the PI_OFFLINE=1 half is asserted, on purpose. The obvious negative half (same run with
# the variable unset, expecting failure) would be VACUOUS here: under `--network none` that run
# fails because the network is off, not because pi refused, so a green would be asserting that
# docker's --network none works. Distinguishing the two would mean asserting on npm's retry
# timing or its error text, neither of which is a contract we can pin. The other direction --
# unset, WITH a network -- is exactly the live third-party install this flag exists to prevent,
# so it is not something to run in CI either. The real enforcement is that the worker sets
# PI_OFFLINE=1 on every job and the runner re-asserts it in-process before the loader is built.
#
# The three suppression flags MIRROR image/runner/src/loader.mjs (noContextFiles:false,
# noExtensions:false, noSkills:true) rather than suppressing everything, so this probe exercises
# the configuration a job actually runs. They had drifted to all-true, which was the old posture
# (CONST-NO-CONTEXT-FILES-MANDATORY, since amended); the probe still passed, which is exactly the
# problem -- a probe that green-lights a config nothing uses proves nothing about the one that does.
#
# Syncing them does NOT weaken what is asserted, because the npm: source does not travel through
# the flag: resolveExtensionSources reads additionalExtensionPaths into cliExtensionPaths BEFORE
# the branch, and noExtensions only decides whether DISCOVERED paths merge in afterwards. But a
# bare `count === 0` WOULD have become weaker -- with discovery on it also asserts "nothing was
# discovered anywhere", which is true here (the image's ~/.pi/agent is empty and /tmp has no .pi/)
# and would silently stop being the thing under test the day either changed. So the assertion is
# a DIFFERENCE against the same loader without the npm: source: whatever discovery contributes,
# it contributes to both, and only the unresolvable source can move the number.
- name: "RUNNER: PI_OFFLINE=1 turns an unresolvable npm: source into a clean skip"
run: |
PROBE='import { DefaultResourceLoader, SettingsManager, getAgentDir } from "@earendil-works/pi-coding-agent";
const build = (additionalExtensionPaths) => new DefaultResourceLoader({
cwd: "/tmp", agentDir: getAgentDir(), settingsManager: SettingsManager.inMemory({}),
noContextFiles: false, noSkills: true, noExtensions: false,
additionalExtensionPaths,
});
const count = async (paths) => { const l = build(paths); await l.reload(); return l.getExtensions().extensions.length; };
const baseline = await count([]);
const withSource = await count(["npm:@pi-dispatch/definitely-does-not-exist"]);
if (withSource !== baseline) { console.error("the unresolvable npm: source changed the loaded set: " + baseline + " -> " + withSource); process.exit(1); }
console.log("OK: offline skip, nothing installed (loaded " + baseline + " either way)");'
docker run --rm --network none -e PI_OFFLINE=1 -e PROBE="$PROBE" \
--entrypoint sh "$IMAGE_REF" -c 'cd /app && node --input-type=module -e "$PROBE"' \
|| { echo "::error::PI_OFFLINE=1 did not make an unresolvable npm: source a clean skip -- pi may have tried to install at load time."; exit 1; }
# --- DES-USAGE-METER-VIA-API-PROVIDER-REGISTRY (trap (g) in INT-SDK-SESSION-OPTIONS) ---
# The meter's compat half must bind pi-coding-agent's OWN pi-ai: that is the pi-ai pi hands its
# extensions, whose module-level api-provider registry legacy extension calls dispatch through, and a
# copy pi does not import meters nothing while reporting success. Since the pi 0.99.1 pin (issue #509)
# a session's own calls go through ModelRuntime instead, so the process-wide choke point is
# ModelRuntime.prototype; the step after this one asserts that half.
#
# What this step deliberately does NOT assert is a layout. Up to pi 0.99.1 pi-coding-agent's shrinkwrap
# nested pi-ai under it; pi 1.0.1 dropped the shrinkwrap, and at the 1.0.3 pin (issue #587) pi-ai sits at
# /app/node_modules beside it. resolvePiAiCompat finds pi's copy by pi-coding-agent's own node_modules
# lookup (piOwnPackageDir), whatever the layout, and image/runner/test/pinned-api.test.mjs pins that the
# first candidate IS the module pi's VIRTUAL_MODULES hands an extension.
#
# So assert what IS load-bearing here, and assert it by running OUR OWN resolver inside the built
# image rather than re-deriving the layout in shell -- a shell re-derivation encodes a rule the
# runner does not follow, since resolvePiAiCompat wraps BOTH lookups in tryResolve and skips an
# unresolvable candidate instead of throwing. No `cd /app`: the import is an absolute file:// URL
# and resolvePiAiCompat resolves from usage-meter.mjs's own URL, which is what lets the real runner
# work from cwd=/workspace.
- name: "RUNNER: the meter's resolver offers pi-coding-agent's own pi-ai first, inside the image"
run: |
PROBE='import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { resolvePiAiCompat } from "file:///app/image/runner/src/usage-meter.mjs";
const candidates = resolvePiAiCompat();
if (candidates.length === 0) { console.error("resolvePiAiCompat() offered NO pi-ai compat candidate at all"); process.exit(1); }
if (candidates[0].tag !== "pi") { console.error("first candidate is tagged " + candidates[0].tag + ", want pi"); process.exit(1); }
if (!existsSync(fileURLToPath(candidates[0].url))) { console.error("the candidate pi itself uses is not on disk"); process.exit(1); }
const mod = await import(candidates[0].url);
for (const fn of ["getApiProvider", "getApiProviders", "registerApiProvider", "resetApiProviders"]) {
if (typeof mod[fn] !== "function") { console.error("the compat copy pi itself uses no longer exports " + fn + "()"); process.exit(1); }
}
console.log("OK: " + candidates.length + " candidate(s), the copy pi itself uses first, compat surface intact");'
docker run --rm --network none -e PROBE="$PROBE" \
--entrypoint sh "$IMAGE_REF" -c 'node --input-type=module -e "$PROBE"' \
|| { echo "::error::The usage meter no longer offers pi-coding-agent's OWN pi-ai first in this image. That copy is the pi-ai pi hands its extensions, whose registry the meter's compat half arms for legacy extension calls; the process-wide choke point is ModelRuntime.prototype, and installProcessUsageMeter accepts the compat copy by identity with pi's VIRTUAL_MODULES, so this step is about the ORDER and the surface, not the decision. If the layout moved, re-verify in this order: piOwnPackageDir and resolvePiAiCompat in image/runner/src/usage-meter.mjs, the compat exports the probe and the wrapper call (getApiProvider/getApiProviders/registerApiProvider/resetApiProviders, createAssistantMessageEventStream), trap (g) in specs/interfaces.md, and image/runner/test/pinned-api.test.mjs."; exit 1; }
# The process-wide half (issue #509): since the pi 0.99.1 pin a session dispatches every model call through
# a ModelRuntime instance, so the meter wraps ModelRuntime.prototype, and the brake's stream factory
# comes from the accepted compat copy. Asserted inside the built image: the install must succeed, accept
# pi's own compat copy, and have a brake for a capped job.
- name: "RUNNER: the usage meter installs on ModelRuntime inside the image, compat half on pi's own pi-ai"
run: |
PROBE='import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { createUsageMeter, installProcessUsageMeter } from "file:///app/image/runner/src/usage-meter.mjs";
const logged = [];
const handle = await installProcessUsageMeter({ ModelRuntime, meter: createUsageMeter({ maxTokens: 1000 }), log: (event, fields) => logged.push({ event, ...fields }) });
handle.uninstall();
const line = logged.find((l) => l.event === "usage_meter");
if (!handle.ok || line?.compat !== "pi" || line?.brake !== true) { console.error(JSON.stringify(logged)); process.exit(1); }
console.log("OK: " + JSON.stringify(line));'
docker run --rm --network none -e PROBE="$PROBE" \
--entrypoint sh "$IMAGE_REF" -c 'cd /app && node --input-type=module -e "$PROBE"' \
|| { echo "::error::The usage meter did not install on ModelRuntime with its compat half and brake inside the image."; exit 1; }
# Issue #500 part C: a pi child in a job meters itself. The runner points every descendant at a ledger directory and
# at the child preload (NODE_OPTIONS); a pi child then loads child-meter.ts first and writes its usage to its own
# ledger file. Asserted inside the image, as an arbitrary uid (the anyUid job user), because three things only
# the image can show: the preload and the .ts meter are readable by any uid, pi's loader transforms the meter with
# /app read-only, and `pi` on PATH (the bundle, through the .bin link) is recognised by realpath. Offline: the
# provider is an extension that answers every call itself.
- name: "RUNNER: a pi child started from PATH meters itself into the ledger, as an arbitrary uid"
run: |
PROVIDER='import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
export default function (pi) {
pi.registerProvider("fake", { baseUrl: "http://127.0.0.1:1", apiKey: "k", api: "fake-api",
models: [{ id: "m1", name: "m1", api: "fake-api", reasoning: false, input: ["text"], cost: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 }, contextWindow: 100000, maxTokens: 4096 }],
streamSimple(model) { const s = createAssistantMessageEventStream(); queueMicrotask(() => s.push({ type: "done", reason: "stop", message: { role: "assistant", content: [{ type: "text", text: "ok" }], api: model.api, provider: model.provider, model: model.id, usage: { input: 500, output: 277, cacheRead: 0, cacheWrite: 0, totalTokens: 777, cost: { input: 0.0005, output: 0.000277, cacheRead: 0, cacheWrite: 0, total: 0.000777 } }, stopReason: "stop", timestamp: Date.now() } })); return s; } });
}'
docker run --rm --network none --user 4242:4242 -e HOME=/tmp -e PROVIDER="$PROVIDER" \
--entrypoint sh "$IMAGE_REF" -c '
set -eu
cd /tmp
printf "%s\n" "$PROVIDER" > /tmp/fake-provider.ts
L=$(mktemp -d /tmp/pi-dispatch-meter-XXXXXX)
PI_CODING_AGENT_DIR=$(mktemp -d) PI_OFFLINE=1 PI_DISPATCH_CHILD_LEDGER="$L" NODE_OPTIONS=--import=file:///app/image/runner/src/child-preload.mjs \
pi --mode json -p --no-session -e /tmp/fake-provider.ts --model fake/m1 hello < /dev/null > /dev/null
node --input-type=module -e "
import { foldChildLedgers } from \"file:///app/image/runner/src/usage-meter.mjs\";
const fold = foldChildLedgers({ dir: process.argv[1] });
const [entry] = fold.files.values();
if (fold.processes !== 1 || fold.unmetered !== 0 || fold.totals.total !== 777 || entry?.state !== \"done\") { console.error(JSON.stringify({ processes: fold.processes, unmetered: fold.unmetered, totals: fold.totals, state: entry?.state })); process.exit(1); }
console.log(\"OK: one metered child ledger, 777 tokens, done\");" "$L"' \
|| { echo "::error::A pi child in the image did not meter itself into the ledger. Check, in order: child-preload.mjs and child-meter.ts are in the image and readable by any uid, image/runner/src/child-preload.mjs still recognises the package bin by realpath, and child-meter.ts still gets the child's own pi-ai and ModelRuntime from pi's loader (image/runner/test/child-meter.integration.test.mjs runs the same route outside the image)."; exit 1; }
# Issue #500 part D: a NESTED runner (pi's stock subagent example spawns `node run-job.mjs <pi args>`) runs as the pi
# CLI, metered into the job runner's ledger, and never as a second job. Asserted inside the image, as an arbitrary
# uid, under the runner's own exec-only node, the way the example spawns it (process.execPath): one ledger of 777
# tokens, `done`, and no exit line on stdout. With no ledger directory it exits 2 before pi runs.
- name: "RUNNER: a nested runner runs as the pi CLI and meters itself into the inherited ledger, as an arbitrary uid"
run: |
PROVIDER='import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
export default function (pi) {
pi.registerProvider("fake", { baseUrl: "http://127.0.0.1:1", apiKey: "k", api: "fake-api",
models: [{ id: "m1", name: "m1", api: "fake-api", reasoning: false, input: ["text"], cost: { input: 1, output: 1, cacheRead: 0, cacheWrite: 0 }, contextWindow: 100000, maxTokens: 4096 }],
streamSimple(model) { const s = createAssistantMessageEventStream(); queueMicrotask(() => s.push({ type: "done", reason: "stop", message: { role: "assistant", content: [{ type: "text", text: "ok" }], api: model.api, provider: model.provider, model: model.id, usage: { input: 500, output: 277, cacheRead: 0, cacheWrite: 0, totalTokens: 777, cost: { input: 0.0005, output: 0.000277, cacheRead: 0, cacheWrite: 0, total: 0.000777 } }, stopReason: "stop", timestamp: Date.now() } })); return s; } });
}'
docker run --rm --network none --user 4242:4242 -e HOME=/tmp -e PROVIDER="$PROVIDER" \
--entrypoint sh "$IMAGE_REF" -c '
set -eu
cd /tmp
printf "%s\n" "$PROVIDER" > /tmp/fake-provider.ts
L=$(mktemp -d /tmp/pi-dispatch-meter-XXXXXX)
rc=0
PI_DISPATCH_RUNNER_PID=1 PI_DISPATCH_CHILD_LEDGER=/tmp/gone /opt/pi-dispatch/runner-node /app/image/runner/run-job.mjs -p hello < /dev/null > /dev/null 2>&1 || rc=$?
[ "$rc" = 2 ] || { echo "a nested runner with no ledger exited $rc, not 2"; exit 1; }
PI_CODING_AGENT_DIR=$(mktemp -d) PI_OFFLINE=1 PI_DISPATCH_RUNNER_PID=1 PI_DISPATCH_CHILD_LEDGER="$L" NODE_OPTIONS=--import=file:///app/image/runner/src/child-preload.mjs \
/opt/pi-dispatch/runner-node /app/image/runner/run-job.mjs --mode json -p --no-session -e /tmp/fake-provider.ts --model fake/m1 hello < /dev/null > /tmp/nested.out
if grep -q "\"event\":\"exit\"" /tmp/nested.out; then echo "a nested runner wrote an exit line"; exit 1; fi
grep -q "\"type\":\"agent_end\"" /tmp/nested.out || { echo "a nested runner did not run as the pi CLI"; exit 1; }
ls /tmp | grep -c "^pi-dispatch-meter-" | grep -qx 1 || { echo "a nested runner made a ledger directory of its own"; exit 1; }
node --input-type=module -e "
import { foldChildLedgers } from \"file:///app/image/runner/src/usage-meter.mjs\";
const fold = foldChildLedgers({ dir: process.argv[1] });
const [entry] = fold.files.values();
if (fold.processes !== 1 || fold.unmetered !== 0 || fold.totals.total !== 777 || entry?.state !== \"done\") { console.error(JSON.stringify({ processes: fold.processes, unmetered: fold.unmetered, totals: fold.totals, state: entry?.state })); process.exit(1); }
console.log(\"OK: a nested runner is one metered child ledger, 777 tokens, done\");" "$L"' \
|| { echo "::error::A nested runner in the image did not run as a metered pi CLI. Check, in order: run-job.mjs calls isNestedRunner before anything else, child-route.mjs runAsPiCli loads pi's dist/cli.js, and child-preload.mjs gives a nested runner the pi CLI's route (image/runner/test/child-meter.integration.test.mjs runs the same route outside the image)."; exit 1; }
# Issue #500 part E: the parent's children hook in the image, with real pi children, as an arbitrary uid. The test
# file ships in the image (image/runner is copied whole). On Linux its detector tests run too, so `skipped 0` is the
# proof that /proc was read: a pi child whose spawner cleared the environment is found and counted unmetered.
- name: "RUNNER: the parent folds a pi child's ledger, stops it at the cap and finds an unmetered pi child, as an arbitrary uid"
run: |
docker run --rm --network none --user 4242:4242 -e HOME=/tmp -e TMPDIR=/tmp -e PI_DISPATCH_REQUIRE_LOADER_TESTS=1 \
--entrypoint node "$IMAGE_REF" --test --test-reporter=tap /app/image/runner/test/child-watch.integration.test.mjs > watch.tap 2>&1 \
&& grep -qx "# fail 0" watch.tap && grep -qx "# skipped 0" watch.tap \
|| { cat watch.tap; echo "::error::The parent's children hook did not fold, stop or detect in the image. Check, in order: run-job.mjs hands createChildWatch to installProcessUsageMeter, child-watch.mjs still matches pi's titles (pi, pi-rpc) and entry files, and the child meter still reads STOP (image/runner/test/child-watch.test.mjs and child-watch.integration.test.mjs run the same rules outside the image)."; exit 1; }
admin-extension-canary:
# The admin extension is the default front door (issue #96): it is TESTED against an exact pi
# (SUPPORTED_PI_VERSION, locked to the admin devDependency pin by admin/test/load.test.mjs) but
# declares a "*" peer, because pi never installs peers for host-provided packages and an exact
# peer pin ERESOLVEs every plain-npm consumer whose pi differs by a patch -- the full reasoning
# lives in .github/scripts/admin-pi-canary.mjs's header. A wildcard peer without a canary is a
# hope; this job is the canary. It installs pi@latest into a SCRATCH dir and asserts the admin's
# pinned assumptions against THAT install: the pinned-api needle list, the USED_API members on
# ExtensionAPI, the runtime VERSION export, and an actual load-and-register of the built bundle.
#
# Failure protocol: a red run here means pi moved underneath the admin. Retest locally against
# the new pi, bump SUPPORTED_PI_VERSION and the admin devDependency pin TOGETHER (load.test.mjs
# locks them to each other), and republish @edgehero/pi-dispatch-admin.
name: the admin extension survives latest pi (canary)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
# NEVER install anything extra into the repo root. admin/test/load.test.mjs and
# admin/test/pinned-extension-api.test.mjs anchor on the HOISTED PIN the lockfile resolves; a
# root `npm install @earendil-works/pi-coding-agent@latest` would silently flip both from
# pinned-version assertions into HEAD assertions -- green-lighting exactly the drift they
# exist to catch. The @latest copy goes into a mktemp scratch dir outside the checkout.
- run: npm ci --no-audit --no-fund
- run: node admin/build.mjs
- name: Install pi@latest into a scratch dir (never the repo)
run: |
SCRATCH=$(mktemp -d)
echo "SCRATCH=$SCRATCH" >> "$GITHUB_ENV"
cd "$SCRATCH"
npm init -y >/dev/null
# Distinguish failures (release.yml's npm-view doctrine): an install error here is
# infrastructure -- network, registry, npm itself -- NOT a canary verdict about pi, and
# reading it as either "pi broke us" or "all clear" would be a guess. Fail loudly instead.
if ! npm install @earendil-works/pi-coding-agent@latest --no-audit --no-fund; then
echo "::error::npm install @earendil-works/pi-coding-agent@latest failed -- an infrastructure failure, not a pi-drift verdict. Re-run before reading anything into it."
exit 1
fi
node -p "'canary pi: ' + require('$SCRATCH/node_modules/@earendil-works/pi-coding-agent/package.json').version"
- name: Run the canary against the scratch pi
run: |
# The bundle is copied INTO the scratch dir so its bare pi import resolves against
# scratch/node_modules -- ESM resolves bare specifiers from the importing file's own
# location, which is what keeps the repo's hoisted pin out of the probe entirely. The
# canary script re-copies fresh and asserts (a)-(d); see its header for the mechanics.
cp admin/dist/index.mjs "$SCRATCH/admin-bundle.mjs"
node .github/scripts/admin-pi-canary.mjs "$SCRATCH"
mcp-canary:
# The job's MCP (REQ-MCP-OPERATOR-SERVERS) rests on pi internals the runner drives directly: the MCP extension's
# events and options, the transports' options and exit hook, the connect order its readiness follows
# (image/runner/src/mcp.mjs). pinned-api.test.mjs and mcp.test.mjs hold those at the PIN, inside the required
# checks; this job runs the same two files against pi@LATEST, through a resolve hook that sends only the
# @earendil-works scope to a scratch install (.github/scripts/mcp-pi-canary.mjs), so a pi release that moves any of
# it shows up here before the daily bump pull request does.
#
# NOT a required check, deliberately: its red is drift in a release this repository does not run yet, and a
# required check could then block every unrelated pull request until the bump is done. Failure protocol: read the
# failing assertion, then fix image/runner/src/mcp.mjs (or the pin's copy) on the bump pull request, never the
# assertion. An install failure is infrastructure and says nothing either way.
name: the job's MCP survives latest pi (canary)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
# The repository's own install, as every other job has it; pi@latest goes into a scratch dir outside it.
- run: npm ci --no-audit --no-fund
- name: Install pi@latest into a scratch dir (never the repo)
run: |
SCRATCH=$(mktemp -d)
echo "MCP_CANARY_PI=$SCRATCH" >> "$GITHUB_ENV"
cd "$SCRATCH"
npm init -y >/dev/null
if ! npm install @earendil-works/pi-coding-agent@latest --no-audit --no-fund; then
echo "::error::npm install of pi@latest failed -- an infrastructure failure, not a pi-drift verdict. Re-run before reading anything into it."
exit 1
fi
node -p "'canary pi: ' + require('$SCRATCH/node_modules/@earendil-works/pi-coding-agent/package.json').version"
- name: Run the job's MCP tests against the scratch pi
env:
PI_DISPATCH_REQUIRE_LOADER_TESTS: "1"
run: |
node --import ./.github/scripts/mcp-pi-canary.mjs --test image/runner/test/mcp.test.mjs \
|| { echo "::error::The job's MCP does not hold against pi@latest: read the failing test, then fix image/runner/src/mcp.mjs on the bump pull request."; exit 1; }
node --import ./.github/scripts/mcp-pi-canary.mjs --test image/runner/test/codemode-models.integration.test.mjs \
|| { echo "::error::codemode's models.* no longer reach the runner's meter on pi@latest: read the failing test before the bump pull request turns them on."; exit 1; }
# A renamed pin would match no name below and pass with nothing run, so the count is asked too.
node --import ./.github/scripts/mcp-pi-canary.mjs --test --test-reporter=tap --test-name-pattern="MCP|McpExtensionOptions|transports the runner|isTransientMcpError|pi-mcp is the copy|connect runs in the order" image/runner/test/pinned-api.test.mjs > pins.tap \
|| { cat pins.tap; echo "::error::A pi internal the job's MCP rests on moved in pi@latest: read the failing pin, re-read that pi code, and fix the runner (or the pin's copy) on the bump pull request."; exit 1; }
PASSED=$(sed -n 's/^# pass //p' pins.tap)
if [ "${PASSED:-0}" -lt 6 ]; then cat pins.tap; echo "::error::Only ${PASSED:-0} of the MCP pins ran: a pin was renamed past the pattern above. Update the pattern."; exit 1; fi
host-pi-canary:
# `worker/src/host-pi.mjs` MIRRORS private pi internals (issue #102): where `pi install` puts a
# package, and whether `pi config` disabled a resource. pi exports no public answer to either, which
# is the residual recorded as OQ-018. `worker/test/host-pi.pinned.test.mjs` gates the PINNED version
# inside contract-tests as a hard failure; this job is the advance warning against `latest`, and both
# read one shared needle list (PINNED_PI_NEEDLES) so the gate and the canary cannot drift apart.
#
# Its OWN job rather than a step on the admin canary, deliberately. Sharing that job made a run named
# "the admin extension survives latest pi" go red for a reason that had nothing to do with the admin
# extension, which is a mislabelled signal however accurate the annotation underneath it was. Separate
# jobs also mean an admin failure no longer hides this result, and vice versa.
#
# Failure protocol: drift here is a `::warning::` and the job stays GREEN, because nothing shipped is
# broken -- the worker pins pi, and the pinned test is what fails when that pin is wrong. A RED run
# means the canary could not execute at all (pi did not install), which is infrastructure and says
# nothing either way about drift. See the script header for the full reasoning.
name: host-pi mirrors survive latest pi (canary)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: ".nvmrc"
# No `npm ci`: this job reads two source files and one scratch install, and never imports the
# repo's own pinned pi -- installing it would be the drift-hiding mistake the admin canary's own
# comment warns about.
- name: Install pi@latest into a scratch dir (never the repo)
run: |
SCRATCH=$(mktemp -d)
echo "SCRATCH=$SCRATCH" >> "$GITHUB_ENV"
cd "$SCRATCH"
npm init -y >/dev/null
# Same doctrine as the admin canary: an install error is infrastructure, NOT a drift verdict.
if ! npm install @earendil-works/pi-coding-agent@latest --no-audit --no-fund; then
echo "::error::npm install @earendil-works/pi-coding-agent@latest failed -- an infrastructure failure, not a pi-drift verdict. Re-run before reading anything into it."
exit 1
fi
node -p "'canary pi: ' + require('$SCRATCH/node_modules/@earendil-works/pi-coding-agent/package.json').version"
- name: "pi's package-install paths and enablement grammar still read the way host-pi.mjs assumes"
run: node .github/scripts/host-pi-canary.mjs "$SCRATCH"