entrypoint: link fleet pi extensions from the mounted config dir
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.
This commit is contained in:
@@ -295,6 +295,28 @@ if [ -n "$_mj_src" ] && [ -f "$_mj_src" ]; then
|
||||
fi
|
||||
unset _mj _mj_src
|
||||
|
||||
# shared fleet extensions (link-pi-extensions.sh) — same shape as models.json
|
||||
# above: checked only when the fleet directory actually holds *.ts, so a device
|
||||
# without a shared directory is not judged by it.
|
||||
_le_src="${PI_EXTENSIONS_SRC-/opt/fleet-pi}"
|
||||
case "$_le_src" in "" | off | OFF | Off) _le_src="" ;; esac
|
||||
if [ -n "$_le_src" ] && ls -1 "$_le_src"/*.ts >/dev/null 2>&1; then
|
||||
for _le_f in "$_le_src"/*.ts; do
|
||||
_le_name="$(basename "$_le_f")"; _le_dest="$HOME/.pi/agent/extensions/$_le_name"
|
||||
if [ -L "$_le_dest" ] && [ "$(readlink "$_le_dest")" = "$_le_f" ]; then
|
||||
pass "~/.pi/agent/extensions/$_le_name -> $_le_f (shared fleet extension)"
|
||||
elif [ -L "$_le_dest" ]; then
|
||||
warn "~/.pi/agent/extensions/$_le_name links to $(readlink "$_le_dest"), not the mounted $_le_f"
|
||||
elif [ -e "$_le_dest" ]; then
|
||||
warn "~/.pi/agent/extensions/$_le_name is a real file, so the mounted $_le_f is NOT in use"
|
||||
else
|
||||
fail "$_le_src holds $_le_name but ~/.pi/agent/extensions/$_le_name is missing (image without link-pi-extensions.sh? run: ln -s $_le_f ~/.pi/agent/extensions/)"
|
||||
fi
|
||||
done
|
||||
unset _le_f _le_name _le_dest
|
||||
fi
|
||||
unset _le_src
|
||||
|
||||
# extensions deployed (pi-extensions) — expect ≥4 *.ts
|
||||
EXT_COUNT=$(ls -1 "$HOME"/.pi/agent/extensions/*.ts 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$EXT_COUNT" -ge 4 ]; then
|
||||
|
||||
@@ -675,6 +675,7 @@ exec_test "mempalace.ts bridge" 'test -L $HOME/.pi/agent/exte
|
||||
exec_test "settings.json bootstrapped" 'test -f $HOME/.pi/agent/settings.json && echo ok'
|
||||
exec_test "no shared models.json mounted → none linked" 'test ! -e $HOME/.pi/agent/models.json && test ! -L $HOME/.pi/agent/models.json'
|
||||
exec_test "link-pi-models.sh links a mounted models.json" 'mkdir -p /tmp/lpm && echo "{}" > /tmp/lpm/models.json && PI_MODELS_JSON=/tmp/lpm/models.json PI_CODING_AGENT_DIR=/tmp/lpm/agent bash /usr/local/lib/pi-devbox/link-pi-models.sh >/dev/null && test "$(readlink /tmp/lpm/agent/models.json)" = /tmp/lpm/models.json && rm -rf /tmp/lpm'
|
||||
exec_test "link-pi-extensions.sh links a mounted *.ts" 'mkdir -p /tmp/lpe/src && echo "export default {}" > /tmp/lpe/src/x.ts && PI_EXTENSIONS_SRC=/tmp/lpe/src PI_CODING_AGENT_DIR=/tmp/lpe/agent bash /usr/local/lib/pi-devbox/link-pi-extensions.sh >/dev/null && test "$(readlink /tmp/lpe/agent/extensions/x.ts)" = /tmp/lpe/src/x.ts && rm -rf /tmp/lpe'
|
||||
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'
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/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 ]
|
||||
Reference in New Issue
Block a user