entrypoint: link fleet pi extensions from the mounted config dir
Lint / skill-floor (push) Successful in 7s
Lint / hadolint (push) Successful in 15s
Lint / actionlint (push) Successful in 19s
Lint / doc-drift (push) Successful in 17s

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:
Joakim Persson
2026-10-16 19:01:04 +02:00
parent 5becbba6c8
commit a6abe17edc
8 changed files with 307 additions and 1 deletions
+6
View File
@@ -96,6 +96,12 @@ jobs:
# on every push, because the smoke test only runs at release time. # on every push, because the smoke test only runs at release time.
run: bash scripts/test-link-pi-models.sh 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 - name: Dockerfile.variant still opens with its BuildKit check directive
# See scripts/check-dockerfile-directives.sh: without that first line the # See scripts/check-dockerfile-directives.sh: without that first line the
# smoke jobs fail with `invalid format delimiter 'ghadelimiter_...'` and # smoke jobs fail with `invalid format delimiter 'ghadelimiter_...'` and
+39 -1
View File
@@ -23,10 +23,48 @@ base** rather than reusing `base-6f3b20266de1`.
| Component | Baked in v1.11.2 | Next build | Why | | 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 ### 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 - **The AWS SSO keeper showed 12-hour times under the image's default
locale** (pi-extensions `0d8e417`). The status line (`AWS ✓ since …` / locale** (pi-extensions `0d8e417`). The status line (`AWS ✓ since …` /
`until …`), `/aws-sso status`, the "session ended at" login notice and the `until …`), `/aws-sso status`, the "session ended at" login notice and the
+21
View File
@@ -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 <provider>`. `recreate-sanity-check.sh` link. Check with `pi --list-models <provider>`. `recreate-sanity-check.sh`
verifies the link whenever a shared file is mounted. 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 ## Build pipeline
pi-devbox is built from this repo's CI in two phases: pi-devbox is built from this repo's CI in two phases:
+10
View File
@@ -381,6 +381,16 @@ if command -v pi &>/dev/null; then
bash /usr/local/lib/pi-devbox/link-pi-models.sh || true bash /usr/local/lib/pi-devbox/link-pi-models.sh || true
fi 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 # Bootstrap settings.json from template if absent (pi rewrites this
# file at runtime — lastChangelogVersion, etc — so we can't symlink it). # file at runtime — lastChangelogVersion, etc — so we can't symlink it).
_pi_settings="$HOME/.pi/agent/settings.json" _pi_settings="$HOME/.pi/agent/settings.json"
@@ -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
+22
View File
@@ -295,6 +295,28 @@ if [ -n "$_mj_src" ] && [ -f "$_mj_src" ]; then
fi fi
unset _mj _mj_src 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 # extensions deployed (pi-extensions) — expect ≥4 *.ts
EXT_COUNT=$(ls -1 "$HOME"/.pi/agent/extensions/*.ts 2>/dev/null | wc -l | tr -d ' ') EXT_COUNT=$(ls -1 "$HOME"/.pi/agent/extensions/*.ts 2>/dev/null | wc -l | tr -d ' ')
if [ "$EXT_COUNT" -ge 4 ]; then if [ "$EXT_COUNT" -ge 4 ]; then
+1
View File
@@ -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 "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 "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-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-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 "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' exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills/mempalace && test -f $HOME/.agents/skills/mempalace/SKILL.md && echo ok'
+133
View File
@@ -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 ]