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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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