From a6abe17edc7ea787b7ab0b3e8386eeb3b6482ad6 Mon Sep 17 00:00:00 2001 From: Joakim Persson Date: Fri, 16 Oct 2026 19:01:04 +0200 Subject: [PATCH] entrypoint: link fleet pi extensions from the mounted config dir MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .gitea/workflows/lint.yml | 6 + CHANGELOG.md | 40 +++++- README.md | 21 +++ entrypoint-user.sh | 10 ++ .../local/lib/pi-devbox/link-pi-extensions.sh | 75 ++++++++++ scripts/recreate-sanity-check.sh | 22 +++ scripts/smoke-test.sh | 1 + scripts/test-link-pi-extensions.sh | 133 ++++++++++++++++++ 8 files changed, 307 insertions(+), 1 deletion(-) create mode 100644 rootfs/usr/local/lib/pi-devbox/link-pi-extensions.sh create mode 100644 scripts/test-link-pi-extensions.sh diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml index c6808b9..8084bab 100644 --- a/.gitea/workflows/lint.yml +++ b/.gitea/workflows/lint.yml @@ -96,6 +96,12 @@ jobs: # on every push, because the smoke test only runs at release time. run: bash scripts/test-link-pi-models.sh + - name: link-pi-extensions.sh contract (two-sided, no docker needed) + # Same reasoning as the models step above: the entrypoint links top-level + # *.ts from the mounted fleet directory into ~/.pi/agent/extensions, and + # must never replace a user's own extension of the same name. + run: bash scripts/test-link-pi-extensions.sh + - name: Dockerfile.variant still opens with its BuildKit check directive # See scripts/check-dockerfile-directives.sh: without that first line the # smoke jobs fail with `invalid format delimiter 'ghadelimiter_...'` and diff --git a/CHANGELOG.md b/CHANGELOG.md index 63629e4..aceec48 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,10 +23,48 @@ base** rather than reusing `base-6f3b20266de1`. | Component | Baked in v1.11.2 | Next build | Why | |---|---|---|---| -| pi-extensions | `750b2a8` | **`0d8e417`** (or later: CI resolves `main` at build time) | the AWS SSO keeper prints clock times in 24-hour format whatever the locale (below) | +| pi-extensions | `750b2a8` | **`4c1d28d`** (or later: CI resolves `main` at build time) | the `confirm-destructive` gate stops prompting on harmless redirects and catches `tee`/`dd of=` (below); picks up the AWS SSO keeper's 24-hour times too (below) | + +### Added + +- **Fleet pi extensions are linked in at every container start** + (`rootfs/usr/local/lib/pi-devbox/link-pi-extensions.sh`, called from + `entrypoint-user.sh` next to `link-pi-models.sh`). pi auto-loads every `.ts` in + `~/.pi/agent/extensions/`, which lives in the `devbox-pi-config` volume: a baked + extension would only seed a fresh volume once, and a hand-copied one is a + per-device step that drifts — the two problems `link-pi-models.sh` was added + for on 2026-10-12, one level down. The script 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) and is never destructive: a real file of that name in + `~/.pi/agent/extensions/` is left alone, so a user's own copy of an extension + always wins over the fleet one. A symlink rather than a single-file bind mount + because `git pull` replaces a file by rename (measured on Docker Desktop, + 2026-10-12), which is the documented reason `models.json` is shared this way. + Nested `*.ts` are deliberately NOT linked — the directory is fleet pi config, + not an extension tree. `scripts/test-link-pi-extensions.sh` covers the contract + with 16 two-sided cases and runs in `lint.yml`; `smoke-test.sh` and + `recreate-sanity-check.sh` gained the matching in-image and on-device checks. + Motivated by the ERDC System One classifier extension: four hosts mount + `common/pi` from `docker-compose-repo`, and three of them are not reachable + from a devbox to create a symlink by hand. ### Fixed +- **`confirm-destructive` asked for approval on nearly every `2>/dev/null`** + (pi-extensions `4c1d28d`, two commits on top of the `0d8e417` named below). + Its "write to device" pattern was a bare `>\s*/dev/`, so redirecting a + standard stream into the null device looked like a write to a block device — + the extension's own comment cites 20 of 70 bash calls in one 2026-10-16 + session, and a gate that prompts that often trains the user to approve without + reading. `/dev/null`, `stdout`, `stderr`, `tty`, `/dev/fd/*` and `/dev/shm` are + now exempt, matched as whole names so `/dev/nullfoo` still prompts and a + `/dev/shm` path containing `..` is not exempt. The same gate got harder to + slip past: `tee` and `dd of=` (which writes from stdin and has no `if=`) are + now write forms, and quoted device paths are recognised. Its suite covers the + new cases — `node --test test/confirm-destructive.test.mjs` in + pi-extensions `4c1d28d`: 2 tests, 2 pass, 0 fail (measured 2026-10-16). + - **The AWS SSO keeper showed 12-hour times under the image's default locale** (pi-extensions `0d8e417`). The status line (`AWS ✓ since …` / `until …`), `/aws-sso status`, the "session ended at" login notice and the diff --git a/README.md b/README.md index 378ccf2..50a5c6e 100644 --- a/README.md +++ b/README.md @@ -986,6 +986,27 @@ To share one file across devices, keep it in a host directory and mount that link. Check with `pi --list-models `. `recreate-sanity-check.sh` verifies the link whenever a shared file is mounted. +### The same directory can carry pi extensions + +pi auto-loads every `.ts` in `~/.pi/agent/extensions/`, which lives in the same +volume and so has the same problem. The entrypoint therefore links top-level +`*.ts` files from the mounted directory in as well, one line each: + + pi extension: linked erdc-classifiers.ts -> /opt/fleet-pi/erdc-classifiers.ts + +- Only **top-level** `*.ts` files are linked: `models.json`, a README or a + nested checkout in that directory is not an extension, and one is never loaded + by accident. +- Same never-destructive rule, with the same `NOTE` lines, and the same + reasoning that makes a link right where a single-file mount is not (a pulled + change is picked up at once). +- **Your own copy wins.** A real file of the same name in + `~/.pi/agent/extensions/` is left alone, so a local edit of a shared extension + is never overwritten. +- `PI_EXTENSIONS_SRC` points at another directory, and `PI_EXTENSIONS_SRC=off` + disables it. `recreate-sanity-check.sh` verifies each link whenever the shared + directory holds `*.ts`. + ## Build pipeline pi-devbox is built from this repo's CI in two phases: diff --git a/entrypoint-user.sh b/entrypoint-user.sh index 3efcf9a..fbd5ff5 100755 --- a/entrypoint-user.sh +++ b/entrypoint-user.sh @@ -381,6 +381,16 @@ if command -v pi &>/dev/null; then bash /usr/local/lib/pi-devbox/link-pi-models.sh || true fi + # Shared pi extensions: link each TOP-LEVEL *.ts in that same mounted directory + # into ~/.pi/agent/extensions (default /opt/fleet-pi; PI_EXTENSIONS_SRC overrides, + # =off disables). Same reasoning and same never-destructive rule as models.json + # above: without it every fresh devbox-pi-config volume needed a manual `ln -s` + # per extension per device, and the copies drift. A user's own file of the same + # name wins over the fleet copy. Rationale and cases: the script's header. + if [ -r /usr/local/lib/pi-devbox/link-pi-extensions.sh ]; then + bash /usr/local/lib/pi-devbox/link-pi-extensions.sh || true + fi + # Bootstrap settings.json from template if absent (pi rewrites this # file at runtime — lastChangelogVersion, etc — so we can't symlink it). _pi_settings="$HOME/.pi/agent/settings.json" diff --git a/rootfs/usr/local/lib/pi-devbox/link-pi-extensions.sh b/rootfs/usr/local/lib/pi-devbox/link-pi-extensions.sh new file mode 100644 index 0000000..5169b60 --- /dev/null +++ b/rootfs/usr/local/lib/pi-devbox/link-pi-extensions.sh @@ -0,0 +1,75 @@ +#!/usr/bin/env bash +# link-pi-extensions.sh — point ~/.pi/agent/extensions/*.ts at shared, mounted files. +# +# pi auto-loads every .ts file in ~/.pi/agent/extensions/. In pi-devbox that +# directory lives in the devbox-pi-config named volume, which makes both obvious +# ways of providing a fleet extension wrong — the same two ways as models.json +# (see link-pi-models.sh, whose header this mirrors): +# - baked into the image, it would only seed a FRESH volume, once; +# - copied by hand into each volume, it is one more per-device step, and the +# copies drift the first time the extension changes. +# Instead, keep the .ts files in the same host directory that already holds +# models.json, mount that directory read-only, and let this script link them in +# at every container start: +# +# volumes: +# - ./pi-shared:/opt/fleet-pi:ro # a directory whose top-level *.ts are extensions +# +# A SYMLINK, not a single-file bind mount, and for the measured reason given in +# link-pi-models.sh: `git pull` replaces a file by rename, so a single-file mount +# keeps the old inode and serves stale content until the next recreate. A link +# follows the path, so a pulled change reaches the next pi session. +# +# Only TOP-LEVEL *.ts files are linked — no recursive search. The directory is +# fleet-shared pi config: models.json, its own docs and any sub-checkout are not +# extensions, and silently loading them would be worse than missing one. +# +# PI_EXTENSIONS_SRC overrides the directory (default /opt/fleet-pi). +# PI_EXTENSIONS_SRC=off (or empty) disables this script. +# +# Never destructive, per file. It only ever creates a link where nothing exists: +# - no file at the destination → link created +# - the destination is already ours → nothing +# - a real file with that name exists → left alone; one NOTE line says why +# (the user's own extension of that name deliberately wins over the fleet copy) +# - the destination links elsewhere → left alone; a deliberate choice +# A link this script made whose target later disappears (mount removed) is +# harmless and needs no cleanup: pi reports the unreadable extension and the rest +# of the session is unaffected. +# +# Called by entrypoint-user.sh. Safe to run by hand at any time. +set -u + +src="${PI_EXTENSIONS_SRC-/opt/fleet-pi}" +case "$src" in + "" | off | OFF | Off) exit 0 ;; +esac +[ -d "$src" ] || exit 0 + +agent_dir="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}" +dest_dir="$agent_dir/extensions" + +for f in "$src"/*.ts; do + [ -f "$f" ] || continue + name="$(basename "$f")" + dest="$dest_dir/$name" + + if [ -L "$dest" ]; then + # Ours already, or someone else's deliberate link: either way, nothing to do. + [ "$(readlink "$dest")" = "$f" ] || \ + echo "NOTE: $dest links to $(readlink "$dest"), not $f; left alone" + continue + fi + if [ -e "$dest" ]; then + echo "NOTE: $dest is a real file; not linking $f (move it away to use the fleet copy)" + continue + fi + + mkdir -p "$dest_dir" 2>/dev/null || true + if ln -s "$f" "$dest" 2>/dev/null; then + echo "pi extension: linked $name -> $f" + else + echo "WARN: could not link $dest -> $f (continuing)" + fi +done +exit 0 diff --git a/scripts/recreate-sanity-check.sh b/scripts/recreate-sanity-check.sh index a59924a..633b147 100755 --- a/scripts/recreate-sanity-check.sh +++ b/scripts/recreate-sanity-check.sh @@ -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 diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index c5641c2..7f57e58 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -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' diff --git a/scripts/test-link-pi-extensions.sh b/scripts/test-link-pi-extensions.sh new file mode 100644 index 0000000..90bb6eb --- /dev/null +++ b/scripts/test-link-pi-extensions.sh @@ -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 ]