a6abe17edc
pi auto-loads every .ts in ~/.pi/agent/extensions/, which lives in the devbox-pi-config volume. A baked extension only seeds a fresh volume once, and a hand-copied one is a per-device step that drifts — the same two problems link-pi-models.sh was added for on 2026-10-12, one level down. This is also the only path that reaches every host: four of them mount common/pi from docker-compose-repo, and three cannot be reached from a devbox to create a symlink by hand (measured: no ssh config entry, no name resolution, and this instance's ~/.ssh-local/config carries no jump alias — only gitea and the mempalace feed host are reachable). link-pi-extensions.sh links each TOP-LEVEL *.ts from the mounted fleet directory (default /opt/fleet-pi, the same directory that already carries models.json; PI_EXTENSIONS_SRC overrides, =off disables). A symlink, not a single-file bind mount, for the measured reason already documented for models.json: git pull replaces a file by rename, and a file mount then serves stale content. Never destructive — a real file of that name in ~/.pi/agent/extensions/ wins over the fleet copy. Nested *.ts are deliberately not linked: the directory is fleet pi config, not an extension tree. - scripts/test-link-pi-extensions.sh: 16 two-sided cases (the negative half is the contract), all passing, wired into lint.yml beside the models one. - smoke-test.sh and recreate-sanity-check.sh gained the matching in-image and on-device checks, each gated on the fleet directory actually holding *.ts so a device without a shared directory is not judged by them. - README: the shared directory can carry extensions, and the rules that apply. Also repairs a pre-existing release blocker, found while checking this change. check-doc-drift.sh failed on pristine HEAD (verified in a clean worktree): pi-extensions main moved 0d8e417 -> 4c1d28d without the SHA being named above the v1.11.2 heading, and docker-publish.yml's resolve-versions has needs: [lint-gate] — so the next tag could not have published. The CHANGELOG now names 4c1d28d and describes the two confirm-destructive commits from their diff (they stop the gate prompting on harmless redirects, and add tee/dd of= as write forms); node --test test/confirm-destructive.test.mjs at 4c1d28d: 2 pass, 0 fail. Local verification: lint-shell.sh (21 files, severity error), check-workflow-shell.sh, check-skill-floor.sh, check-dockerfile-directives.sh, check-doc-drift.sh, and both link-pi-*.sh contract suites all pass. actionlint and hadolint are not installed in a devbox, so those CI steps are unvalidated here; the smoke-test addition runs only inside the image at release time.
134 lines
7.6 KiB
Bash
134 lines
7.6 KiB
Bash
#!/usr/bin/env bash
|
|
# test-link-pi-extensions.sh — two-sided tests for
|
|
# rootfs/usr/local/lib/pi-devbox/link-pi-extensions.sh.
|
|
#
|
|
# Runs the real script against a throwaway HOME / PI_CODING_AGENT_DIR, so it never
|
|
# touches a live ~/.pi/agent. Every "must link" case has a "must NOT touch" twin:
|
|
# the script's whole contract is that it only ever creates a link where nothing
|
|
# exists, so the negative cases are the important half — and one of them (a real
|
|
# extension of the same name wins) is what keeps a user's own file safe.
|
|
#
|
|
# bash scripts/test-link-pi-extensions.sh # CI (lint.yml) and locally
|
|
# LINK_PI_EXTENSIONS=/path/to/variant.sh bash scripts/test-link-pi-extensions.sh
|
|
set -u
|
|
here="$(cd "$(dirname "$0")/.." && pwd)"
|
|
SCRIPT="${LINK_PI_EXTENSIONS:-$here/rootfs/usr/local/lib/pi-devbox/link-pi-extensions.sh}"
|
|
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
|
|
PASS=0; FAIL=0
|
|
ok() { printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
|
|
bad() { printf ' ❌ %s — %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
|
|
|
|
# One fresh world per case: a shared dir holding two extensions (plus files that
|
|
# are NOT extensions), an empty HOME, agent dir unset.
|
|
world() {
|
|
rm -rf "$T/w"; mkdir -p "$T/w/shared" "$T/w/home"
|
|
printf 'export default {}\n' > "$T/w/shared/alpha.ts"
|
|
printf 'export default {}\n' > "$T/w/shared/beta.ts"
|
|
printf '{"providers":{}}\n' > "$T/w/shared/models.json"
|
|
printf 'not an extension\n' > "$T/w/shared/README.md"
|
|
SRC="$T/w/shared"; AG="$T/w/home/.pi/agent"; DEST="$AG/extensions"
|
|
}
|
|
# run [env assignments...] — run the script with HOME and PI_CODING_AGENT_DIR pointed into the world.
|
|
# "$@" goes FIRST so an option like `-u PI_EXTENSIONS_SRC` is still parsed as one by env.
|
|
run() { OUT="$(env "$@" HOME="$T/w/home" PI_CODING_AGENT_DIR="$AG" bash "$SCRIPT" 2>&1)"; RC=$?; }
|
|
|
|
echo "── link-pi-extensions.sh ($SCRIPT)"
|
|
|
|
world; run PI_EXTENSIONS_SRC="$SRC"
|
|
if [ "$RC" = 0 ] && [ "$(readlink "$DEST/alpha.ts")" = "$SRC/alpha.ts" ] \
|
|
&& [ "$(readlink "$DEST/beta.ts")" = "$SRC/beta.ts" ] && [[ "$OUT" == *"linked alpha.ts"* ]]; then
|
|
ok "links every top-level *.ts when the shared dir exists and nothing is there (creates the extensions dir)"
|
|
else bad "link when absent" "rc=$RC link=$(readlink "$DEST/alpha.ts" 2>&1) out=[$OUT]"; fi
|
|
|
|
run PI_EXTENSIONS_SRC="$SRC"
|
|
if [ "$RC" = 0 ] && [ -z "$OUT" ] && [ "$(readlink "$DEST/alpha.ts")" = "$SRC/alpha.ts" ]; then
|
|
ok "second run is a silent no-op (every container start runs it)"
|
|
else bad "idempotent" "rc=$RC out=[$OUT]"; fi
|
|
|
|
world; run PI_EXTENSIONS_SRC="$SRC"
|
|
if [ ! -e "$DEST/models.json" ] && [ ! -L "$DEST/models.json" ] \
|
|
&& [ ! -e "$DEST/README.md" ] && [ ! -L "$DEST/README.md" ]; then
|
|
ok "non-.ts files in the shared dir are ignored (models.json, README.md)"
|
|
else bad "non-.ts ignored" "dest holds: $(ls -A "$DEST" 2>&1 | tr '\n' ' ')"; fi
|
|
|
|
# A nested .ts must NOT be linked: the dir is fleet pi config, not an extension
|
|
# tree, and loading something nobody intended to publish as an extension is worse
|
|
# than missing one.
|
|
world; mkdir -p "$SRC/nested"; printf 'export default {}\n' > "$SRC/nested/gamma.ts"; run PI_EXTENSIONS_SRC="$SRC"
|
|
if [ "$RC" = 0 ] && [ ! -e "$DEST/gamma.ts" ] && [ ! -L "$DEST/gamma.ts" ]; then
|
|
ok "does not recurse — a nested *.ts is left alone (only top level is the contract)"
|
|
else bad "no recursion" "rc=$RC found=$(readlink "$DEST/gamma.ts" 2>&1)"; fi
|
|
|
|
world; mkdir -p "$SRC/dir.ts"; run PI_EXTENSIONS_SRC="$SRC"
|
|
if [ "$RC" = 0 ] && [ ! -e "$DEST/dir.ts" ] && [ ! -L "$DEST/dir.ts" ]; then
|
|
ok "a directory whose name ends in .ts is not linked"
|
|
else bad "dir named *.ts" "rc=$RC out=[$OUT]"; fi
|
|
|
|
world; mkdir -p "$DEST"; printf 'my own\n' > "$DEST/alpha.ts"; before="$(sha256sum "$DEST/alpha.ts")"; run PI_EXTENSIONS_SRC="$SRC"
|
|
if [ "$RC" = 0 ] && [ ! -L "$DEST/alpha.ts" ] && [ "$(sha256sum "$DEST/alpha.ts")" = "$before" ] \
|
|
&& [[ "$OUT" == NOTE*"real file"* ]] && [ "$(readlink "$DEST/beta.ts")" = "$SRC/beta.ts" ]; then
|
|
ok "never replaces the user's own extension of that name (content unchanged, one NOTE line) — and still links the others"
|
|
else bad "real file kept" "rc=$RC islink=$([ -L "$DEST/alpha.ts" ] && echo y) out=[$OUT]"; fi
|
|
|
|
world; mkdir -p "$DEST"; ln -s /somewhere/else.ts "$DEST/alpha.ts"; run PI_EXTENSIONS_SRC="$SRC"
|
|
if [ "$RC" = 0 ] && [ "$(readlink "$DEST/alpha.ts")" = /somewhere/else.ts ] && [[ "$OUT" == NOTE*"left alone"* ]]; then
|
|
ok "never re-points a link the user made to another file"
|
|
else bad "foreign link kept" "rc=$RC link=$(readlink "$DEST/alpha.ts") out=[$OUT]"; fi
|
|
|
|
world; rm -rf "$SRC"; run PI_EXTENSIONS_SRC="$SRC"
|
|
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ] && [ -z "$OUT" ]; then
|
|
ok "no shared dir → creates nothing and says nothing (the default for most users)"
|
|
else bad "absent source" "rc=$RC out=[$OUT]"; fi
|
|
|
|
world; rm -f "$SRC"/*.ts; run PI_EXTENSIONS_SRC="$SRC"
|
|
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ] && [ -z "$OUT" ]; then
|
|
ok "a shared dir with no *.ts → creates nothing (the dir is not treated as an extension itself)"
|
|
else bad "empty source dir" "rc=$RC out=[$OUT]"; fi
|
|
|
|
for v in off OFF ""; do
|
|
world; run PI_EXTENSIONS_SRC="$v"
|
|
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ] && [ -z "$OUT" ]; then
|
|
ok "PI_EXTENSIONS_SRC='$v' disables it"
|
|
else bad "disable '$v'" "rc=$RC out=[$OUT]"; fi
|
|
done
|
|
|
|
world; run PI_EXTENSIONS_SRC="$SRC"; rm -f "$SRC"/alpha.ts; run PI_EXTENSIONS_SRC="$SRC"
|
|
if [ "$RC" = 0 ] && [ -L "$DEST/alpha.ts" ] && [ "$(readlink "$DEST/alpha.ts")" = "$SRC/alpha.ts" ] && [ -z "$OUT" ]; then
|
|
ok "our link whose target vanished (mount removed) is left as is, silently"
|
|
else bad "dangling own link" "rc=$RC out=[$OUT]"; fi
|
|
|
|
# pi honours PI_CODING_AGENT_DIR, so the links must land THERE, not in $HOME/.pi/agent.
|
|
world; AG="$T/w/elsewhere/agent"; DEST="$AG/extensions"; run PI_EXTENSIONS_SRC="$SRC"
|
|
if [ "$RC" = 0 ] && [ "$(readlink "$DEST/alpha.ts" 2>/dev/null)" = "$SRC/alpha.ts" ] \
|
|
&& [ ! -e "$T/w/home/.pi/agent/extensions" ]; then
|
|
ok "links into PI_CODING_AGENT_DIR when set (and leaves \$HOME/.pi/agent alone)"
|
|
else bad "PI_CODING_AGENT_DIR" "rc=$RC link=$(readlink "$DEST/alpha.ts" 2>&1) out=[$OUT]"; fi
|
|
|
|
# 'off' must be a switch, not merely a path that happens not to exist: with a
|
|
# directory literally named "off" in the working directory it must still disable.
|
|
world; mkdir -p "$T/w/off"; printf 'export default {}\n' > "$T/w/off/alpha.ts"
|
|
OUT="$(cd "$T/w" && env PI_EXTENSIONS_SRC=off HOME="$T/w/home" PI_CODING_AGENT_DIR="$AG" bash "$SCRIPT" 2>&1)"; RC=$?
|
|
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ]; then
|
|
ok "PI_EXTENSIONS_SRC=off disables it even when a directory named 'off' exists in the cwd"
|
|
else bad "off is a switch" "rc=$RC link=$(readlink "$DEST/alpha.ts" 2>&1) out=[$OUT]"; fi
|
|
|
|
# The default is /opt/fleet-pi — the same dir that holds models.json. Whether it
|
|
# exists, and whether it holds any *.ts, depends on where this runs (a fleet devbox:
|
|
# maybe both; CI: neither), so derive the expectation instead of assuming it.
|
|
world; run -u PI_EXTENSIONS_SRC
|
|
default_ts="$(ls -1 /opt/fleet-pi/*.ts 2>/dev/null | head -1)"
|
|
if [ -n "$default_ts" ]; then
|
|
_dn="$(basename "$default_ts")"
|
|
if [ "$RC" = 0 ] && [ "$(readlink "$DEST/$_dn" 2>/dev/null)" = "$default_ts" ]; then
|
|
ok "PI_EXTENSIONS_SRC unset → default /opt/fleet-pi (here: holds $_dn) is linked"
|
|
else bad "default path (with .ts)" "rc=$RC link=$(readlink "$DEST/$_dn" 2>&1) out=[$OUT]"; fi
|
|
unset _dn
|
|
else
|
|
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ] && [ -z "$OUT" ]; then
|
|
ok "PI_EXTENSIONS_SRC unset → default /opt/fleet-pi (absent or has no *.ts here) → nothing"
|
|
else bad "default path (no .ts)" "rc=$RC out=[$OUT]"; fi
|
|
fi
|
|
|
|
echo "── $PASS passed, $FAIL failed"
|
|
[ "$FAIL" = 0 ]
|