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
+7
View File
@@ -186,6 +186,13 @@ GIT_USER_EMAIL=
# # exec "${SHELL:-/bin/sh}" -lc 'aws sso login --sso-session {session}'
# # (Compose expands ${...} in .env: write $$ for a literal $)
# ── Shared custom model providers (pi models.json) ────────────────────
# If /opt/fleet-pi/models.json exists (mount a host DIRECTORY there, read-only),
# the entrypoint links ~/.pi/agent/models.json to it on every start. A real file
# or a link of your own is never replaced. README: "Custom model providers".
# Put API keys in this .env and reference them as "$NAME" in models.json.
# PI_MODELS_JSON=/opt/fleet-pi/models.json # another path; off = disable
# ── Skillset (agent skills and instructions) ─────────────────────────
# If you have a skillset repo, the entrypoint auto-deploys skills and
# instructions on container start using relative symlinks.
+6
View File
@@ -90,6 +90,12 @@ jobs:
# actionlint so the more precise diagnostic surfaces first.
run: bash scripts/check-workflow-shell.sh .gitea/workflows
- name: link-pi-models.sh contract (two-sided, no docker needed)
# The entrypoint links ~/.pi/agent/models.json to a mounted shared file
# and must never replace a real file or a user's own link. Tested here
# on every push, because the smoke test only runs at release time.
run: bash scripts/test-link-pi-models.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
+29
View File
@@ -22,6 +22,35 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
### Added
- **Shared custom model providers: `~/.pi/agent/models.json` is linked to a
mounted file at every start.** Mount a host *directory* read-only at
`/opt/fleet-pi`, and new `rootfs/usr/local/lib/pi-devbox/link-pi-models.sh`
links `~/.pi/agent/models.json` to `/opt/fleet-pi/models.json`. It runs from
`entrypoint-user.sh`. `PI_MODELS_JSON` points at another path, and `=off`
disables it. It only ever creates a link where nothing exists: a real file or a
link of your own is left alone, with one `NOTE` line in `docker logs`.
Why this is needed: pi reads custom providers (company gateways, vLLM, Ollama,
…) from `~/.pi/agent/models.json`, and `~/.pi` is the `devbox-pi-config`
volume. A file baked into the image would only seed a fresh volume once, and a
hand-made link was one more step on every new volume. In October 2026 that was
the same `ln -s` once per device for one fleet-shared file. Mount the
directory, not the file. `git pull` replaces the file by rename, and a
single-file bind mount then shows "No such file" until the next recreate
(measured on Docker Desktop).
Tests:
- `scripts/test-link-pi-models.sh` has 13 two-sided cases and now runs in
`lint.yml` on every push. Every mutation of a rule was caught, except one
that the guards make unreachable.
- Two smoke-test checks: with nothing mounted no link appears, and the script
links a mounted file.
- `recreate-sanity-check.sh` checks the link whenever a shared file is mounted.
pi treats a dangling `models.json` exactly like a missing one, with no error,
so removing the mount later is harmless (measured with pi 1.0.0). README:
"Custom model providers (`models.json`)".
- **Long Bedrock runs no longer die at "Token is expired" when the AWS SSO
session ends: `/aws-sso` (pi-extensions `aws-sso.ts`) runs `aws sso login` for
you just after the session ends.** Off until `/aws-sso on` (or
+34
View File
@@ -879,6 +879,40 @@ If you use Bedrock as pi's LLM provider:
The pi-toolkit AWS env loader (in `~/.pi/agent/`) prepares Bedrock
inference-profile model IDs (with `eu.` / `us.` prefixes) automatically.
## Custom model providers (`models.json`)
pi reads extra providers from `~/.pi/agent/models.json`. That can be any
OpenAI-, Anthropic- or Google-compatible endpoint: a company gateway, vLLM,
Ollama, LM Studio and so on (pi's
[models.md](https://github.com/earendil-works/pi/blob/main/packages/coding-agent/docs/models.md)).
`~/.pi` is the `devbox-pi-config` volume. A file you put there by hand survives
recreates, but every new volume starts without one: another device, or after
`down -v`.
To share one file across devices, keep it in a host directory and mount that
**directory** read-only at `/opt/fleet-pi`. At every start the entrypoint links
`~/.pi/agent/models.json` to `/opt/fleet-pi/models.json`:
```yaml
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. A single-file bind mount then keeps showing the old one, or
"No such file", until the next recreate. A directory mount shows the new file
at once. pi reads `models.json` when it starts, so a pulled change reaches the
next pi session without a recreate.
- **Keep keys out of the file.** Write `"apiKey": "$MY_GATEWAY_KEY"` in
`models.json` and put the value in `.env`, which `env_file:` already passes
in. The file then holds no secret and can live in git.
- **Nothing of yours is replaced.** A real `~/.pi/agent/models.json`, or a link
of your own, is left alone; `docker logs` shows one `NOTE` line saying so. With
nothing mounted, nothing happens.
- `PI_MODELS_JSON` points at another path, and `PI_MODELS_JSON=off` disables the
link. Check with `pi --list-models <provider>`. `recreate-sanity-check.sh`
verifies the link whenever a shared file is mounted.
## Build pipeline
pi-devbox is built from this repo's CI in two phases:
+10
View File
@@ -371,6 +371,16 @@ if command -v pi &>/dev/null; then
echo "WARN: pi-extensions install.sh failed (continuing)"
fi
# Shared custom providers: link ~/.pi/agent/models.json to a models.json in a
# read-only mounted DIRECTORY (default /opt/fleet-pi/models.json; PI_MODELS_JSON
# overrides, =off disables). Only ever creates a link where nothing exists —
# a real file or a link of the user's own is left alone. Without this, every
# fresh devbox-pi-config volume needed a manual `ln -s` (2026-10-12: one per
# device for the same file). Rationale and cases: the script's header.
if [ -r /usr/local/lib/pi-devbox/link-pi-models.sh ]; then
bash /usr/local/lib/pi-devbox/link-pi-models.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"
+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
+18
View File
@@ -277,6 +277,24 @@ else
fail "~/.pi/agent/AGENTS.md missing or not a symlink"
fi
# shared models.json (link-pi-models.sh) — checked only when a shared file is
# actually mounted, so a device without one is not judged by it.
_mj_src="${PI_MODELS_JSON-/opt/fleet-pi/models.json}"
case "$_mj_src" in "" | off | OFF | Off) _mj_src="" ;; esac
if [ -n "$_mj_src" ] && [ -f "$_mj_src" ]; then
_mj="$HOME/.pi/agent/models.json"
if [ -L "$_mj" ] && [ "$(readlink "$_mj")" = "$_mj_src" ]; then
pass "~/.pi/agent/models.json -> $_mj_src (shared custom providers)"
elif [ -L "$_mj" ]; then
warn "~/.pi/agent/models.json links to $(readlink "$_mj"), not the mounted $_mj_src"
elif [ -e "$_mj" ]; then
warn "~/.pi/agent/models.json is a real file, so the mounted $_mj_src is NOT in use"
else
fail "$_mj_src is mounted but ~/.pi/agent/models.json is missing (image without link-pi-models.sh? run: ln -s $_mj_src ~/.pi/agent/models.json)"
fi
fi
unset _mj _mj_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
+2
View File
@@ -672,6 +672,8 @@ exec_test "keybindings.json (pi-toolkit)" 'test -L $HOME/.pi/agent/keybi
exec_test "extensions ≥ 4 (pi-extensions)" 'count=$(ls -1 $HOME/.pi/agent/extensions/*.ts 2>/dev/null | wc -l); [ $count -ge 4 ] && echo "$count extensions"'
exec_test "mempalace.ts bridge" 'test -L $HOME/.pi/agent/extensions/mempalace.ts && 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 "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 "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'
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env bash
# test-link-pi-models.sh — two-sided tests for rootfs/usr/local/lib/pi-devbox/link-pi-models.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.
#
# bash scripts/test-link-pi-models.sh # CI (lint.yml) and locally
# LINK_PI_MODELS=/path/to/variant.sh bash scripts/test-link-pi-models.sh
set -u
here="$(cd "$(dirname "$0")/.." && pwd)"
SCRIPT="${LINK_PI_MODELS:-$here/rootfs/usr/local/lib/pi-devbox/link-pi-models.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: shared dir with models.json, empty HOME, agent dir unset.
world() {
rm -rf "$T/w"; mkdir -p "$T/w/shared" "$T/w/home"
printf '{"providers":{}}\n' > "$T/w/shared/models.json"
SRC="$T/w/shared/models.json"; AG="$T/w/home/.pi/agent"; DEST="$AG/models.json"
}
# 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_MODELS_JSON` 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-models.sh ($SCRIPT)"
world; run PI_MODELS_JSON="$SRC"
if [ "$RC" = 0 ] && [ -L "$DEST" ] && [ "$(readlink "$DEST")" = "$SRC" ] && [[ "$OUT" == *linked* ]]; then
ok "links models.json when the shared file exists and nothing is there (creates the agent dir)"
else bad "link when absent" "rc=$RC link=$(readlink "$DEST" 2>&1) out=[$OUT]"; fi
run PI_MODELS_JSON="$SRC"
if [ "$RC" = 0 ] && [ -z "$OUT" ] && [ "$(readlink "$DEST")" = "$SRC" ]; then
ok "second run is a silent no-op (every container start runs it)"
else bad "idempotent" "rc=$RC out=[$OUT]"; fi
world; mkdir -p "$AG"; printf 'my own\n' > "$DEST"; before="$(sha256sum "$DEST")"; run PI_MODELS_JSON="$SRC"
if [ "$RC" = 0 ] && [ ! -L "$DEST" ] && [ "$(sha256sum "$DEST")" = "$before" ] && [[ "$OUT" == NOTE*"real file"* ]]; then
ok "never replaces a real models.json (content unchanged, one NOTE line)"
else bad "real file kept" "rc=$RC islink=$([ -L "$DEST" ] && echo y) out=[$OUT]"; fi
world; mkdir -p "$AG"; ln -s /somewhere/else.json "$DEST"; run PI_MODELS_JSON="$SRC"
if [ "$RC" = 0 ] && [ "$(readlink "$DEST")" = /somewhere/else.json ] && [[ "$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") out=[$OUT]"; fi
world; rm -f "$SRC"; run PI_MODELS_JSON="$SRC"
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ] && [ -z "$OUT" ]; then
ok "no shared file → creates nothing and says nothing (the default for most users)"
else bad "absent source" "rc=$RC out=[$OUT]"; fi
world; rm -f "$SRC"; mkdir -p "$SRC"; run PI_MODELS_JSON="$SRC"
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ]; then
ok "a directory at the path is not a models.json → nothing"
else bad "directory source" "rc=$RC out=[$OUT]"; fi
for v in off OFF ""; do
world; run PI_MODELS_JSON="$v"
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ] && [ -z "$OUT" ]; then
ok "PI_MODELS_JSON='$v' disables it"
else bad "disable '$v'" "rc=$RC out=[$OUT]"; fi
done
world; run PI_MODELS_JSON="$SRC"; rm -f "$SRC"; run PI_MODELS_JSON="$SRC"
if [ "$RC" = 0 ] && [ -L "$DEST" ] && [ "$(readlink "$DEST")" = "$SRC" ] && [ -z "$OUT" ]; then
ok "our link whose target vanished (mount removed) is left as is, silently (pi treats it as no file)"
else bad "dangling own link" "rc=$RC out=[$OUT]"; fi
# pi honours PI_CODING_AGENT_DIR, so the link must land THERE, not in $HOME/.pi/agent.
world; AG="$T/w/elsewhere/agent"; DEST="$AG/models.json"; run PI_MODELS_JSON="$SRC"
if [ "$RC" = 0 ] && [ "$(readlink "$DEST" 2>/dev/null)" = "$SRC" ] && [ ! -e "$T/w/home/.pi/agent/models.json" ]; 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" 2>&1) out=[$OUT]"; fi
# 'off' must be a switch, not merely a path that happens not to exist: with a file
# literally named "off" in the working directory it must still disable.
world; printf '{}\n' > "$T/w/off"; OUT="$(cd "$T/w" && env PI_MODELS_JSON=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_MODELS_JSON=off disables it even when a file named 'off' exists in the cwd"
else bad "off is a switch" "rc=$RC link=$(readlink "$DEST" 2>&1) out=[$OUT]"; fi
# The default path is /opt/fleet-pi/models.json. Whether it exists depends on where
# this runs (a fleet devbox: yes; CI: no), so derive the expectation, don't assume it.
world; run -u PI_MODELS_JSON
if [ -f /opt/fleet-pi/models.json ]; then
if [ "$RC" = 0 ] && [ "$(readlink "$DEST" 2>/dev/null)" = /opt/fleet-pi/models.json ]; then
ok "PI_MODELS_JSON unset → default /opt/fleet-pi/models.json (present here) is linked"
else bad "default path (present)" "rc=$RC link=$(readlink "$DEST" 2>&1) out=[$OUT]"; fi
else
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ] && [ -z "$OUT" ]; then
ok "PI_MODELS_JSON unset → default /opt/fleet-pi/models.json (absent here) → nothing"
else bad "default path (absent)" "rc=$RC out=[$OUT]"; fi
fi
echo "── $PASS passed, $FAIL failed"
[ "$FAIL" = 0 ]