feat(entrypoint): link ~/.pi/agent/models.json to a mounted shared file

pi reads custom providers from ~/.pi/agent/models.json, and ~/.pi is the
devbox-pi-config named volume. A file baked into the image would only seed a
fresh volume, once. A hand-made link is one more step on every new volume: on
2026-10-12 that was the same `ln -s /opt/fleet-pi/models.json …` once per
device, for one fleet-shared file.

New rootfs/usr/local/lib/pi-devbox/link-pi-models.sh, called from
entrypoint-user.sh after the pi-toolkit and pi-extensions installs. When
/opt/fleet-pi/models.json exists (PI_MODELS_JSON overrides the path, =off
disables it), it links ~/.pi/agent/models.json to it, honouring
PI_CODING_AGENT_DIR. It only ever creates a link where nothing exists: a real
file or a link of the user's own is left alone, with one NOTE line. With
nothing mounted, it does nothing and prints nothing. A link whose target later
vanishes is harmless; measured with pi 1.0.0, a dangling models.json behaves
like a missing one (rc 0, no error, no custom models).

Tests:
- scripts/test-link-pi-models.sh: 13 two-sided cases against a throwaway HOME.
  Wired into lint.yml, so it runs on every push and not only at release.
- Of 11 mutations, 10 are caught. The 11th (ln -s -> ln -sfn on the final
  link) is unreachable behind the guards.
- smoke-test.sh: with nothing mounted no link appears, and the script links a
  mounted file. Both commands were run under dash here.
- recreate-sanity-check.sh: pass, warn or fail on the link, but only when a
  shared file is mounted. Here it passes.

Docs:
- README "Custom model providers (models.json)": mount the DIRECTORY (git pull
  renames, and a single-file mount then shows "No such file" until recreate),
  keep keys in .env as "$NAME".
- .env.example: PI_MODELS_JSON.

Local gates green: lint-shell, check-workflow-shell, check-dockerfile-directives,
check-base-hash, check-doc-drift.
This commit is contained in:
Joakim Persson
2026-10-12 16:28:28 +02:00
parent eb70928b16
commit 11e512e497
9 changed files with 270 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env bash
# link-pi-models.sh — point ~/.pi/agent/models.json at a shared, mounted file.
#
# pi reads custom providers (any OpenAI-, Anthropic- or Google-compatible
# endpoint: a company gateway, vLLM, Ollama, LM Studio …) from
# ~/.pi/agent/models.json. In pi-devbox that directory is the devbox-pi-config
# named volume, which makes both obvious ways of providing the file wrong:
# - 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 a price or model id changes.
# Instead, keep ONE models.json in a host directory, mount that directory
# read-only, and let this script link it in at every container start:
#
# volumes:
# - ./pi-shared:/opt/fleet-pi:ro # a directory containing models.json
#
# Mount the DIRECTORY, not the file. `git pull` (and most editors) replace a
# file by rename, and a single-file bind mount keeps the old inode and shows
# "No such file" until the next recreate (measured on Docker Desktop,
# 2026-10-12). A directory mount shows the new file at once, and pi re-reads
# models.json when it starts, so a pulled change reaches the next pi session.
#
# PI_MODELS_JSON overrides the path (default /opt/fleet-pi/models.json).
# PI_MODELS_JSON=off (or empty) disables this script.
#
# Never destructive. It only ever creates a link where nothing exists:
# - no file at the path → nothing (the case for most users)
# - models.json is already our link → nothing
# - models.json is a real file → left alone; one NOTE line says why
# - models.json links somewhere else → left alone; a deliberate choice
# A link this script made whose target later disappears (mount removed) is
# harmless: pi treats a dangling models.json exactly like a missing one —
# no error, no custom models (measured with pi 1.0.0, 2026-10-12).
#
# Called by entrypoint-user.sh. Safe to run by hand at any time.
set -u
src="${PI_MODELS_JSON-/opt/fleet-pi/models.json}"
case "$src" in
"" | off | OFF | Off) exit 0 ;;
esac
[ -f "$src" ] && [ -r "$src" ] || exit 0
agent_dir="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
dest="$agent_dir/models.json"
if [ -L "$dest" ]; then
# Ours already, or someone else's deliberate link: either way, nothing to do.
[ "$(readlink "$dest")" = "$src" ] || \
echo "NOTE: $dest links to $(readlink "$dest"), not $src; left alone"
exit 0
fi
if [ -e "$dest" ]; then
echo "NOTE: $dest is a real file; not linking $src (move it away to use the shared one)"
exit 0
fi
mkdir -p "$agent_dir" 2>/dev/null || true
if ln -s "$src" "$dest" 2>/dev/null; then
echo "pi models.json: linked to $src"
else
echo "WARN: could not link $dest -> $src (continuing)"
fi
exit 0