adcf56f829
Version audit for the next release. pi 0.84.4 -> 0.85.1, deliberately skipping 0.85.0 (it published internal experimental code and broke SDK imports, upstream #9132). mempalace 3.8.0 -> 3.9.0. pi-atelier v0.10.0 -> v0.10.1. PI_STUDIO_VERSION relabelled none -> v0.9.60-rc.0 so the floating main ref's RC status is visible at docker-inspect time instead of discovered later. PI_FORK_REF stays floating and adopts e69725c. The pi bump was verified by running it under a pty in five combinations rather than by reading the changelog, because this repo has already shipped a version pair no changelog flagged (atelier < 0.7.1 hangs pi >= 0.84). CPU delta 0.00-0.01s over 5s against a ~5s sustained-CPU hang signature, two-sided via the atelier sidebar painting identically to the 0.84.4 control. NODE_VERSION stays 22 on purpose: node 24 is technically safe (pi's five prebuilt addons are all NAPI, nothing declares a ceiling, agent-browser's engines.node >=24 is vestigial for the shipped aarch64 ELF), but this release already moves two minors and bakes an RC, and a node major would leave four suspects if the image misbehaves. Own release, smoke suite as the gate. Also corrects a stale claim at the mempalace ARG: synlig serves 3.8.0 server-side, not 3.7.1 (measured over ssh 2026-09-06). agent-browser volume shadowing: the image has shipped 0.35.2, but every session on mbp-m1-2020 ran 0.27.0 from a 2026-07-17 hand-install in ~/.pi/npm-global (a VOLUME, at PATH position 2 vs /usr/bin at 8). Third package hit by this hazard after pi and pi-atelier, so the guard is now generalised: entrypoint-user.sh retires the copy by moving it aside (reversible, only when the image ships its own), recreate-sanity-check.sh asserts resolution under /usr where the volume is real, smoke-test.sh carries the build-time half and says in the source why it is weak. The real damage was the stale BUNDLED SKILL (3 skillsets/17.6 KB vs 8/31.5 KB, ten subcommands undocumented to the agent) - a stale tool errors, a stale skill quietly teaches wrong commands. pi-fork capability floor (extensions: []): forks were measured across four dispatches ignoring their brief, answering in the user's voice, fabricating self-referential measurements, and once filing a diary entry as agent_name=pi. Cause is upstream by design - the child gets getHeader()+getBranch(), the whole active session branch, with the brief as the final user message. Not a model-capability problem: the same model as the fast profile obeyed the identical brief perfectly with a fresh session and no inherited context. extensions: [] runs children with --no-extensions, so the mempalace bridge is absent and palace writes are impossible by construction (verified by asking a child to enumerate its tools: read, bash, edit, write). Removes palace writes, not filesystem writes.
786 lines
46 KiB
Bash
Executable File
786 lines
46 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# smoke-test.sh — sanity checks for the pi-devbox image
|
||
#
|
||
# Usage: ./scripts/smoke-test.sh <image>
|
||
#
|
||
# Verifies:
|
||
# - pi binary present and (if EXPECTED_PI_VERSION set) matches CI's resolved version
|
||
# - mempalace core matches the audited pin (if EXPECTED_MEMPALACE_VERSION set)
|
||
# - new v1.0.0 base additions (pandoc, graphviz, imagemagick, yq, tealdeer)
|
||
# - typst PDF engine for pandoc (v1.4.0) — `pandoc --pdf-engine=typst`
|
||
# - non-modal editors nano + micro (alongside nvim)
|
||
# - terminfo for modern emulators: xterm-kitty, xterm-ghostty, wezterm,
|
||
# alacritty, foot (kitty-terminfo + ncurses-term + compiled ghostty alias)
|
||
# - tmux 0-indexing baked in /etc/tmux.conf (required for pi-studio variants)
|
||
# - pi-toolkit cloned at /opt/pi-toolkit
|
||
# - pi-extensions cloned at /opt/pi-extensions
|
||
# - pi-atelier vendored at /opt/pi-atelier, registered from /opt (not npm:),
|
||
# and >= the version floor pi's TUI requires (see the floor test)
|
||
# - pi-fork + pi-observational-memory cloned with node_modules baked
|
||
# - entrypoint deploys pi-toolkit keybindings symlink
|
||
# - entrypoint deploys ≥4 extensions
|
||
# - mempalace bridge symlink present
|
||
# - settings.json bootstrapped
|
||
# - pi-fork + pi-observational-memory registered in settings.json packages[]
|
||
# via `pi install`
|
||
# - pi-devbox-version command present + wraps the build manifest correctly
|
||
# (human, --json, --quiet)
|
||
# - (studio variant only, auto-detected) pi-studio cloned + prebuilt
|
||
# client bundle present + registered via `pi install`
|
||
# - image size within threshold
|
||
|
||
set -euo pipefail
|
||
|
||
IMAGE="${1:?usage: $0 <image>}"
|
||
PASS=0; FAIL=0
|
||
# pi-devbox v1.0.0 (decoupled from opencode-devbox) added pandoc, graphviz,
|
||
# imagemagick, yq, tealdeer, a baked /etc/tmux.conf, and the non-modal
|
||
# editors nano + micro (~15 MB combined). v1.6.0 baked in agent-browser +
|
||
# Playwright Chromium (~291 MB net after dropping the unused headless-shell
|
||
# build), which lifted the baseline. CI amd64 actuals observed on run 512
|
||
# (v1.6.1): 3411 MB non-studio, 3574 MB studio. Threshold below carries
|
||
# ~225 MB margin above the studio number to absorb minor arch/build-cache
|
||
# differences and small future growth without false reds, while still
|
||
# catching an unexpected +GB regression.
|
||
SIZE_THRESHOLD_MB=3800
|
||
|
||
# On failure, surface the last few lines the command produced. This used to
|
||
# discard output entirely (`>/dev/null 2>&1`), which made a red ❌ carry zero
|
||
# diagnostic weight: explaining the single v1.8.0 stage-default failure took a
|
||
# full CI-log dig plus a registry-config inspection, when the container had
|
||
# already printed the answer and thrown it away. Assertions that want a
|
||
# diagnostic just echo it to stderr — it stays hidden while they pass.
|
||
run() {
|
||
local label="$1"; local cmd="$2"
|
||
local out
|
||
if out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd" 2>&1); then
|
||
printf " ✅ %s\n" "$label"; PASS=$((PASS+1))
|
||
else
|
||
printf " ❌ %s\n" "$label"; FAIL=$((FAIL+1))
|
||
# `if`, not `&&` — a trailing false under `set -e` would abort the script.
|
||
if [ -n "$out" ]; then
|
||
printf " └─ %s\n" "$(printf '%s' "$out" | tail -3 | tr '\n' ' ' | cut -c1-300)"
|
||
fi
|
||
fi
|
||
}
|
||
|
||
# Stricter version of `run` that asserts an expected substring in stdout.
|
||
# Catches the "image bytes silently identical to previous release" class of
|
||
# regression — Docker layer cache hit on `npm install -g <pkg>` because the
|
||
# bare command string is identical across builds, even when `latest` would
|
||
# resolve differently. Discovered 2026-05-23 — every pi-devbox release
|
||
# v0.74.0..v0.75.5 had been shipping the same image bytes.
|
||
run_expect() {
|
||
local label="$1"; local cmd="$2"; local expect="$3"
|
||
local out
|
||
out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd" 2>&1) || true
|
||
if echo "$out" | grep -Fq "$expect"; then
|
||
printf " ✅ %s (got %s)\n" "$label" "$expect"; PASS=$((PASS+1))
|
||
else
|
||
printf " ❌ %s — expected substring %q, got: %s\n" "$label" "$expect" "$out"; FAIL=$((FAIL+1))
|
||
fi
|
||
}
|
||
|
||
echo "=== pi-devbox smoke test: $IMAGE ==="
|
||
echo ""
|
||
|
||
# ── Binaries ─────────────────────────────────────────────────────────
|
||
echo "── Binaries ──"
|
||
if [ -n "${EXPECTED_PI_VERSION:-}" ]; then
|
||
run_expect "pi version matches build arg" "pi --version" "$EXPECTED_PI_VERSION"
|
||
else
|
||
run "pi" "pi --version"
|
||
fi
|
||
run "node" "node --version"
|
||
run "git" "git --version"
|
||
run "aws" "aws --version"
|
||
run "uv" "uv --version"
|
||
run "nvim" "nvim --version"
|
||
run "nano" "nano --version"
|
||
run "micro" "micro --version"
|
||
run "kitty-terminfo" "infocmp -x xterm-kitty >/dev/null 2>&1"
|
||
run "terminfo: modern emulators (ncurses-term)" 'for t in wezterm alacritty foot ghostty st-256color; do infocmp -x "$t" >/dev/null 2>&1 || exit 1; done'
|
||
run "terminfo: xterm-ghostty alias (tic)" "infocmp -x xterm-ghostty >/dev/null 2>&1"
|
||
run "nvim true-colour default (sysinit.vim)" "nvim --headless -c 'lua os.exit(vim.o.termguicolors and 0 or 1)'"
|
||
run "mempalace-mcp" "mempalace-mcp --help"
|
||
run "mempalace-pi-session on PATH" "mempalace-pi-session --help"
|
||
# The staging dir must sit next to the palace, not in a disposable cache: the
|
||
# palace keys per-source dedup on the STAGED path, so a stage that can be wiped
|
||
# while the palace survives lets `mempalace sync` prune every drawer mined from
|
||
# it. Assert the resolved default, not an env var — the guarantee is "stage
|
||
# shares the palace's lifetime", which an ENV pin would quietly break.
|
||
# NOTE: --sessions-dir gets an EMPTY temp dir, never /tmp. The stage banner is
|
||
# printed before any export, so nothing needs to be found — and pointing a
|
||
# default-staged run at a populated dir would export whatever transcripts it
|
||
# finds into the real stage, which is how a synthetic test session ends up
|
||
# staged for mining as if it were a real conversation.
|
||
#
|
||
# Asserted $HOME-RELATIVE, not against a literal /home/developer. `run` invokes
|
||
# `docker run --entrypoint=""`, and neither Dockerfile sets USER or ENV HOME
|
||
# (HOME is set by entrypoint-user.sh, which --entrypoint="" deliberately skips),
|
||
# so these assertions execute as root with HOME=/root. The original literal
|
||
# /home/developer form could therefore never match and failed the v1.8.0
|
||
# release — a test bug, not a product one: the stage resolution was correct all
|
||
# along, it just follows $HOME. The invariant under test ("the stage sits beside
|
||
# the palace, sharing its lifetime") is user-independent, so pinning the user
|
||
# was never part of it. A cache-dir default still fails the pattern below, which
|
||
# is the regression this guards.
|
||
#
|
||
# It went unnoticed for three days because this workflow only triggers on
|
||
# `push: tags: v*` — the assertion was added on a main push, so v1.8.0 was its
|
||
# first execution ever. Use the `smoke_only` workflow_dispatch input to run
|
||
# smoke against HEAD without cutting a tag.
|
||
run "pi stage defaults next to the palace (not a cache dir)" '
|
||
out=$(mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true
|
||
stage=$(echo "$out" | grep -oE "stage=[^ ]+" | head -1)
|
||
echo "resolved ${stage:-<no stage= line>} with HOME=$HOME" >&2
|
||
case "$stage" in
|
||
"stage=$HOME/.mempalace/pi-stage/"*) exit 0 ;;
|
||
*) exit 1 ;;
|
||
esac
|
||
'
|
||
# Companion to the above: the deployment-specific case the literal assertion was
|
||
# reaching for, done properly by supplying the HOME the container actually runs
|
||
# with instead of assuming it.
|
||
run "pi stage is palace-adjacent for the developer user" '
|
||
out=$(HOME=/home/developer mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true
|
||
echo "$out" | grep -oE "stage=[^ ]+" | head -1 >&2
|
||
echo "$out" | grep -q "stage=/home/developer/.mempalace/pi-stage/"
|
||
'
|
||
run "pi stage follows MEMPALACE_PALACE_PATH" '
|
||
out=$(MEMPALACE_PALACE_PATH=/tmp/alt/.mempalace/palace \
|
||
mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true
|
||
echo "$out" | grep -q "stage=/tmp/alt/.mempalace/pi-stage/"
|
||
'
|
||
# The feeder's --agent default is WHO a drawer is attributed to. mempalace core
|
||
# records neither the machine nor the harness on a write, and one shared bearer
|
||
# token means the server cannot tell clients apart, so toolkit c64ffa1 changed
|
||
# this default from $USER to pi@$MEMPALACE_PI_DEVICE — the one string that makes
|
||
# a write attributable to both. Nothing ever PRINTED the resolved value (the
|
||
# banner shows mode= and stage= only), so an image built from a pre-c64ffa1
|
||
# toolkit ref would ship unattributed writes with every check still green.
|
||
#
|
||
# `--help` assigns AGENT (script top) before it parses args, then exits 0 with
|
||
# no side effects — so `bash -x` observes the REAL resolution, env interpolation
|
||
# and fallback included, rather than grepping the source for a literal line that
|
||
# any reformat would break. Two-sided on purpose: device set => pi@<device>;
|
||
# device UNSET => must not be pi@anything. The second half is what fails against
|
||
# the old unconditional $USER default, which ignored the device entirely.
|
||
#
|
||
# Probes the PATH entry (a symlink into the /opt clone) rather than that clone
|
||
# path directly: this is the invocation the systemd/launchd timers and
|
||
# entrypoint-user.sh actually use, so it is the default that reaches the palace.
|
||
run "feeder resolves --agent to pi@<device> (drawer attribution)" '
|
||
f=$(command -v mempalace-pi-session) || { echo "feeder not on PATH" >&2; exit 1; }
|
||
with=$(MEMPALACE_PI_DEVICE=smoke-device bash -x $f --help 2>&1 | sed -n "s/^+* *AGENT=//p" | tail -n1)
|
||
without=$(env -u MEMPALACE_PI_DEVICE bash -x $f --help 2>&1 | sed -n "s/^+* *AGENT=//p" | tail -n1)
|
||
echo "resolved with-device=[$with] without-device=[$without]" >&2
|
||
[ "$with" = "pi@smoke-device" ] || exit 1
|
||
case "$without" in pi@*) exit 1 ;; esac
|
||
echo ok
|
||
'
|
||
# Regression guard for the pi transcript exporter. If pi ever changes its
|
||
# session JSONL shape, the exporter stops recognising sessions and the palace
|
||
# silently gets nothing (or, worse, raw JSON chunked as prose). Feed it a
|
||
# synthetic session and assert it is actually exported. Uses --dry-run so no
|
||
# palace is touched, and a temp stage so nothing real is written.
|
||
run "pi transcript exporter recognises a pi session" '
|
||
set -e
|
||
d=$(mktemp -d); s="$d/sessions/--workspace--"; mkdir -p "$s"
|
||
{
|
||
printf "%s\n" "{\"type\":\"session\",\"version\":1,\"id\":\"smoke\",\"cwd\":\"/workspace\",\"timestamp\":\"2026-01-01T00:00:00Z\"}"
|
||
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"question one\"}}"
|
||
a=$(printf "a%.0s" $(seq 1 1200))
|
||
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"$a\"}]}}"
|
||
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"question two\"}}"
|
||
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"short reply\"}]}}"
|
||
} > "$s/2026-01-01T00-00-00-000Z_smoke.jsonl"
|
||
out=$(mempalace-pi-session --dry-run --sessions-dir "$d/sessions" --stage "$d/stage" 2>&1)
|
||
echo "$out" | grep -q "Exported 1 session"
|
||
'
|
||
# The same guard from the other side: a session with no real assistant output
|
||
# (an abandoned prompt, whose bulk is injected skill text) must NOT be filed.
|
||
run "pi transcript exporter rejects an abandoned session" '
|
||
set -e
|
||
d=$(mktemp -d); s="$d/sessions/--workspace--"; mkdir -p "$s"
|
||
{
|
||
printf "%s\n" "{\"type\":\"session\",\"version\":1,\"id\":\"smoke2\",\"cwd\":\"/workspace\",\"timestamp\":\"2026-01-01T00:00:00Z\"}"
|
||
u=$(printf "u%.0s" $(seq 1 13000))
|
||
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"$u\"}}"
|
||
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Ready. What would you like to work on?\"}]}}"
|
||
} > "$s/2026-01-01T00-00-00-000Z_smoke2.jsonl"
|
||
out=$(mempalace-pi-session --dry-run --sessions-dir "$d/sessions" --stage "$d/stage" 2>&1)
|
||
echo "$out" | grep -q "no sessions qualified"
|
||
'
|
||
# The remote-palace-without-inbox skip must ANNOUNCE itself, not vanish. This
|
||
# branch of entrypoint-user.sh runs at container start (not reachable from a
|
||
# `docker run` one-shot), so assert against the entrypoint that actually shipped
|
||
# in the image. Guards a silent regression back to the bare `:` no-op, which
|
||
# left a container contributing nothing to the palace with no artifact saying
|
||
# why — the log it would normally leave is written by the other branch.
|
||
run_expect "remote-palace-without-inbox skip is announced, not silent" \
|
||
"grep -o 'MemPalace catch-up skipped' /usr/local/bin/entrypoint-user.sh | head -1" \
|
||
"MemPalace catch-up skipped"
|
||
run "...and the skip notice names the variable that fixes it" \
|
||
"grep -A6 'MemPalace catch-up skipped' /usr/local/bin/entrypoint-user.sh | grep -q 'MEMPALACE_PI_SSH_TARGET'"
|
||
# A remote mine that FAILS must not report success. MCP answers a hard tool
|
||
# failure with HTTP 200 and the tool's own JSON escaped inside
|
||
# result.content[].text, so the feeder's old `'\"error\"' in body` check could
|
||
# never see it: on 2026-08-15 a mine that died with "source directory not found:
|
||
# '/data/feed/...'" logged "Done. Wing updated." and exited 0, and this
|
||
# container's transcripts were filed nowhere for a whole session. The feeder
|
||
# carries fixtures for that exact body; run them against the baked toolkit so a
|
||
# stale/reverted toolkit ref can't reintroduce a silent feed.
|
||
run "baked feeder detects a failed remote mine (no silent false success)" \
|
||
"mempalace-pi-session --self-test"
|
||
# v1.0.0 base additions — verify presence and basic functionality.
|
||
run "pandoc" "pandoc --version"
|
||
run "typst" "typst --version"
|
||
run "pandoc+typst PDF engine" "printf '# hi\n' | pandoc --pdf-engine=typst -o /tmp/_smoke.pdf - && test -s /tmp/_smoke.pdf; rm -f /tmp/_smoke.pdf"
|
||
run "graphviz (dot)" "dot -V"
|
||
run "imagemagick" "magick --version"
|
||
run "yq (mikefarah v4)" "yq --version | grep -qE 'mikefarah.*version v4'"
|
||
run "tldr (tealdeer)" "tldr --version"
|
||
run "socat" "socat -V"
|
||
run "studio-expose helper" "test -x /usr/local/bin/studio-expose"
|
||
run "image-baked pi-devbox-environment skill" \
|
||
"test -f /usr/local/share/pi-devbox/skills/pi-devbox-environment/SKILL.md"
|
||
run "image-baked credential-incident-response skill" \
|
||
"test -f /usr/local/share/pi-devbox/skills/credential-incident-response/SKILL.md"
|
||
run "global-AGENTS append snippet present" \
|
||
"test -f /usr/local/share/pi-devbox/pi-global-AGENTS.append.md"
|
||
run "pi-devbox block merged into 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" \
|
||
"grep -q 'load the mempalace skill' /opt/pi-toolkit/pi-global-AGENTS.md"
|
||
# Vendored fallback skills (so a no-skillset container still resolves the
|
||
# AGENTS.md 'read the pi-extensions skill' pointer).
|
||
run "image-baked pi-extensions fallback skill" \
|
||
"test -f /usr/local/share/pi-devbox/skills/pi-extensions/SKILL.md"
|
||
run "pi-extensions skill ships its helper" \
|
||
"test -f /usr/local/share/pi-devbox/skills/pi-extensions/evaluate-extension-usage.py"
|
||
run "image-baked mempalace fallback skill" \
|
||
"test -f /usr/local/share/pi-devbox/skills/mempalace/SKILL.md"
|
||
# Layered freshness: when the pinned pi-extensions clone carries the skill, the
|
||
# baked copy must be the fresh package copy (Option 1), not the stale snapshot.
|
||
run "pi-extensions skill refreshed from package when present" \
|
||
"if [ -f /opt/pi-extensions/skill/SKILL.md ]; then cmp -s /opt/pi-extensions/skill/SKILL.md /usr/local/share/pi-devbox/skills/pi-extensions/SKILL.md; else true; fi"
|
||
# Runtime ownership handover (v1.8.5): the baked links are a FALLBACK, and
|
||
# skillset-OWNED skills must be repointed at the live clone when one is mounted.
|
||
# The list is data, so assert its content, not just its presence: mempalace in,
|
||
# pi-extensions deliberately out (its skillset copy is a lagging duplicate).
|
||
run "devbox-skill-reconcile helper present + executable" \
|
||
"test -x /usr/local/bin/devbox-skill-reconcile"
|
||
run "skillset-owned list ships and names mempalace" \
|
||
"grep -qx 'mempalace' /usr/local/share/pi-devbox/skills/skillset-owned.txt"
|
||
run "skillset-owned list excludes pi-extensions (ownership)" \
|
||
"! grep -qx 'pi-extensions' /usr/local/share/pi-devbox/skills/skillset-owned.txt"
|
||
|
||
# ── tmux 0-indexing (required for pi-studio variants) ─────────────────
|
||
echo ""
|
||
echo "── tmux config ──"
|
||
run_expect "/etc/tmux.conf has base-index 0" \
|
||
"cat /etc/tmux.conf" "set -g base-index 0"
|
||
run_expect "/etc/tmux.conf has pane-base-index 0" \
|
||
"cat /etc/tmux.conf" "set -g pane-base-index 0"
|
||
|
||
# ── Repo clones ───────────────────────────────────────────────────────
|
||
echo ""
|
||
echo "── Repo clones ──"
|
||
run "pi-toolkit clone" "test -d /opt/pi-toolkit && git -C /opt/pi-toolkit rev-parse --short HEAD"
|
||
run "pi-extensions clone" "test -d /opt/pi-extensions && git -C /opt/pi-extensions rev-parse --short HEAD"
|
||
run "pi-fork clone + node_modules" \
|
||
"test -f /opt/pi-fork/package.json && test -d /opt/pi-fork/node_modules"
|
||
run "pi-observational-memory clone + node_modules" \
|
||
"test -f /opt/pi-observational-memory/package.json && test -d /opt/pi-observational-memory/node_modules"
|
||
# ...and that the clone carries the AUTH FIX, not merely that it exists. om's
|
||
# pre-flight hasUsableAuth() check silently disabled `recall` for ~8 weeks once
|
||
# pi moved to request-time SigV4 signing and stopped exposing a static Bedrock
|
||
# key; upstream fixed it in ce9fc98, adopted in v1.8.4. PI_OBSMEM_REF tracks
|
||
# master, so an upstream revert or force-push would ship a dead `recall` with
|
||
# the clone assertion above still green — the exact gap flagged as open in the
|
||
# v1.8.5 changelog.
|
||
#
|
||
# Pin the markers to src/runtime.ts, the fix SITE, rather than grepping the
|
||
# repo: two of these three strings also appear under tests/, so a repo-wide
|
||
# grep stays green with runtime.ts itself reverted. That is a false green of the
|
||
# same family as the old skill-snapshot canary.
|
||
run "pi-observational-memory carries the ce9fc98 auth fix (recall stays alive)" '
|
||
f=/opt/pi-observational-memory/src/runtime.ts
|
||
test -f "$f" || { echo "fix site missing: $f" >&2; exit 1; }
|
||
for m in availability_recheck providerCredentialConfigured hasConfiguredAuth; do
|
||
grep -q "$m" "$f" || { echo "marker absent from runtime.ts: $m" >&2; exit 1; }
|
||
done
|
||
echo ok
|
||
'
|
||
# pi-atelier: deliberately NO node_modules assertion, unlike its siblings —
|
||
# it declares zero runtime dependencies (only peerDeps, satisfied by the baked
|
||
# pi) and has no build step, so Dockerfile.variant skips `npm install` for it.
|
||
# Assert what pi actually loads instead: the entry point named by its
|
||
# package.json `pi.extensions` key.
|
||
run "pi-atelier clone + entry point" \
|
||
"test -f /opt/pi-atelier/package.json && test -f /opt/pi-atelier/extensions/index.ts"
|
||
|
||
# ── pi <-> pi-atelier compatibility floor (executable, not a comment) ──
|
||
# pi-atelier < 0.7.1 wraps pi's PRIVATE TUI renderer in a way that recurses
|
||
# under pi >= 0.84: pi hangs at startup burning CPU, with no error. Upstream
|
||
# fixed it in 0.7.1/0.7.2, but atelier's peerDependencies still say
|
||
# `>=0.80.7`, so neither npm nor pi can warn about the real floor. Both
|
||
# versions are pinned in Dockerfile.variant; this makes a bad PAIRING fail the
|
||
# build instead of publishing an image whose TUI never starts.
|
||
run_expect "pi-atelier >= 0.7.1 floor for pi >= 0.84 (startup-hang guard)" \
|
||
'ge() { [ "$(printf "%s\n%s\n" "$1" "$2" | sort -V | head -n1)" = "$2" ]; }; AV=$(jq -r ".version // empty" /opt/pi-atelier/package.json 2>/dev/null); PV=$(pi --version 2>/dev/null | grep -oE "[0-9]+\.[0-9]+\.[0-9]+" | head -n1); if [ -z "$AV" ] || [ -z "$PV" ]; then echo "unreadable versions (atelier=$AV pi=$PV)"; elif ge "$PV" 0.84.0 && ! ge "$AV" 0.7.1; then echo "VIOLATION: pi $PV with pi-atelier $AV"; else echo "compatible: pi $PV + pi-atelier $AV"; fi' \
|
||
"compatible:"
|
||
|
||
# pi-studio is present only in the :latest-studio variant. Auto-detect by
|
||
# probing /opt/pi-studio so this one script covers both variants.
|
||
if docker run --rm --entrypoint="" "$IMAGE" sh -c 'test -d /opt/pi-studio' >/dev/null 2>&1; then
|
||
STUDIO_VARIANT=1
|
||
echo " ℹ️ pi-studio detected — running studio assertions"
|
||
run "pi-studio clone + node_modules" \
|
||
"test -f /opt/pi-studio/package.json && test -d /opt/pi-studio/node_modules"
|
||
run "pi-studio prebuilt client bundle" \
|
||
"test -f /opt/pi-studio/client/studio-client.js"
|
||
else
|
||
STUDIO_VARIANT=0
|
||
echo " ℹ️ pi-studio not present (non-studio variant) — skipping studio clone checks"
|
||
fi
|
||
|
||
# ── Build provenance (manifest + OCI labels) ─────────────────────────
|
||
echo ""
|
||
echo "── Build provenance ──"
|
||
run "/etc/pi-devbox/build-manifest.json present" \
|
||
"test -f /etc/pi-devbox/build-manifest.json"
|
||
# These next checks replace three that grepped the manifest for the FIELD NAME
|
||
# and never looked at the value:
|
||
#
|
||
# run_expect "manifest records pi_version" "cat …manifest.json" '"pi_version"'
|
||
#
|
||
# which passes on {"pi_version": ""} and on {"pi_version": null}. The tell was
|
||
# visible in its own passing output — `✅ manifest records pi_version (got
|
||
# "pi_version")` echoes the key back as the thing it claims to have found.
|
||
# Two failure modes were therefore invisible: a key that survives with an empty
|
||
# or garbage value, and a key that vanishes from the manifest while every
|
||
# remaining value still looks fine.
|
||
#
|
||
# Those two need SEPARATE assertions, and the reason is a trap worth keeping in
|
||
# writing: an "every component value is a valid SHA" loop passes VACUOUSLY on
|
||
# components:{} — jq's all() over an empty list is true — so the value check
|
||
# alone would go green on a manifest that lost every component. Mutation-tested
|
||
# 2026-08-25 across nine fabricated manifests (empty map, deleted key, "",
|
||
# null, "unknown", 12-hex truncation, 40 non-hex chars, legit null pi-studio).
|
||
run "manifest declares every required component key" '
|
||
req="pi-toolkit pi-extensions pi-fork pi-observational-memory pi-atelier mempalace-toolkit pi-studio"
|
||
for k in $req; do
|
||
jq -e --arg k "$k" "(.components|has(\$k))" /etc/pi-devbox/build-manifest.json >/dev/null \
|
||
|| { echo "manifest lost component key: $k" >&2; exit 1; }
|
||
done
|
||
'
|
||
# Subsumes the old `! grep -q \"unknown\"` check ("unknown" is not 40-hex), and
|
||
# also catches "", null and truncated SHAs, which that grep let through. null is
|
||
# legitimate for pi-studio alone: the non-studio variant has no such clone.
|
||
run "manifest component values are resolved 40-hex commits" '
|
||
jq -e "
|
||
.components
|
||
| to_entries
|
||
| all(if .key == \"pi-studio\" and .value == null then true
|
||
else (.value|type) == \"string\" and (.value|test(\"^[0-9a-f]{40}\$\")) end)
|
||
" /etc/pi-devbox/build-manifest.json >/dev/null
|
||
'
|
||
# pi_version against ground truth, same shape as the mempalace check below.
|
||
# Chains with the "pi version matches build arg" assertion earlier in this file:
|
||
# together they tie build arg -> installed binary -> recorded manifest, so a
|
||
# manifest written from a stale variable cannot pass by agreeing with itself.
|
||
run "manifest pi_version matches the installed pi" '
|
||
m=$(jq -r ".pi_version // empty" /etc/pi-devbox/build-manifest.json)
|
||
b=$(pi --version 2>/dev/null | head -n1 | tr -d "\r")
|
||
echo "manifest=[$m] installed=[$b]" >&2
|
||
[ -n "$m" ] && [ "$m" = "$b" ]
|
||
'
|
||
# Top-level provenance fields: assert the SHAPE of each value, and only when the
|
||
# field is populated. source_revision and build_date legitimately default to
|
||
# empty (Dockerfile.variant ARGs) on a plain local `docker build`, so demanding
|
||
# them would fail honest local smoke runs; a populated-but-malformed value is
|
||
# the actual defect. release_tag defaults to "dev", so empty means a broken write.
|
||
run "manifest top-level fields are well-formed, not merely present" '
|
||
j=/etc/pi-devbox/build-manifest.json
|
||
t=$(jq -r ".release_tag // empty" $j)
|
||
r=$(jq -r ".source_revision // empty" $j)
|
||
d=$(jq -r ".build_date // empty" $j)
|
||
echo "release_tag=[$t] source_revision=[$r] build_date=[$d]" >&2
|
||
[ -n "$t" ] || { echo "release_tag empty (ARG default is dev)" >&2; exit 1; }
|
||
if [ -n "$r" ]; then
|
||
printf "%s" "$r" | grep -qxE "[0-9a-f]{40}" || { echo "source_revision not a 40-hex commit" >&2; exit 1; }
|
||
fi
|
||
if [ -n "$d" ]; then
|
||
printf "%s" "$d" | grep -qE "^[0-9]{4}-[0-9]{2}-[0-9]{2}T" || { echo "build_date not ISO-8601" >&2; exit 1; }
|
||
fi
|
||
'
|
||
# mempalace CORE was absent from the manifest through v1.8.5: the toolkit SHA
|
||
# was recorded but the palace version behind the MCP tools was not, so a palace
|
||
# bug could not be correlated to an image version. Assert the field exists AND
|
||
# equals the installed binary — recording it from ARG MEMPALACE_VERSION instead
|
||
# would look identical here yet drift silently the first time an install
|
||
# resolved to something other than the pin, which is the whole reason this file
|
||
# is built from ground truth. `// empty` matters: jq -r prints the 4-char
|
||
# string "null" for a JSON null, which would satisfy a naive -n test.
|
||
run "manifest mempalace_version matches the installed core" '
|
||
m=$(jq -r ".mempalace_version // empty" /etc/pi-devbox/build-manifest.json)
|
||
b=$(mempalace --version 2>/dev/null | head -n1 | tr -d "\r"); b=${b##* }
|
||
echo "manifest=[$m] installed=[$b]" >&2
|
||
[ -n "$m" ] && [ "$m" = "$b" ]
|
||
'
|
||
# ... and, when CI supplies it, that the installed core is the version CI
|
||
# actually AUDITED (published + not yanked on PyPI, in resolve-versions). This
|
||
# does NOT duplicate the check above, which compares two properties of one
|
||
# image and so cannot notice that BOTH are the wrong version. The live failure
|
||
# mode it covers: the variant builds `FROM` a base tag chosen by base-decide's
|
||
# content hash, so a bug in that hashing (the reason scripts/check-base-hash.sh
|
||
# exists) could reuse a cached base built from an OLDER MEMPALACE_VERSION pin —
|
||
# internally consistent, silently stale, invisible to every other assertion.
|
||
if [ -n "${EXPECTED_MEMPALACE_VERSION:-}" ]; then
|
||
run "installed mempalace matches CI's audited pin (${EXPECTED_MEMPALACE_VERSION})" "
|
||
b=\$(mempalace --version 2>/dev/null | head -n1 | tr -d '\r'); b=\${b##* }
|
||
echo \"installed=[\$b] audited_pin=[${EXPECTED_MEMPALACE_VERSION}]\" >&2
|
||
[ \"\$b\" = \"${EXPECTED_MEMPALACE_VERSION}\" ]
|
||
"
|
||
fi
|
||
# Every component must be a resolved commit (or null for pi-studio in the
|
||
# non-studio variant) — now enforced by the 40-hex value check above, which
|
||
# strictly subsumes the old whole-file grep for '"unknown"'. Only rev() ever
|
||
# emits "unknown" and rev() feeds components only, so nothing is lost.
|
||
# pi-devbox-version wraps the manifest into a human-first command; verify the
|
||
# binary is present, executable, and that all three output modes work.
|
||
run "pi-devbox-version binary present + executable" \
|
||
"test -x /usr/local/bin/pi-devbox-version"
|
||
run_expect "pi-devbox-version human output shows release tag" \
|
||
"pi-devbox-version" "pi-devbox "
|
||
# --json is a verbatim `cat` of the manifest, so "round-trips" is assertable
|
||
# literally. The old form grepped the output for the string "release_tag" — the
|
||
# key name again — which would pass on a truncated or re-serialised dump.
|
||
run "pi-devbox-version --json round-trips the manifest byte-for-byte" '
|
||
a=$(cat /etc/pi-devbox/build-manifest.json)
|
||
b=$(pi-devbox-version --json)
|
||
[ "$a" = "$b" ] || { echo "--json output differs from the manifest on disk" >&2; exit 1; }
|
||
'
|
||
run_expect "pi-devbox-version --quiet is a compact one-liner" \
|
||
"pi-devbox-version --quiet | wc -l" "1"
|
||
# ── Vendored skill snapshot provenance ─────────────────────────────────
|
||
# The vendored mempalace skill is the one baked artefact with no /opt clone
|
||
# behind it (private upstream — see VENDORED.md), so until now the manifest
|
||
# could not say which skillset commit it came from. Two fields now travel with
|
||
# it: the CLAIMED ref (ARG default in Dockerfile.variant) and the MEASURED
|
||
# sha256 of the shipped bytes. Assert both are well-formed, and — separately —
|
||
# that the measurement still describes the file in the image.
|
||
#
|
||
# Kept as two assertions for the same reason the component checks are: one
|
||
# proves the fields are not empty/garbage, the other proves they are not merely
|
||
# self-consistent. A single combined check could pass on a manifest whose hash
|
||
# was computed from a file that was later overwritten (the pi-extensions skill
|
||
# copy at Dockerfile.variant:165 does exactly that kind of overwrite, one stage
|
||
# earlier), which is the failure this second one exists to catch.
|
||
run "manifest records the vendored skill snapshot provenance" '
|
||
j=/etc/pi-devbox/build-manifest.json
|
||
r=$(jq -r ".skillset_snapshot_ref // empty" $j)
|
||
s=$(jq -r ".skillset_snapshot_tree_sha256 // empty" $j)
|
||
echo "ref=[$r] tree_sha256=[$s]" >&2
|
||
printf "%s" "$r" | grep -qxE "[0-9a-f]{40}" \
|
||
|| { echo "skillset_snapshot_ref is not a 40-hex commit" >&2; exit 1; }
|
||
printf "%s" "$s" | grep -qxE "[0-9a-f]{64}" \
|
||
|| { echo "skillset_snapshot_tree_sha256 is not a 64-hex digest" >&2; exit 1; }
|
||
'
|
||
# Recomputes over the whole DIRECTORY with the same tree_sha256() pipeline
|
||
# Dockerfile.variant used to measure it, not a plain `sha256sum SKILL.md` —
|
||
# a file-only compare here would pass even if the manifest recorded a
|
||
# fingerprint over a directory that has since grown a second file (this is
|
||
# not hypothetical: pi-extensions already ships two files for its skill).
|
||
run "manifest skill fingerprint matches the baked snapshot" '
|
||
j=/etc/pi-devbox/build-manifest.json
|
||
d=/usr/local/share/pi-devbox/skills/mempalace
|
||
m=$(jq -r ".skillset_snapshot_tree_sha256 // empty" $j)
|
||
a=$( (cd "$d" && find . -type f -print | LC_ALL=C sort | xargs -r sha256sum) | sha256sum | cut -d" " -f1)
|
||
echo "manifest=[$m] actual=[$a]" >&2
|
||
[ -n "$m" ] && [ "$m" = "$a" ]
|
||
'
|
||
# OCI labels live in the image config, not the container fs — inspect them
|
||
# from the host docker rather than via `docker run`.
|
||
LBL=$(docker inspect --format '{{ index .Config.Labels "se.jordbo.pi-devbox.pi-extensions-ref" }}' "$IMAGE" 2>/dev/null || true)
|
||
if [ -n "$LBL" ] && [ "$LBL" != "<no value>" ]; then
|
||
printf " ✅ OCI label se.jordbo.pi-devbox.pi-extensions-ref=%s\n" "$LBL"; PASS=$((PASS+1))
|
||
else
|
||
printf " ❌ OCI label se.jordbo.pi-devbox.pi-extensions-ref missing or empty\n"; FAIL=$((FAIL+1))
|
||
fi
|
||
|
||
# ── Runtime deployment (needs entrypoint to run) ──────────────────────
|
||
echo ""
|
||
echo "── Runtime deployment ──"
|
||
# Spin up a long-running container WITHOUT overriding the entrypoint, so
|
||
# the baked entrypoint chain (entrypoint.sh → entrypoint-user.sh) runs and
|
||
# deploys pi-toolkit + pi-extensions to ~/.pi/agent/. Override CMD to
|
||
# tail -f /dev/null so the container stays alive while we docker-exec.
|
||
CID=$(docker run -d --rm "$IMAGE" tail -f /dev/null)
|
||
cleanup() { docker rm -f "$CID" >/dev/null 2>&1 || true; }
|
||
trap cleanup EXIT
|
||
|
||
# Wait for entrypoint-user.sh to finish deploying pi-toolkit + extensions.
|
||
# Gate on BOTH the keybindings symlink (deployed by pi-toolkit) AND the
|
||
# mempalace.ts bridge (deployed last by entrypoint-user.sh) AND ≥4 *.ts
|
||
# extensions present. Parallel build load can otherwise sample the *.ts
|
||
# count mid-deploy and produce a flake. See opencode-devbox c6f9d11
|
||
# (2026-06-08) — same fix transplanted.
|
||
for i in $(seq 1 45); do
|
||
if docker exec "$CID" sh -c '
|
||
test -L /home/developer/.pi/agent/keybindings.json && \
|
||
test -L /home/developer/.pi/agent/extensions/mempalace.ts && \
|
||
test -L /home/developer/.agents/skills/pi-devbox-environment && \
|
||
test -L /home/developer/.agents/skills/pi-extensions && \
|
||
test -L /home/developer/.agents/skills/mempalace && \
|
||
count=$(ls -1 /home/developer/.pi/agent/extensions/*.ts 2>/dev/null | wc -l) && \
|
||
[ "$count" -ge 4 ]
|
||
' >/dev/null 2>&1; then
|
||
break
|
||
fi
|
||
sleep 1
|
||
done
|
||
|
||
exec_test() {
|
||
local label="$1"; local cmd="$2"
|
||
if docker exec -u developer "$CID" sh -c "$cmd" >/dev/null 2>&1; then
|
||
printf " ✅ %s\n" "$label"; PASS=$((PASS+1))
|
||
else
|
||
printf " ❌ %s\n" "$label"; FAIL=$((FAIL+1))
|
||
fi
|
||
}
|
||
|
||
exec_test "keybindings.json (pi-toolkit)" 'test -L $HOME/.pi/agent/keybindings.json && echo ok'
|
||
exec_test "extensions ≥ 4 (pi-extensions)" 'count=$(ls -1 $HOME/.pi/agent/extensions/*.ts 2>/dev/null | wc -l); [ $count -ge 4 ] && echo "$count extensions"'
|
||
exec_test "mempalace.ts bridge" 'test -L $HOME/.pi/agent/extensions/mempalace.ts && echo ok'
|
||
exec_test "settings.json bootstrapped" 'test -f $HOME/.pi/agent/settings.json && echo ok'
|
||
exec_test "pi-devbox-environment skill linked" 'test -L $HOME/.agents/skills/pi-devbox-environment && test -f $HOME/.agents/skills/pi-devbox-environment/SKILL.md && echo ok'
|
||
exec_test "pi-extensions skill linked (fallback)" 'test -L $HOME/.agents/skills/pi-extensions && test -f $HOME/.agents/skills/pi-extensions/SKILL.md && echo ok'
|
||
exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills/mempalace && test -f $HOME/.agents/skills/mempalace/SKILL.md && echo ok'
|
||
# 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
|
||
# silently SHADOWED the live skillset copy, so staleness was invisible — and the
|
||
# canary that was supposed to catch it could not: it grepped "Shared palace:
|
||
# multiple harnesses", a phrase present in BOTH the stale and the fresh copy.
|
||
# A snapshot canary must pin the NEWEST section, so update this string whenever
|
||
# the snapshot is refreshed — that is the point of it.
|
||
#
|
||
# v1.8.7: this fired for real, and on the release that changed the snapshot. The
|
||
# pinned phrase was "Attribute what you file yourself", the heading of the
|
||
# instruction telling agents to hand-stamp added_by — which that same release
|
||
# WITHDREW (RFC 001 §7.3.2 ranks agent-side stamping worst-possible; the bridge
|
||
# now does it). So the canary correctly reported "snapshot changed, expectation
|
||
# did not", and blocked publication of an otherwise-green build (81 passed, 1
|
||
# failed, twice). Two lessons kept in the assertion itself:
|
||
# * it is now BIDIRECTIONAL — the new phrase must be present AND the withdrawn
|
||
# one absent, so a re-vendored stale snapshot fails just as loudly as a
|
||
# forgotten bump. A one-way canary only catches half the drift.
|
||
# * a phrase canary can only ever detect "older than what I remembered to pin",
|
||
# never "older than skillset main".
|
||
#
|
||
# That structural limit is now addressed, but NOT by the "CI job diffing this
|
||
# file against the skillset repo" this comment used to point at (that pointer
|
||
# also dangled: it referenced an Unreleased changelog note that had become the
|
||
# v1.8.7 heading). A CI diff cannot be done without granting CI a credential
|
||
# for the PRIVATE skillset repo, and it would guard a file that on this fleet
|
||
# NO host reads — all four compose stacks mount a workspace containing the
|
||
# skillset, so devbox-skill-reconcile repoints this link at the live clone and
|
||
# the baked copy is a CI/no-mount fallback only. Instead the snapshot now
|
||
# carries its provenance (skillset_snapshot_ref + a measured
|
||
# skillset_snapshot_sha256 in build-manifest.json, written by
|
||
# scripts/vendor-mempalace-skill.sh), which moves the check to where the
|
||
# skillset actually IS: `scripts/vendor-mempalace-skill.sh --check` for a
|
||
# maintainer, and `pi-devbox-version` for an agent inside any container.
|
||
# This assertion is kept because it is orthogonal and free: it pins content,
|
||
# not provenance, so it still catches a re-vendored snapshot whose ref was
|
||
# bumped correctly but whose bytes came from the wrong place.
|
||
exec_test "mempalace skill snapshot is current" 'f=$HOME/.agents/skills/mempalace/SKILL.md; grep -q "Provenance is stamped for you" "$f" && ! grep -q "Attribute what you file yourself" "$f" && echo ok'
|
||
# Link TARGETS, not just link existence: with no skillset mounted (as here) the
|
||
# baked tree must be what resolves, for all four vendored skills.
|
||
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
|
||
case "$(readlink -f $HOME/.agents/skills/$s)" in
|
||
/usr/local/share/pi-devbox/skills/$s) ;;
|
||
*) echo "$s resolves to $(readlink -f $HOME/.agents/skills/$s)" >&2; exit 1 ;;
|
||
esac
|
||
done; echo ok'
|
||
# ... and that the tool REPORTS that resolution, which is the half that was
|
||
# missing: a stale baked snapshot and a current live clone were
|
||
# indistinguishable from inside the container. CI mounts no skillset, so every
|
||
# vendored skill must report "baked" here — which also makes this a real test of
|
||
# the fallback path rather than of the environment it happens to run in.
|
||
exec_test "pi-devbox-version reports skill sources (all baked, no skillset here)" \
|
||
'out=$(pi-devbox-version)
|
||
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
|
||
echo "$out" | grep -qE "^ $s +baked$" \
|
||
|| { echo "$s not reported as baked" >&2; exit 1; }
|
||
done; echo ok'
|
||
# The boot banner must NOT carry the section: entrypoint-user.sh prints the
|
||
# version FIRST, before the baked links exist and long before the skillset
|
||
# deploy + reconcile run last, so anything it said about skill sources would be
|
||
# a pre-reconcile state that is about to change.
|
||
# A bare negative (`! grep -q "skills:"`) passes if the tool crashes or
|
||
# prints nothing at all — it cannot tell "correctly omitted the section"
|
||
# apart from "the binary is broken". Anchor it positively: the command must
|
||
# still succeed and still print its normal release-tag line.
|
||
exec_test "pi-devbox-version --no-skills omits the skills section" \
|
||
'out=$(pi-devbox-version --no-skills) && echo "$out" | grep -q "^pi-devbox " && ! echo "$out" | grep -q "skills:"'
|
||
exec_test "entrypoint prints the version banner with --no-skills" \
|
||
'grep -q "pi-devbox-version --no-skills" /usr/local/bin/entrypoint-user.sh'
|
||
# The handover path itself. CI never mounts a skillset, so without this the
|
||
# v1.8.5 fix would ship untested: fabricate a skillset + a skills dir holding
|
||
# baked-style links, run the reconciler, and assert all three outcomes —
|
||
# owned skill repointed, unowned skill left baked, user override untouched.
|
||
exec_test "reconciler: owned skill handed to live clone, others untouched" \
|
||
'set -e; t=$(mktemp -d); mkdir -p $t/ss/skills/mempalace $t/ss/skills/pi-extensions $t/skills
|
||
echo LIVE > $t/ss/skills/mempalace/SKILL.md; echo LIVE > $t/ss/skills/pi-extensions/SKILL.md
|
||
ln -s /usr/local/share/pi-devbox/skills/mempalace $t/skills/mempalace
|
||
ln -s /usr/local/share/pi-devbox/skills/pi-extensions $t/skills/pi-extensions
|
||
mkdir -p $t/skills/mine; echo MINE > $t/skills/mine/SKILL.md
|
||
devbox-skill-reconcile $t/ss $t/skills >/dev/null
|
||
devbox-skill-reconcile $t/ss $t/skills >/dev/null # idempotent
|
||
[ "$(readlink $t/skills/mempalace)" = "$t/ss/skills/mempalace" ] || { echo "owned skill NOT repointed" >&2; exit 1; }
|
||
[ "$(readlink $t/skills/pi-extensions)" = /usr/local/share/pi-devbox/skills/pi-extensions ] || { echo "unowned skill was repointed" >&2; exit 1; }
|
||
[ "$(cat $t/skills/mine/SKILL.md)" = MINE ] || { echo "user override clobbered" >&2; exit 1; }
|
||
rm -rf $t; echo ok'
|
||
# The case above cannot fail if the reconciler stops checking WHERE a link
|
||
# points — a mutation test showed all three of its assertions still passing with
|
||
# that guard deleted, which is the same false-green shape as the old snapshot
|
||
# canary. This one discriminates: an OWNED name (so it is considered) whose link
|
||
# is a user override pointing outside the baked tree (so it must be left alone).
|
||
exec_test "reconciler: user override on an owned name is left alone" \
|
||
'set -e; t=$(mktemp -d); mkdir -p $t/ss/skills/mempalace $t/skills $t/mine-skill
|
||
echo LIVE > $t/ss/skills/mempalace/SKILL.md; echo USERLINK > $t/mine-skill/SKILL.md
|
||
ln -sfn $t/mine-skill $t/skills/mempalace
|
||
devbox-skill-reconcile $t/ss $t/skills >/dev/null
|
||
[ "$(cat $t/skills/mempalace/SKILL.md)" = USERLINK ] || { echo "user symlink override clobbered" >&2; exit 1; }
|
||
rm -rf $t; echo ok'
|
||
# mempalace-census gained a /usr/local/bin symlink in v1.8.3; its three siblings
|
||
# had one since they were added, so this asserts the set stays complete.
|
||
exec_test "mempalace-census on PATH" 'command -v mempalace-census >/dev/null && mempalace-census --help >/dev/null && echo ok'
|
||
|
||
# pi-fork + pi-observational-memory are registered by entrypoint-user.sh via
|
||
# `pi install /opt/<pkg>`, which runs slightly after the keybindings marker.
|
||
#
|
||
# Assert against the `packages` ARRAY, never a whole-file grep: the settings
|
||
# template ships a top-level "pi-fork" CONFIG block, so `grep -q pi-fork
|
||
# settings.json` passes even when `pi install /opt/pi-fork` never ran. That
|
||
# false green is exactly why the missing `fork` tool shipped unnoticed from
|
||
# v1.0.0 through v1.6.3.
|
||
pkg_registered_cmd() {
|
||
printf "jq -e --arg n %s '(.packages // []) | any((type == \"string\") and (. == \"npm:\" + \$n or endswith(\"/\" + \$n)))' \$HOME/.pi/agent/settings.json" "$1"
|
||
}
|
||
|
||
for i in $(seq 1 15); do
|
||
if docker exec -u developer "$CID" sh -c "$(pkg_registered_cmd pi-observational-memory)" \
|
||
>/dev/null 2>&1; then
|
||
break
|
||
fi
|
||
sleep 1
|
||
done
|
||
exec_test "pi-fork registered in packages[] (fork tool)" \
|
||
"$(pkg_registered_cmd pi-fork)"
|
||
exec_test "pi-observational-memory registered in packages[] (recall tool)" \
|
||
"$(pkg_registered_cmd pi-observational-memory)"
|
||
|
||
# pi-studio registration (studio variant only) — registered by the same
|
||
# entrypoint-user.sh local-path install loop as fork/obsmem.
|
||
if [ "${STUDIO_VARIANT:-0}" = "1" ]; then
|
||
for i in $(seq 1 15); do
|
||
if docker exec -u developer "$CID" sh -c "$(pkg_registered_cmd pi-studio)" \
|
||
>/dev/null 2>&1; then
|
||
break
|
||
fi
|
||
sleep 1
|
||
done
|
||
exec_test "pi-studio registered in packages[] (/studio command + studio_* tools)" \
|
||
"$(pkg_registered_cmd pi-studio)"
|
||
fi
|
||
|
||
# pi-atelier registration. It is LAST in the entrypoint's install loop, so a
|
||
# pass here also means that loop ran to completion rather than dying midway.
|
||
for i in $(seq 1 15); do
|
||
if docker exec -u developer "$CID" sh -c "$(pkg_registered_cmd pi-atelier)" \
|
||
>/dev/null 2>&1; then
|
||
break
|
||
fi
|
||
sleep 1
|
||
done
|
||
exec_test "pi-atelier registered in packages[] (TUI sidebar)" \
|
||
"$(pkg_registered_cmd pi-atelier)"
|
||
# ...and registered from the vendored /opt copy, NOT as `npm:pi-atelier`: an
|
||
# npm: entry resolves through ~/.pi/npm-global on the config VOLUME, which
|
||
# outlives image upgrades and would silently keep an old, unaudited atelier —
|
||
# exactly the shape that pairs a stale 0.6.x with a new pi and hangs at startup.
|
||
exec_test "pi-atelier registered from /opt, not npm: (volume-shadowing guard)" \
|
||
'jq -e "((.packages // []) | any((type == \"string\") and endswith(\"/pi-atelier\"))) and (((.packages // []) | any(. == \"npm:pi-atelier\")) | not)" $HOME/.pi/agent/settings.json'
|
||
|
||
# agent-browser: the third package hit by ~/.pi/npm-global volume shadowing
|
||
# (after pi itself and pi-atelier). This build-time check is deliberately WEAK
|
||
# and says so: a `docker run` container has an EMPTY config volume, so it can
|
||
# only prove the image ships a sane copy and nothing in the image itself
|
||
# shadows it. The check that actually bites lives in
|
||
# recreate-sanity-check.sh, which runs where the volume is real — that is
|
||
# where a 7-week-old 0.27.0 was caught shadowing 0.35.2 on 2026-09-06.
|
||
exec_test "agent-browser resolves under /usr (volume-shadowing guard, build-time half)" '
|
||
p=$(command -v agent-browser) || { echo "agent-browser not on PATH" >&2; exit 1; }
|
||
r=$(readlink -f "$p")
|
||
echo "resolved=[$r] version=[$(agent-browser --version 2>/dev/null | head -n1)]" >&2
|
||
case "$r" in /usr/*) ;; *) exit 1 ;; esac
|
||
test ! -d "$HOME/.pi/npm-global/lib/node_modules/agent-browser" || exit 1
|
||
echo ok
|
||
'
|
||
|
||
# pi-fork capability floor. `extensions: []` makes a fork child run with
|
||
# --no-extensions, which is the only MECHANICAL guarantee that a fork cannot
|
||
# file drawers or diary entries under the parent's identity — the mempalace
|
||
# bridge is an extension, so removing extensions removes the write path.
|
||
# Asserted because it is a security-shaped default that a settings merge or a
|
||
# hand-edit could silently drop, and its absence is invisible until a fork
|
||
# writes to the shared palace as you (measured twice: 2026-09-01, 2026-09-06).
|
||
# Deliberately compares to [] and not "is falsy": null means "load normal
|
||
# extensions", i.e. exactly the unguarded state this asserts against.
|
||
exec_test "pi-fork extensions floor is [] (forks cannot write to the palace)" \
|
||
'jq -e ".[\"pi-fork\"].extensions == []" $HOME/.pi/agent/settings.json'
|
||
|
||
# ── /tmp/sshcm directory created by entrypoint ────────────────────────
|
||
exec_test "/tmp/sshcm dir mode 700 (ssh ControlMaster)" \
|
||
'test -d /tmp/sshcm && [ "$(stat -c %a /tmp/sshcm)" = "700" ] && echo ok'
|
||
|
||
# ── Image size ────────────────────────────────────────────────────────
|
||
echo ""
|
||
echo "── Image size ──"
|
||
# Sum all layers via `docker history`. Docker's `image inspect --format='{{.Size}}'`
|
||
# returns ONLY the variant-unique layer when the base is content-addressed and
|
||
# shared (the case in this repo's two-phase build), which understates the
|
||
# user-facing image size by 2+ GB. Summing layer sizes from history is the
|
||
# metric Hub displays to users and the one we actually want to gate on.
|
||
SIZE_MB=$(docker history --format '{{.Size}}' "$IMAGE" | python3 -c '
|
||
import sys, re
|
||
total=0.0
|
||
for line in sys.stdin:
|
||
s=line.strip()
|
||
if s in ("0B", ""): continue
|
||
m=re.match(r"^([0-9.]+)(B|kB|MB|GB)$", s)
|
||
if not m: continue
|
||
v=float(m.group(1)); u=m.group(2)
|
||
mult={"B":1/1048576,"kB":1/1024,"MB":1,"GB":1024}[u]
|
||
total+=v*mult
|
||
print(int(total))
|
||
')
|
||
if [ -z "$SIZE_MB" ] || [ "$SIZE_MB" = "0" ]; then
|
||
printf " ⚠️ image size: could not parse — skipping check\n"
|
||
elif [ "$SIZE_MB" -le "$SIZE_THRESHOLD_MB" ]; then
|
||
printf " ✅ size: %d MB (threshold %d MB)\n" "$SIZE_MB" "$SIZE_THRESHOLD_MB"; PASS=$((PASS+1))
|
||
else
|
||
printf " ❌ size: %d MB exceeds threshold %d MB\n" "$SIZE_MB" "$SIZE_THRESHOLD_MB"; FAIL=$((FAIL+1))
|
||
fi
|
||
|
||
# ── Summary ───────────────────────────────────────────────────────────
|
||
echo ""
|
||
echo "=== Results: ${PASS} passed, ${FAIL} failed ==="
|
||
[ "$FAIL" -eq 0 ]
|