diff --git a/.env.example b/.env.example index bed5818..5f8f59b 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/.gitea/workflows/lint.yml b/.gitea/workflows/lint.yml index d01a834..c6808b9 100644 --- a/.gitea/workflows/lint.yml +++ b/.gitea/workflows/lint.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 24a9725..1c15aab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index aad1599..584a1c3 100644 --- a/README.md +++ b/README.md @@ -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 `. `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: diff --git a/entrypoint-user.sh b/entrypoint-user.sh index 6e1e482..3efcf9a 100755 --- a/entrypoint-user.sh +++ b/entrypoint-user.sh @@ -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" diff --git a/rootfs/usr/local/lib/pi-devbox/link-pi-models.sh b/rootfs/usr/local/lib/pi-devbox/link-pi-models.sh new file mode 100755 index 0000000..550c403 --- /dev/null +++ b/rootfs/usr/local/lib/pi-devbox/link-pi-models.sh @@ -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 diff --git a/scripts/recreate-sanity-check.sh b/scripts/recreate-sanity-check.sh index 4d28b00..85695ae 100755 --- a/scripts/recreate-sanity-check.sh +++ b/scripts/recreate-sanity-check.sh @@ -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 diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index aed10a5..9aa15b4 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -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' diff --git a/scripts/test-link-pi-models.sh b/scripts/test-link-pi-models.sh new file mode 100755 index 0000000..84f6040 --- /dev/null +++ b/scripts/test-link-pi-models.sh @@ -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 ]