Compare commits

..

10 Commits

Author SHA1 Message Date
joakimp 4ee94da523 release(v1.11.0): pi 1.1.0, pi-obsmem 022da5e, pi-atelier v0.15.0, all floating refs at newest
Lint / hadolint (push) Successful in 10s
Lint / skill-floor (push) Successful in 11s
Lint / actionlint (push) Successful in 19s
Lint / doc-drift (push) Successful in 22s
Publish Docker Image / lint-gate (push) Successful in 23s
Publish Docker Image / resolve-versions (push) Successful in 17s
Publish Docker Image / base-decide (push) Successful in 11s
Publish Docker Image / build-base (push) Successful in 50m31s
Publish Docker Image / smoke (push) Successful in 5m26s
Publish Docker Image / smoke-studio (push) Successful in 11m35s
Publish Docker Image / build-variant (push) Successful in 17m18s
Publish Docker Image / update-description (push) Successful in 9s
Publish Docker Image / promote-base-latest (push) Successful in 8s
Publish Docker Image / build-variant-studio (push) Successful in 20m28s
- ARG PI_VERSION 1.0.0 -> 1.1.0. Audited against the npm tarballs: the only
  breaking change in 1.0.1..1.1.0 is 1.0.3's azure-openai-responses -> azure
  rename, which nothing here uses. pi-agent-core's finishTurn handling is
  byte-identical between 1.0.0 and 1.1.0.
- ARG PI_OBSMEM_REF 731c3d4 -> 022da5e (= master): #81 compaction keeps
  unobserved context, #93 prebuilt dist/index.ts. src/agents/ unchanged.
- ARG PI_ATELIER_REF/VERSION v0.13.0 -> v0.15.0. Sidebar Auto/Manual modes,
  Manual by default. README's sidebar section updated.
- SKILLSET_SNAPSHOT_REF e9e45f7 -> 96b840e via vendor-mempalace-skill.sh.
  The vendored bytes are unchanged.
- Dockerfile.base BASE_REBUILD_DATE marker refreshed, at no cost, because the
  base rebuilds anyway (rootfs + entrypoint-user.sh changed in 11e512e).
- CHANGELOG: Unreleased -> v1.11.0 with the audit. The CI bypass of the obsmem
  pin is recorded under Still open.

Exercised before tagging, with pi 1.1.0 from a temp prefix in a real pty
against -ne controls:
- obsmem turn cap: 11 turns uncapped, exactly 3 capped.
- atelier v0.15.0 paints in both renderers (controls 0/3, render-proofed).
- obsmem 022da5e registers /om:status and /om:view.
- All 11 pi-extensions a88fa99 extensions load.

Local gates: check-doc-drift (rc 0, check 9 names every moved component),
lint-shell, check-workflow-shell, test-link-pi-models (13/13),
check-dockerfile-directives, check-skill-floor, check-base-hash.
2026-10-12 16:54:55 +02:00
Joakim Persson 3f46f5fd29 changelog(unreleased): pi-task fallback covers the task children the AWS SSO keeper leaves alone
Lint / skill-floor (push) Successful in 11s
Lint / hadolint (push) Successful in 13s
Lint / actionlint (push) Successful in 21s
Lint / doc-drift (push) Successful in 20s
The keeper (pi-extensions aws-sso.ts, entry by pi@mbp-m1-2020 in eb70928) acts
only in interactive sessions: ctx.hasUI in the code, and the troubleshooting row
in docs/aws-sso.md. A pi-task child that hits an ended SSO session is the case
the new fallback handles, so the two entries now point at each other. The keeper
entry itself is unchanged.
2026-10-12 16:34:41 +02:00
Joakim Persson 09ce100c92 changelog(unreleased): secret-guard (pi-extensions a88fa99) and pi-task model fallback (pi-toolkit 3760d90)
Lint / hadolint (push) Successful in 12s
Lint / skill-floor (push) Successful in 10s
Lint / actionlint (push) Successful in 22s
Lint / doc-drift (push) Successful in 16s
Both arrive with the next build without a pin change, because CI resolves
pi-extensions and pi-toolkit main at build time. The components table now names
them: pi-extensions 143a214 -> a88fa99, and a new pi-toolkit row 9c87ee8 ->
3760d90. Both ids were checked against ls-remote, and the baked values against
/etc/pi-devbox/build-manifest.json on a v1.10.2 device.
2026-10-12 16:28:28 +02:00
Joakim Persson 11e512e497 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.
2026-10-12 16:28:28 +02:00
pi@mbp-m1-2020 eb70928b16 changelog(unreleased): AWS SSO keeper (pi-extensions f885199); pi-studio v0.9.63 named
Lint / hadolint (push) Successful in 10s
Lint / skill-floor (push) Successful in 12s
Lint / doc-drift (push) Successful in 18s
Lint / actionlint (push) Successful in 19s
pi-extensions 143a214 -> f885199 adds aws-sso.ts: renews the Bedrock SSO
session just after IdC ends it (end read from AWS's token cap), local mode
via the host browser, remote mode via device code + ntfy push. Measured on
mbp-m1-2020 2026-10-11/12. pi-studio moved upstream 641aa32 -> fae3ad3
(v0.9.63) and CI's newest-tag resolution will bake it; named so
check-doc-drift passes, not yet reviewed.
2026-10-12 09:20:48 +02:00
pi@mbp-m1-2020 32448c7353 docs(.env.example): document the PI_AWS_SSO_* variables of the pi-extensions AWS SSO keeper
All optional and commented out. Guide: pi-extensions docs/aws-sso.md.
2026-10-11 23:00:17 +02:00
Joakim Persson 56e3742a2d fix(ci): skip the BuildKit check whose warning embedded this Dockerfile in CI output
Lint / hadolint (push) Successful in 10s
Lint / skill-floor (push) Failing after 13s
Lint / doc-drift (push) Successful in 18s
Lint / actionlint (push) Successful in 33s
Publish Docker Image / lint-gate (push) Successful in 32s
Publish Docker Image / resolve-versions (push) Successful in 17s
Publish Docker Image / base-decide (push) Successful in 13s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke-studio (push) Successful in 5m38s
Publish Docker Image / smoke (push) Successful in 8m20s
Publish Docker Image / build-variant-studio (push) Successful in 20m34s
Publish Docker Image / build-variant (push) Successful in 21m5s
Publish Docker Image / update-description (push) Successful in 8s
Publish Docker Image / promote-base-latest (push) Successful in 9s
v1.10.1 (run 707) failed with ZERO failing steps: every step Success, `smoke`
printing "101 passed, 0 failed", `smoke-studio` "104 passed, 0 failed", both
jobs red. The clipboard fix from f3b3748 worked exactly as predicted; this is a
second, independent defect that run 704 had masked.

CHAIN, measured end to end rather than reasoned:

`ARG BASE_IMAGE` has no default on purpose (the two-phase build always supplies
it), which trips BuildKit's InvalidDefaultArgInFrom check. buildx attaches that
warning's source context to the build metadata as
buildx.build.warnings[].sourceInfo.data — the ENTIRE Dockerfile, base64, on ONE
line. docker/build-push-action writes that metadata to $GITHUB_OUTPUT as a
`name<<ghadelimiter_<uuid>` heredoc. Gitea's act_runner truncates any single
line at exactly 65536 chars, so the closing delimiter was cut off:

  invalid format delimiter 'ghadelimiter_...' not found before end of file

and the runner failed the job while attributing it to no step at all.

  v1.9.4  run 695 SUCCESS: longest metadata line 56355, 0 delimiter errors
  v1.10.0 run 704 failed : longest metadata line 65536, 1 delimiter error
  v1.10.1 run 707 failed : longest metadata line 65536, 1 delimiter error

65536 = 2^16: cut AT the cap, not merely long. Independent route via file size
rather than log parsing: 42251 B at v1.9.4 (base64 56335, matching the log) vs
50034 B now (base64 66712, truncated). The cap corresponds to a 49152 B
Dockerfile, so this cycle's comment growth crossed it by 882 B.

Located by asking which top-level metadata key CONTAINS the base64, instead of
assuming: it is buildx.build.warnings -> sourceInfo -> data in BOTH runs.

THIS IS THE SECOND FIX FOR THIS BUG. The first, `provenance: false` on the two
smoke build steps, was committed as 784fad7 and is REVERTED here: a real buildx
on another host showed provenance metadata present in both modes with a longest
string of 71 chars, i.e. the base64 was never in provenance. Shipping it would
have left the release broken a third time while looking like a fix.

Also rejected, each measured: floating action tags moving (act action-bundle
hashes byte-identical between runs 695 and 707, all four) and the build-check
annotation text changing (byte-identical).

FIX: `# check=skip=InvalidDefaultArgInFrom` as the FIRST line of
Dockerfile.variant — BuildKit parses `# check=` only before any other line, so
placement is load-bearing. No honest default exists for BASE_IMAGE: `scratch`
would satisfy the linter while being a lie, and would convert today's instant
"invalid reference format" into a failure deep in the build. Verified against
the actual edited file with `docker buildx build --check`: "Check complete, no
warnings found." Zero warnings means no sourceInfo, so the longest metadata line
drops 65536 -> ~1936 and the file's SIZE stops gating CI.

GUARD: scripts/check-dockerfile-directives.sh, wired into BOTH lint.yml and
docker-publish.yml's lint-gate, because a check that gates only `push` lets a
tag regress — the adoption slip that let doc-drift land 27 h after the v1.9.3
tag. It also fails if ARG BASE_IMAGE gains a default, so the directive cannot
rot into guarding a check that can no longer fire. Truth table, exit codes:
present 0, removed 1, demoted to line 2 → 1, ARG defaulted 1, file missing 2
(a gate that cannot run must not pass).

Release renamed v1.10.1 -> v1.10.2 with both failed tags left standing as
tombstones. Docs swept again (README "since" marker, Dockerfile decision
comments) because CI reads them from the TAG.

Gates: doc-drift 23 OK / 0 DRIFT; base-hash, workflow-shell, skill-floor,
lint-shell (17 files now), dockerfile-directives all rc=0.
2026-10-02 10:58:03 +02:00
Joakim Persson 784fad78f3 fix(ci): provenance: false on the smoke builds — base64(Dockerfile) crossed 64 KiB
Lint / hadolint (push) Successful in 9s
Lint / skill-floor (push) Successful in 12s
Lint / doc-drift (push) Successful in 18s
Lint / actionlint (push) Successful in 32s
v1.10.1 (run 707) failed with ZERO failing steps: every step reported Success,
`smoke` printed "Results: 101 passed, 0 failed", `smoke-studio` printed "104
passed, 0 failed", and both jobs went red anyway. The clipboard fix worked
exactly as predicted; this is a second, independent defect that run 704 hid.

MECHANISM, measured end to end:

buildx writes a provenance attestation into the metadata it returns, and that
metadata embeds the ENTIRE Dockerfile as a single base64 "data" field on ONE
line. docker/build-push-action writes that metadata to $GITHUB_OUTPUT as a
`name<<ghadelimiter_<uuid>` heredoc. Gitea's act_runner truncates any single
line at exactly 65536 chars, so once base64(Dockerfile.variant) crosses 64 KiB
the closing delimiter is cut off and the runner reports

  invalid format delimiter 'ghadelimiter_...' not found before end of file

then fails the job while attributing it to no step at all.

  v1.9.4  run 695 (SUCCESS): longest metadata line 56355 chars, 0 delimiter errors
  v1.10.0 run 704 (failed) : longest metadata line 65536 chars, 1 delimiter error
  v1.10.1 run 707 (failed) : longest metadata line 65536 chars, 1 delimiter error

65536 is exactly 2^16 — the line was TRUNCATED at the cap, not merely long.
Independent route via file size, not log parsing: Dockerfile.variant was 42251 B
at v1.9.4 (base64 56335, matching the log) and is 50034 B at HEAD (base64 66712,
cut to 65536). The cap corresponds to a 49152 B Dockerfile, so this cycle's
comment growth (+7783 B, mostly comments I wrote) crossed it by 882 B.

Hypotheses measured and REJECTED before landing this:
  - floating action tags moved: the act action-bundle hashes are IDENTICAL
    between run 695 and run 707 (all four), so no action changed version.
  - build-check annotation changed: the ::warning text is byte-identical.
  - stale clipboard assertion again: no, the suites print 0 failed.

WHY provenance: false and not shorter comments — trimming would restore the
margin and silently re-arm the trap for the next comment, with a failure mode of
"job red, no failing step, suite green", which cost most of this session to
diagnose once. Dropping the attestation removes the Dockerfile-size coupling
entirely.

BLAST RADIUS IS NIL for what ships: these two steps build with `load: true` and
the images are discarded after the suite runs, so the attestation has no
consumer. The PUBLISHED images are built by raw `docker buildx build --push` in
run: blocks (three call sites), which never write $GITHUB_OUTPUT metadata and
are therefore both unaffected by the bug and unchanged by this fix.

Verified: YAML parses; check-workflow-shell rc=0; doc-drift 23 OK / 0 DRIFT.
Next step is a `smoke_only=true` dispatch against main — the escape hatch this
workflow already documents — so the fix is proven before another tag is cut.
2026-10-02 10:50:16 +02:00
Joakim Persson c2c42c34a2 docs(v1.10.1): cut v1.10.1; v1.10.0 stays tagged-but-never-published
Lint / skill-floor (push) Successful in 11s
Lint / doc-drift (push) Successful in 12s
Lint / hadolint (push) Successful in 14s
Lint / actionlint (push) Successful in 19s
Publish Docker Image / resolve-versions (push) Successful in 12s
Publish Docker Image / base-decide (push) Successful in 13s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / lint-gate (push) Successful in 25s
Publish Docker Image / smoke-studio (push) Failing after 5m43s
Publish Docker Image / build-variant-studio (push) Has been skipped
Publish Docker Image / smoke (push) Failing after 8m21s
Publish Docker Image / build-variant (push) Has been skipped
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
v1.10.0's build failed one assertion of 104 and published nothing. Rather than
re-point a tag CI had already consumed, the number moves: a version that failed
its build should stay failed and readable, not be quietly overwritten with
different bytes.

CHANGELOG gains a v1.10.1 section carrying the smoke fix (moved out of
v1.10.0's Fixed list), and states plainly that everything under v1.10.0 ships
here so the big section stays the content record. v1.10.0's heading is marked
"tagged, never published — superseded by v1.10.1" with a blockquote naming the
commit (12f99c4), the run (704), the four jobs that skipped, and the fact that
no image carries the tag.

Pre-tag doc sweep, because CI reads docs from the TAG and POSTs DOCKER_HUB.md
to the Hub as full_description:

  - README "Terminal UI mode: fullscreen is the default (since v1.10.0)"
    -> v1.10.1. A "since" marker pointing at an image nobody can pull is a
    worse lie than no marker.
  - Dockerfile.variant decision comments for the pi and pi-atelier bumps
    -> v1.10.1 (these describe which release carries the change).
  - scripts/smoke-test.sh deliberately KEEPS "the v1.10.0 tag build" — that
    one is a historical event and v1.10.0 is its correct name.
  - DOCKER_HUB.md needed no change: its fullscreen note is version-free.

No code changes. RELEASE_TAG is derived from github.ref_name, so nothing in the
build hardcodes the version.

Base layer does NOT rebuild: no base input (Dockerfile.base, rootfs/**,
entrypoint.sh, entrypoint-user.sh) has changed since 12f99c4, so base-decide
will cache-hit base-dad0f365ff24, which run 704's build-base already pushed at
07:52:38Z. This retry skips the expensive half.

Gates: doc-drift 23 OK / 0 DRIFT / 0 SKIP; base-hash, workflow-shell,
skill-floor, lint-shell all rc=0.
2026-10-02 10:36:21 +02:00
Joakim Persson f3b3748223 fix(smoke): derive the clipboard assertion from what pi declares
Lint / skill-floor (push) Successful in 7s
Lint / actionlint (push) Successful in 17s
Lint / hadolint (push) Successful in 14s
Lint / doc-drift (push) Successful in 14s
v1.10.0's first tag build (run 704) failed on ONE assertion out of 104, and
published nothing. The assertion was wrong, not the image.

scripts/smoke-test.sh required @mariozechner/clipboard to be installed and
require()-able at every install site, failing hard when the family was absent.
pi 0.86.0 (#9163) "Replaced the external native clipboard dependency with
bundled asynchronous macOS, Windows, and X11 helpers while preserving platform
command and OSC 52 fallbacks". Measured in the published tarballs rather than
inferred from the changelog:

  pi 0.85.1 -> dependencies include @mariozechner/clipboard@0.3.9
  pi 0.87.1 -> no @mariozechner/* at all
  pi 1.0.0  -> no @mariozechner/* at all

So on a correct v1.10.0 image the package is legitimately gone, `if [ -z
"$sites" ]; then exit 1` fired, and both smoke jobs went red on the same line:
smoke 100 passed/1 failed, smoke-studio 103 passed/1 failed with every
studio-specific assertion green. build-variant, build-variant-studio,
promote-base-latest and update-description all skipped -> nothing published.
The gate worked; it was enforcing pi 0.85.1's dependency graph.

The guard is NOT deleted, because its "fail when absent" shape is the thing
that stops prune_foreign_natives() from deleting the binding instead of the
surplus platform copies. It now derives the expectation from what the image's
pi actually DECLARES:

  declares + install loads     -> pass
  declares + no install        -> FAIL (prune removed function)
  does not declare + no install-> pass (pi bundles it since 0.86.0)
  does not declare + install   -> FAIL (orphan nothing depends on)

That re-arms automatically if a future pi re-adds the dependency, which a
skip-if-absent would not.

Verified as that four-quadrant truth table on EXIT CODES, not messages, since
run() keys on status: rc=0/1/0/1 as listed. The final check extracted the
command string verbatim from the committed file and ran it through `sh -c` the
way run() does, so the escaping was tested as shipped rather than as drafted.

Gates: doc-drift 23 OK / 0 DRIFT; base-hash, workflow-shell, skill-floor,
lint-shell all rc=0. smoke-test.sh is not a base-hash input, so the base layer
does not rebuild.
2026-10-02 10:27:25 +02:00
13 changed files with 777 additions and 22 deletions
+24
View File
@@ -169,6 +169,30 @@ GIT_USER_EMAIL=
# AWS_ACCESS_KEY_ID=
# AWS_SECRET_ACCESS_KEY=
# ── AWS SSO keeper (pi-extensions aws-sso.ts; all optional) ──────────
# Renews the AWS SSO session for Bedrock when it ends (aws sso login); you or
# your signed-in browser still approve each login. Off until `/aws-sso on`.
# Guide: pi-extensions docs/aws-sso.md. Recreate the container after editing.
# PI_AWS_SSO_REFRESH=1 # force the keeper on (0 = force off)
# PI_AWS_SSO_MODE=local # local (default): browser on the Docker host
# # remote: device code + push (headless/ssh+tmux)
# # (a mode set with /aws-sso local|remote wins)
# PI_AWS_SSO_NTFY= # push target: https://ntfy.sh/<long-random-topic>
# # anyone knowing the topic can read the pushes
# PI_AWS_SSO_NTFY_TOKEN= # token for a protected topic (own ntfy server)
# PI_AWS_SSO_SSH_HOST=host # local: SSH alias of the Docker host
# PI_AWS_SSO_LOCAL_CMD= # local: command run on the host; {session} =
# # sso-session name. Default (POSIX host):
# # 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.
+7
View File
@@ -222,6 +222,13 @@ jobs:
- name: Components the next build would bake differently are named in the CHANGELOG
run: bash scripts/check-doc-drift.sh
# Runs HERE as well as in lint.yml deliberately. A guard that gates only
# `push` lets a tag regress — the adoption slip that let doc-drift land
# 27 h after the v1.9.3 tag. This one protects the tag build from the
# zero-failing-step failure mode that killed v1.10.0 and v1.10.1.
- name: Dockerfile.variant still opens with its BuildKit check directive
run: bash scripts/check-dockerfile-directives.sh Dockerfile.variant
resolve-versions:
# Gated: a defective tree must not reach a 46-minute base build.
needs: [lint-gate]
+12
View File
@@ -90,6 +90,18 @@ 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
# NO failing step. Also wired into docker-publish.yml's lint-gate.
run: bash scripts/check-dockerfile-directives.sh Dockerfile.variant
- name: Install actionlint (pinned)
env:
ACTIONLINT_VERSION: 1.7.12
+316 -1
View File
@@ -11,7 +11,322 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
---
## v1.10.0 — 2026-10-02
## v1.11.0 — 2026-10-12
Minor release. Headlines: **pi 1.1.0**, **secret env values are kept out of
model context by default** (pi-extensions `secret-guard`), an opt-in **AWS SSO
keeper** for long Bedrock runs, and **`~/.pi/agent/models.json` linked to a
mounted shared file** at every start. Everything else moves to its newest
upstream: pi-observational-memory `022da5e`, pi-atelier `v0.15.0`, pi-studio
`v0.9.63`. The base image is rebuilt (rootfs and `entrypoint-user.sh` changed),
so the base tools pinned to `latest` re-resolve too.
### Components that move with this release
| Component | Baked in v1.10.2 | v1.11.0 | Why |
|---|---|---|---|
| **pi** | `1.0.0` | **`1.1.0`** | audited, see *Changed* below |
| pi-obsmem | `731c3d4` | **`022da5e`** (pinned SHA, = `master`) | #81: compaction never drops unobserved context; #93: pi loads a prebuilt `dist/index.ts` (see *Changed*) |
| pi-atelier | `v0.13.0` (`34d26f1`) | **`v0.15.0`** (`3befe02`) | v0.14.0 Auto/Manual sidebar modes with auto-collapse; v0.15.0 starts in Manual, recolours panels, simpler Activity rows |
| skillset snapshot | `e9e45f7` | **`96b840e`** | provenance only: the vendored `SKILL.md` is byte-identical (sha256 `a1db86da…` at both refs) |
| pi-extensions | `143a214` | **`a88fa99`** (or later: CI resolves `main` at build time) | new `aws-sso.ts` (keeps the Bedrock SSO session alive) and `secret-guard.ts` (keeps secret env values out of model context); both below |
| pi-toolkit | `9c87ee8` | **`3760d90`** (or later: CI resolves `main` at build time) | `pi-task`: optional model fallback when the primary model fails auth before acting (below) |
| pi-studio | `v0.9.61` (`641aa32`) | **`v0.9.63`** (`fae3ad3`) | upstream releases (v0.9.62, v0.9.63), picked up by CI's newest-tag resolution; `-studio` variant only. Reviewed: browser-editor fixes only (WebKit cursor drift after re-wrap, re-layout after macOS autocorrect) plus `Ctrl+Shift+H` to toggle highlighting; peerDependencies unchanged: [compare](https://github.com/omaclaren/pi-studio/compare/641aa3288a6bfd4a7eb37a3a4847836de85e0593...fae3ad38d23c9666981bfc29f9a4e06d56f8de84) |
### 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`)".
- **secret-guard (pi-extensions `secret-guard.ts`): secret env values never reach
the model, the session file or the palace feed.** It replaces the exact value
of every env var whose name says secret (`…TOKEN`, `…API_KEY`, `…PASSWORD`, …)
with `<redacted:NAME>`, the same rule and placeholder as mempalace-toolkit's
feed redactor. It applies to every tool result (MCP tools included), to the
context of each request (covering user `!` commands and older sessions), and it
blocks a `write`/`edit` that would write such a placeholder back over the real
value. `$NAME` still expands in commands; only the output coming back is
rewritten.
Measured motivation, 2026-10-12: an agent's own `sed` failed to redact
`MEMPALACE_REMOTE_TOKEN=…` from `env | grep` output. The raw value had been in
local session files for two months, so each of those sessions had sent it to
its model provider. The palace stayed clean only because the feed redactor
works.
Not covered:
- live streaming output while a tool runs;
- the stored entry of a user `!` command;
- secrets not in the environment;
- `pi-task` children, which run `--no-extensions`.
`PI_SECRET_GUARD=off` disables it.
- **`pi-task` model fallback (pi-toolkit `bin/pi-task`).** A
`pi-task.fallbackProfiles` block in `settings.json` (per effort tier:
provider/id/thinking) lets a task retry once on another model. The retry
happens only when the primary fails with an auth-class error (an expired SSO
token, an invalid key) **before** it has called a tool or changed a watched
file, and with at least 30 s of wall budget left. Both attempts are recorded in
`result.json` and the audit dir, and the result line is tagged `[FALLBACK]`.
Without the block, behaviour is unchanged. It was needed because pi reports
such a failure only inside its JSON stream: `stopReason=error`, rc 0. Before
this, an expired Bedrock session failed every task as "no fenced json
envelope". The `fork` tool does not read this key. Documented in pi-toolkit's
README. It complements `/aws-sso` (below), which by design acts only in
interactive sessions (`ctx.hasUI`; pi-extensions `docs/aws-sso.md`,
troubleshooting). A `task` child that meets an ended SSO session gets no help
from the keeper.
- **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
`PI_AWS_SSO_REFRESH=1`), so other devices stay as they are.
Why this is needed: pi's AWS SDK already renews the 1-hour access token by itself.
The limit is the IAM Identity Center session behind it, the browser's AWS sign-in,
which the IdC admin caps (8 h here, measured three times). After it, AWS refuses
every renewal, and a long or overnight run stops. Renewing on a clock does not
work: a login while the browser sign-in is still valid completes silently but
*inherits* its end. Only a login after the end starts a new session.
How it finds the end: in the session's last hour AWS still issues tokens, but caps
them at the end. The keeper makes the SDK's own renewal attempt as soon as the
SDK's 5-minute window opens and reads the result from the token cache: a token
issued for under 55 min gives the end, and the login runs 30 s after it. A refused
renewal triggers the login at once. If a request fails anyway (pi closed, laptop
asleep), it logs in and sends `continue`, so the run resumes. It never handles
passwords or MFA, and tokens appear only as `sha256[:8]` fingerprints.
Two modes, set per device with `/aws-sso local|remote` or `PI_AWS_SSO_MODE`:
`local` (default) runs the login on the Docker host over `~/.ssh-local/config`,
so the host's signed-in browser normally completes it silently. `remote`
(headless server, ssh + tmux) uses the device-code flow in the container: link
and code shown in pi and pushed to the phone via [ntfy](https://ntfy.sh)
(`PI_AWS_SSO_NTFY`; the topic name is a secret, shown only as a fingerprint).
Pushes also report a `local` login waiting for you, a failed login and a renewal.
Measured on mbp-m1-2020 (macOS host, `local`), 2026-10-11/12: overnight, two
unattended silent logins with nobody at the Mac. Then, with the final rule: the
end was read from the cap at 08:08:57 (session end 08:47:55), and the login ran
at 08:48:40 and completed in 7 s with a new full-hour token, before any request
failed.
`.env.example` documents the `PI_AWS_SSO_*` variables. The setup guide (modes
per device type, ntfy, log reference, troubleshooting) is pi-extensions
[`docs/aws-sso.md`](https://gitea.jordbo.se/joakimp/pi-extensions/src/branch/main/docs/aws-sso.md).
`.env` changes need a container recreate. To try a variable without one, run a
second pi as the right user: `docker exec` defaults to **root** here (the image
sets no `USER`), and root has no `~/.aws` ("Could not load credentials from any
providers"). Use
`docker exec -it -u developer -e PI_AWS_SSO_NTFY=… pi-devbox bash -lc pi`.
### Changed
- **pi `1.0.0` → `1.1.0`.** Audited 2026-10-12 against the published npm
tarballs, by the same method as the v1.10.2 audit (the full note is above
`ARG PI_VERSION` in `Dockerfile.variant`):
- The only `### Breaking Changes` section in 1.0.1..1.1.0 is 1.0.3's rename
of the `azure-openai-responses` provider to `azure`. Nothing here uses it.
- `finishTurn` handling in pi-agent-core is byte-identical between 1.0.0 and
1.1.0. The six named `pi.*` APIs our extensions call (`registerTool`,
`registerCommand`, `registerFlag`, `getFlag`, `sendMessage`,
`sendUserMessage`) appear in the same dist files in both versions, and so do
the TUI internals atelier hooks. `engines.node` is unchanged.
- User-visible: `Home`/`End` now always move the editor cursor (fullscreen
transcript top/bottom moved to `Ctrl+Home`/`Ctrl+End`). Terminals that
support OSC 7501 now see pi's working/blocked/done status; set
`PI_PROGRAM_STATUS=0` to turn that off.
- Exercised before tagging with pi 1.1.0 from a temp prefix, in a real pty,
with `--no-extensions` controls:
- The obsmem turn cap held on the real 1.1.0 agent loop: uncapped ran 11
turns, capped stopped at exactly 3.
- atelier v0.15.0 painted in both renderers (controls 0/3, every arm
render-proofed).
- obsmem `022da5e` registered `/om:status` and `/om:view`.
- All 11 pi-extensions `a88fa99` extensions loaded without error.
- **pi-observational-memory `731c3d4` → `022da5e`**, still a full-SHA pin; no
tag newer than 3.1.4 exists.
- #81 changes compaction. Entries the observer has not reached yet are no
longer cut out of context, and memory workers yield to the session. New
settings (`compactionMaxRetainedTokens`, `consolidateWhenIdle`,
`piAutoCompactionEnabled`) all have defaults, so nothing needs setting.
- #93 makes pi load a committed `dist/index.ts` bundle instead of 32 TS
sources, which is faster to start.
- `src/agents/` is unchanged between the two SHAs, so the workers'
`finishTurn` migration is intact.
- **pi-atelier `v0.13.0` → `v0.15.0`.** The sidebar now starts in **Manual**
mode. `/atelier sidebar auto` collapses it when the terminal is narrow and
reopens it when there is room. `showSidebarOnStartup` keeps its key and its
default. README's sidebar section describes both modes.
- **Base rebuilt** (`base-<hash>` changes because `rootfs/` and
`entrypoint-user.sh` changed). The base tools pinned to `latest` re-resolve.
Upstream on 2026-10-12: uv 0.13.0 (its breaking change makes Python 3.15 the
default *download*; mempalace still installs with `uv tool install` on
Debian's python3.13, which uv prefers because it is already installed), nvim
0.12.6, fzf 0.74.5. `# BASE_REBUILD_DATE:` is updated while it is free.
### Still open
- `remote` mode end to end (code → push → approve on the phone) is unit-tested and
its push path is verified against ntfy.sh, but no live device-code renewal has
run yet.
- `local` is verified on a macOS host only. On a Linux or Windows host, the login
started over SSH may not reach the desktop browser; `PI_AWS_SSO_LOCAL_CMD`
overrides the host command.
- AWS refused renewals twice with time left in the session (a probe 5 min before
the end, a failed request about 2 h before it). The cause is unknown; both times
a login then worked at once.
- The real fix is administrative: the IdC admin can raise the session length (up to
90 days), after which the keeper has almost nothing to do.
- **The pi-obsmem pin does not bind CI builds.** `resolve-versions` in
`docker-publish.yml` still resolves `pi-observational-memory` from `master`
and passes it as `--build-arg PI_OBSMEM_REF`, which overrides the Dockerfile
default. This was verified on both sides with buildx: without the build-arg
the pin is baked, with it `master` is. The pin reached v1.9.5, v1.10.2 and
this release only because `master` equalled the pin at build time.
`check-doc-drift.sh` reads the ARG, so it would not notice a divergence. Fix
it by reading the pin from `Dockerfile.variant` in `resolve-versions`, the way
pi-atelier's tag already is.
---
## v1.10.2 — 2026-10-02
**v1.10.0 and v1.10.1 were both tagged and never published.** Neither failure
was in the image — both were in the test and CI harness, and each hid the other:
| tag | run | symptom | cause |
|---|---|---|---|
| v1.10.0 | 704 | `smoke` 100 passed / **1 failed**; `smoke-studio` 103 / **1** | a smoke assertion required a pi dependency upstream had deleted |
| v1.10.1 | 707 | `smoke` **101 passed / 0 failed**, `smoke-studio` **104 / 0**, and both jobs red anyway with **no failing step** | base64(`Dockerfile.variant`) in buildx's warning metadata crossed act_runner's 64 KiB line cap |
In both runs every publish job skipped, so no image, no `latest`, and no Hub
description was ever written — the gates did their job twice. **Everything
described under v1.10.0 below ships here**; that section remains the content
record and nothing in it changed.
The tag number moves each time rather than being re-pointed, because CI had
already consumed the previous one. A version that failed its build should stay
failed and readable, not be quietly overwritten with different bytes.
### Fixed
- **A job can fail with every step green, a smoke suite reporting `0 failed`,
and no failing step anywhere — and it cost two tags** — `Dockerfile.variant`
now opens with `# check=skip=InvalidDefaultArgInFrom`, and
`scripts/check-dockerfile-directives.sh` keeps it there.
The chain, measured rather than reasoned: `ARG BASE_IMAGE` deliberately has no
default (the two-phase build always supplies it), which trips BuildKit's
`InvalidDefaultArgInFrom` check. buildx attaches that warning's source context
to the build metadata as `buildx.build.warnings[].sourceInfo.data` — **the
entire Dockerfile, base64, on one line**. `docker/build-push-action` writes
that metadata to `$GITHUB_OUTPUT` as a `name<<ghadelimiter_<uuid>` heredoc,
and Gitea's act_runner truncates any single line at exactly 65536 chars. Once
base64 of the file passed 64 KiB the closing delimiter was cut off and the
runner reported `invalid format delimiter 'ghadelimiter_...' not found before
end of file`, then failed the job while attributing it to nothing.
Numbers: the file was 42251 B at v1.9.4 → base64 56355 chars, fine; it is
50034 B now → base64 66712, truncated to exactly 65536 (2^16, i.e. cut *at*
the cap, not merely long). The cap corresponds to a 49152 B Dockerfile, so
this release's comment growth — much of it comments added while documenting
the v1.10.0 round — crossed it by **882 bytes**. Confirmed two ways: by
parsing the metadata out of both job logs, and independently by arithmetic on
the file size at each tag. Verified fixed by running `docker buildx build
--check` against the actual edited file: *"Check complete, no warnings
found."* With zero warnings there is no `sourceInfo`, so the longest metadata
line drops from 65536 to ~1936 chars and the Dockerfile's **size stops being
coupled to whether CI passes**.
Hypotheses measured and rejected on the way, each of which would have produced
a wrong fix: floating action tags moving (the act action-bundle hashes are
byte-identical between the green v1.9.4 run and the red one — all four); the
build-check annotation text changing (byte-identical); and `provenance: false`,
which was written, tested against a real buildx, and **reverted** when the
metadata turned out to carry the base64 under `buildx.build.warnings`, not
under provenance. The published images are unaffected either way: they are
built by raw `docker buildx build --push`, which never writes
`$GITHUB_OUTPUT` metadata.
The new guard runs in **both** `lint.yml` and the publish workflow's
`lint-gate`, because a check that gates only `push` lets a tag regress — the
same adoption slip that let doc-drift land 27 h after the v1.9.3 tag. It
also fails if `ARG BASE_IMAGE` ever gains a default, so the skip directive
cannot rot into pointing at a check that can no longer fire. Truth table:
directive present → rc=0; removed → rc=1; demoted to line 2 → rc=1; ARG given
a default → rc=1; file missing → rc=2 (cannot-run must not pass).
- **The smoke suite asserted a pi dependency that upstream deleted, and it cost
this release its first tag build** — `scripts/smoke-test.sh` required
`@mariozechner/clipboard` to be installed and `require()`-able at every install
site. pi **0.86.0** (`#9163`) *"Replaced the external native clipboard
dependency with bundled asynchronous macOS, Windows, and X11 helpers while
preserving platform command and OSC 52 fallbacks"*. Measured in the published
tarballs: pi `0.85.1` declares `@mariozechner/clipboard@0.3.9`; `0.87.1` and
`1.0.0` declare no `@mariozechner/*` at all. So on a correct v1.10.0 image the
package is legitimately absent, the assertion's `if [ -z "$sites" ]; then exit
1` fired, and run 704 ended **100 passed / 1 failed** on `smoke` and **103
passed / 1 failed** on `smoke-studio` — same single assertion, every
studio-specific check green. `build-variant`, `build-variant-studio`,
`promote-base-latest` and `update-description` were all skipped, so **nothing
was published**: the gate behaved exactly as designed, against a stale
expectation rather than a real defect.
The fix does not delete the guard, because the guard was right: "fail when the
family is absent" is what stops `prune_foreign_natives()` from silently
deleting the binding instead of the surplus platform copies. It now **derives
its expectation from what the image's pi actually declares** — if pi declares
the dependency an install must exist and load; if pi does not, no install may
linger. That re-arms by itself should a future pi re-add it, and it still
catches an orphaned install. Verified as a four-quadrant truth table with the
command string extracted verbatim from the file and run through `sh -c` the way
`run()` invokes it: declared+present → rc=0, declared+absent → rc=1,
undeclared+absent → rc=0, undeclared+present → rc=1.
Worth stating plainly, since it is the whole value of the round: pi 1.0.0,
pi-atelier v0.13.0 and pi-studio v0.9.61 passed **103 of 104** assertions on a
pi MAJOR bump. The one red was the suite describing pi 0.85.1's dependency
graph, not the image.
---
## v1.10.0 — 2026-10-02 (tagged, never published — content shipped as v1.10.2)
> Tagged 2026-10-02 at commit `12f99c4`. Its build (run 704) went red on the
> stale clipboard assertion described under v1.10.1, so `build-variant`,
> `build-variant-studio`, `promote-base-latest` and `update-description` all
> skipped and nothing reached the Hub. No image carries this tag. The content
> below is accurate and shipped as **v1.10.2**. v1.10.1 (commit `c2c42c3`,
> run 707) was the first attempt at republishing it and died on the separate
> act_runner line-cap bug described under v1.10.2.
Three small fixes found by the v1.9.4 first-boot acceptance and the CI base
hash prediction, plus one boot-time wiring fix that stops a recurring `git push`
+1 -1
View File
@@ -14,7 +14,7 @@
# content-addressed over this file, so any byte change invalidates the
# cache. Recommended cadence: once per release for security updates.
#
# BASE_REBUILD_DATE: 2026-09-22 (v1.9.4 — mempalace 3.9.0 -> 3.10.0 with ENV MEMPALACE_CONFIG_DIR pinning the layout, mempalace-toolkit 2167a1b explicit event_list order; previous marker 2026-09-19 / v1.9.3)
# BASE_REBUILD_DATE: 2026-10-12 (v1.11.0 — rootfs gains link-pi-models.sh, entrypoint-user.sh links models.json; base tools re-resolve `latest` (upstream on 2026-10-12: uv 0.13.0, nvim 0.12.6, fzf 0.74.5); previous marker 2026-09-22 / v1.9.4)
#
# ── Lineage note ─────────────────────────────────────────────────────
# Adapted from opencode-devbox/Dockerfile.base (commit before v1.16.2).
+89 -8
View File
@@ -1,3 +1,37 @@
# check=skip=InvalidDefaultArgInFrom
#
# ^ MUST stay the FIRST line of this file, and it is load-bearing for CI, not
# style. BuildKit parses `# check=` only before any other line, so moving it
# below the title comment silently disables it.
#
# Why it exists: `ARG BASE_IMAGE` (below) deliberately has NO default — this
# file is only ever built by the two-phase CI with --build-arg BASE_IMAGE=
# <image>:base-<hash>. BuildKit's InvalidDefaultArgInFrom check flags that as
# "default value for global ARG results in an empty or invalid base image
# name". There is no honest default to give it: `scratch` would satisfy the
# linter while being a lie (nothing here can build FROM scratch), and it would
# convert today's instant "invalid reference format" into a failure deep in the
# build. So the check is skipped by name, with the reason written down.
#
# What the warning actually COST, which is why this is not cosmetic: buildx
# attaches the warning's source context to the build metadata as
# buildx.build.warnings[].sourceInfo.data — the ENTIRE Dockerfile, base64, on
# ONE line. docker/build-push-action writes that metadata to $GITHUB_OUTPUT as
# a `name<<ghadelimiter_<uuid>` heredoc, and Gitea's act_runner truncates any
# single line at exactly 65536 chars. Once base64(this file) crossed 64 KiB the
# closing delimiter was cut off, and the runner failed the job with
# invalid format delimiter 'ghadelimiter_...' not found before end of file
# and NO failing step: every step green, smoke suite "0 failed", job red.
# Measured: 42251 B at v1.9.4 -> base64 56355 (fine); 50034 B at v1.10.1 ->
# base64 66712, truncated to exactly 65536. The cap corresponds to a 49152 B
# Dockerfile, so this release's comment growth crossed it by 882 B. It killed
# v1.10.0 (run 704, where a stale clipboard assertion hid it) and v1.10.1
# (run 707). With zero warnings there is no sourceInfo, so the longest metadata
# line drops from 65536 to ~1936 chars and the Dockerfile's SIZE stops being
# coupled to CI passing at all.
#
# If this ever recurs — job red, zero failing steps, suite reporting 0 failed —
# grep the job log for `ghadelimiter` first.
# pi-devbox — variant image
#
# FROMs a base-<hash> image produced by Dockerfile.base and adds only
@@ -154,11 +188,11 @@ ARG USER_NAME=developer
# (e7d77dc -> 1529e14 -> 731c3d4 in the nine days to 2026-10-01), so waiting for
# a tag means holding pi indefinitely. A full SHA keeps the one property
# `master` does not have: rebuilding this tag later produces the SAME image.
# v1.9.5 SKIPPED 0.99.x and 1.0.0 for lack of evidence. v1.10.0 went and got the
# v1.9.5 SKIPPED 0.99.x and 1.0.0 for lack of evidence. v1.10.2 went and got the
# evidence instead of waiting for obsmem to mention a version, because "no issue
# names 1.0" is an absence of a statement, not a measurement.
#
# v1.10.0: 0.87.1 -> 1.0.0. MEASURED 2026-10-02 against the published npm
# v1.10.2: 0.87.1 -> 1.0.0. MEASURED 2026-10-02 against the published npm
# tarballs for 0.87.1 and 1.0.0, unpacked side by side:
# 1. NO `### Breaking Changes` SECTION IN 1.0.0 AT ALL. The changelog's
# breaking sections belong to 0.87.0, 0.86.0, 0.84.3, 0.84.0, 0.83.0,
@@ -197,7 +231,34 @@ ARG USER_NAME=developer
# is documented (README -> "Terminal UI mode"): `"tuiMode": "regular"` in
# ~/.pi/agent/settings.json, `pi --tui-mode regular` for one session, or the
# same key in a project's .pi/settings.json.
ARG PI_VERSION=1.0.0
#
# v1.11.0: 1.0.0 -> 1.1.0. MEASURED 2026-10-12 against the published npm
# tarballs for 1.0.0 and 1.1.0, unpacked side by side, by the same method as
# the v1.10.2 audit above:
# 1. ONE `### Breaking Changes` section in 1.0.1..1.1.0: 1.0.3 renamed the
# provider `azure-openai-responses` -> `azure`. 0 uses here: grepped
# rootfs/, ~/.pi/agent/settings.json, pi-toolkit's settings.example.json
# and the fleet's shared models.json. Everything else is additive or a fix
# (OSC 7501 program status, `+name`/`-name` in --tools, `durationMs` and
# `outputPad` in the tool render context, `aborted` on agent_settled).
# Two user-visible changes: Home/End always move the editor cursor
# (fullscreen transcript top/bottom moved to Ctrl+Home/Ctrl+End, 1.0.3),
# and OSC 7501 status reporting (PI_PROGRAM_STATUS=0 turns it off, 1.1.0).
# 2. pi-agent-core 1.0.0 -> 1.1.0: the finishTurn context in dist is
# BYTE-IDENTICAL; the only agent-loop diff adds `durationMs`. The obsmem
# turn-cap probe drove the real 1.1.0 agentLoop: uncapped ran its chosen
# 11 turns, maxTurns=3 stopped at exactly 3 + agent_end.
# 3. The 6 named pi.* APIs (registerTool, registerCommand, registerFlag,
# getFlag, sendMessage, sendUserMessage) appear in the same number of dist
# files in 1.0.0 and 1.1.0. atelier's internals TuiMainScreen and
# renderLayoutFrame are both still in 1.1.0's dist.
# 4. engines.node is still `>=22.19.0`; the image ships 24.x.
# Exercised before tagging, from a temp prefix with pi 1.1.0 and a throwaway
# PI_CODING_AGENT_DIR, in a real pty, with -ne controls: atelier v0.15.0 paints
# ACTIVITY/READY/TOOLS in BOTH renderers (controls 0/3, every arm render-proofed);
# obsmem 022da5e registers /om:status and /om:view (control: none); and all 11
# pi-extensions a88fa99 extensions load with no error (control: no /aws-sso).
ARG PI_VERSION=1.1.0
ARG PI_TOOLKIT_REF=main
ARG PI_EXTENSIONS_REF=main
# Repo URLs default to the canonical gitea origin but are overridable so a
@@ -219,7 +280,18 @@ ARG PI_OBSMEM_REPO=https://github.com/elpapi42/pi-observational-memory.git
# 40-char match (SHA40), so a short pin would fall through to its
# branch-or-tag lookup, fail, and downgrade that component's drift check to a
# silent SKIP — a pin that reads fine and is no longer verified.
ARG PI_OBSMEM_REF=731c3d49288580f4d79cabdbfbc0d16b34db0f41
# v1.11.0: 731c3d4 -> 022da5e (master, 2026-10-05; still no newer tag than
# 3.1.4). Two commits: #81 never drops unobserved context at compaction
# (new settings compactionMaxRetainedTokens, consolidateWhenIdle,
# piAutoCompactionEnabled, all defaulted), and #93 makes pi load a prebuilt
# dist/index.ts instead of 32 TS sources. The bundle is committed, so
# `npm install --omit=dev` below needs no build step. src/agents/ is
# UNCHANGED between the two SHAs, so the finishTurn migration is intact.
# KNOWN GAP, not fixed here: docker-publish.yml's resolve-versions still
# resolves obsmem from `master` and passes it as --build-arg, which overrides
# this default (verified two-sided on buildx 2026-10-12). CI builds therefore
# get the pin only while master == pin, as it is at this release.
ARG PI_OBSMEM_REF=022da5e5554d04dfdc876ed7dac4a42c6b83dfd4
# pi-atelier (TUI sidebar: ordered panels, split-pane, themes) is PINNED TO A
# TAG, which CI resolves to that tag's commit SHA — same treatment as
# pi-studio, for reproducibility plus cache-busting.
@@ -273,7 +345,7 @@ ARG PI_ATELIER_REPO=https://github.com/michaelmjhhhh/pi-atelier.git
# pi >=0.84.0, so it still spans the pinned 0.85.1. 13 commits v0.10.1..v0.10.3,
# all under src/ tests/ docs/ scripts/ plus metadata; no entry-point move.
#
# v1.10.0: v0.10.3 -> v0.13.0, closing the two-minor gap v1.9.5 flagged as the
# v1.10.2: v0.10.3 -> v0.13.0, closing the two-minor gap v1.9.5 flagged as the
# residual risk of the pi bump. peerDependencies are UNCHANGED at pi >=0.84.0
# across v0.10.3, v0.12.1 and v0.13.0 — still a FLOOR, so still not evidence of
# anything; the floor is satisfied by 1.0.0 either way. What was actually
@@ -298,9 +370,18 @@ ARG PI_ATELIER_REPO=https://github.com/michaelmjhhhh/pi-atelier.git
# - Residual: v0.13.0 is SAME-DAY upstream (2026-10-02). Acceptance proves
# the sidebar PAINTS with the two-sided check from 0.84.4/0.85.1 that can
# tell "loaded" from "silently absent" — "no crash" is not the test.
ARG PI_ATELIER_REF=v0.13.0
#
# v1.11.0: v0.13.0 -> v0.15.0 (v0.14.0 and v0.15.0 both 2026-10-03). The
# peerDependencies floor is unchanged (pi >=0.84.0), atelier still has zero
# runtime dependencies, and it still imports only the public package exports.
# Behaviour changes: v0.14.0 auto-collapses the Sidebar when the terminal is
# narrow (Auto/Manual modes). v0.15.0 starts in Manual mode, recolours the
# panels and simplifies the Activity rows. The startup preference is still
# `showSidebarOnStartup` (default true, src/config.ts:38). Painted in both
# renderers on pi 1.1.0 before tagging (see the PI_VERSION note).
ARG PI_ATELIER_REF=v0.15.0
# Human-readable tag PI_ATELIER_REF was resolved from; recorded as a label.
ARG PI_ATELIER_VERSION=v0.13.0
ARG PI_ATELIER_VERSION=v0.15.0
RUN set -e && \
# git_fetch_ref: clone-equivalent helper that accepts EITHER a branch name
@@ -625,7 +706,7 @@ ARG MEMPALACE_TOOLKIT_REF=main
# no ~67-minute base rebuild. (scripts/check-base-hash.sh scans only
# Dockerfile.base, so no folding into the base hash is required — nor would
# it be correct, since this ARG changes nothing about the base's contents.)
ARG SKILLSET_SNAPSHOT_REF=e9e45f7acdde490c3b5d24ce5f508bff8785c2c7
ARG SKILLSET_SNAPSHOT_REF=96b840ee43beca65a190772021a8ef370145cde1
# Dockerfile.base sets description="pi-devbox — base image (variant-independent)"
# and every variant INHERITS it, so both published images used to advertise
+43 -6
View File
@@ -358,7 +358,7 @@ DOT syntax errors instead of crashing. Then in Studio: open the PNG (or a
`.md` that embeds it) and hit **refresh-from-disk** after each edit.
Note: SVG is **not** in Studio's local-image-link allowlist — use PNG.
## Terminal UI mode: fullscreen is the default (since v1.10.0)
## Terminal UI mode: fullscreen is the default (since v1.10.2)
pi **1.0.0** changed the default terminal UI mode to **fullscreen**, and this
image adopts upstream's default rather than overriding it. Fullscreen draws into
@@ -464,7 +464,10 @@ pin the order yourself.
The sidebar auto-hides below 92 terminal columns and keeps the main pane at
least 64 columns wide, so a narrow terminal degrades to the plain TUI rather
than a squeezed one.
than a squeezed one. That is **Manual** mode, the default since atelier v0.15.0.
`/atelier sidebar auto` switches to **Auto** mode, which collapses the sidebar
when space is tight (below 124 columns at the default width) and reopens it at
132. Manual width adjustment is disabled in Auto mode.
## docker-compose.yml — basic shape
@@ -879,6 +882,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:
@@ -1161,7 +1198,7 @@ persisted volumes survived, and pi runtime wiring is intact:
```bash
./scripts/recreate-sanity-check.sh # auto-detects variant
./scripts/recreate-sanity-check.sh --expected-image-version 1.8.9 # assert the pi-devbox release tag
./scripts/recreate-sanity-check.sh --expected-version 1.0.0 # assert the pi coding agent version
./scripts/recreate-sanity-check.sh --expected-version 1.1.0 # assert the pi coding agent version
```
Those are **two different versions**, and the flags are not interchangeable:
@@ -1200,9 +1237,9 @@ resolved to `latest` at build time:
| Component | Pin | Where |
|---|---|---|
| pi | `1.0.0` | `ARG PI_VERSION` — `Dockerfile.variant` |
| pi-obsmem | `731c3d49288580f4d79cabdbfbc0d16b34db0f41` | `ARG PI_OBSMEM_REF` — `Dockerfile.variant` |
| pi-atelier | `v0.13.0` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
| pi | `1.1.0` | `ARG PI_VERSION` — `Dockerfile.variant` |
| pi-obsmem | `022da5e5554d04dfdc876ed7dac4a42c6b83dfd4` | `ARG PI_OBSMEM_REF` — `Dockerfile.variant` |
| pi-atelier | `v0.15.0` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
| mempalace | `3.10.0` | `ARG MEMPALACE_VERSION` — `Dockerfile.base` |
The objective is **not** to freeze versions. Bumping is routine — usually one
+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
+69
View File
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
# Guard: Dockerfile.variant must OPEN with its BuildKit `# check=` directive.
#
# Why this is a gate and not a comment. BuildKit parses `# check=` ONLY before
# any other line in the file, so moving it below the title comment — or dropping
# it during an unrelated header edit — silently re-enables the
# InvalidDefaultArgInFrom warning on `ARG BASE_IMAGE` / `FROM ${BASE_IMAGE}`.
#
# A re-enabled warning is not cosmetic. buildx attaches the warning's source
# context to the build metadata as buildx.build.warnings[].sourceInfo.data: the
# entire Dockerfile, base64, on ONE line. docker/build-push-action writes that
# metadata to $GITHUB_OUTPUT as a `name<<ghadelimiter_<uuid>` heredoc, and
# Gitea's act_runner truncates any single line at exactly 65536 chars. Since
# base64(Dockerfile.variant) passed 64 KiB (50034 B source -> 66712 chars, cut
# to 65536), the closing delimiter is lost and act_runner fails the job with
# invalid format delimiter 'ghadelimiter_...' not found before end of file
# while attributing it to NO step: every step reports Success, the smoke suite
# prints "0 failed", and the job is red regardless. That cost two tags —
# v1.10.0 (run 704, where a stale clipboard assertion masked it) and v1.10.1
# (run 707) — and most of a session to localise.
#
# So: one deterministic grep, run both on push AND in the publish workflow's
# lint-gate. A check that gates only `push` lets a tag regress — the same
# adoption slip that let doc-drift land 27 h after the v1.9.3 tag.
#
# Exit codes: 0 OK, 1 violation, 2 cannot-run (missing file) — matching
# lint-shell.sh / check-skill-floor.sh / check-doc-drift.sh, because a gate that
# cannot run must not pass.
set -euo pipefail
DF="${1:-Dockerfile.variant}"
EXPECTED='# check=skip=InvalidDefaultArgInFrom'
if [ ! -f "$DF" ]; then
echo "::error::check-dockerfile-directives: '$DF' not found (cannot-run)" >&2
exit 2
fi
first="$(head -n 1 "$DF")"
if [ "$first" != "$EXPECTED" ]; then
{
echo "::error::$DF line 1 must be exactly: $EXPECTED"
echo "::error::found instead: ${first:-<empty>}"
echo "::error::"
echo "::error::BuildKit only honours '# check=' before any other line. Without it the"
echo "::error::InvalidDefaultArgInFrom warning returns, buildx embeds this whole file as"
echo "::error::base64 in buildx.build.warnings[].sourceInfo.data, that single line exceeds"
echo "::error::act_runner's 65536-char cap, and the smoke jobs fail with"
echo "::error:: invalid format delimiter 'ghadelimiter_...' not found before end of file"
echo "::error::and NO failing step. See the header of $DF for the full measurement."
} >&2
exit 1
fi
# Second, independent assertion: the condition the directive exists FOR. If a
# later edit gives ARG BASE_IMAGE a default, the directive becomes dead weight
# and should be removed deliberately rather than left to rot — and if the ARG is
# renamed, this guard would otherwise keep passing while guarding nothing.
if ! grep -qE '^ARG BASE_IMAGE$' "$DF"; then
{
echo "::error::$DF no longer contains a bare 'ARG BASE_IMAGE' (no default)."
echo "::error::That is the only thing '$EXPECTED' suppresses. Either restore the bare ARG"
echo "::error::or drop the directive and this guard together — do not leave a skip"
echo "::error::directive pointing at a check that can no longer fire."
} >&2
exit 1
fi
echo "Dockerfile directive guard OK — $DF opens with the check-skip directive and still declares a bare ARG BASE_IMAGE."
+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
+24 -6
View File
@@ -30,7 +30,7 @@
# client bundle present + registered via `pi install`
# - no foreign npm-11 platform packages (@esbuild, clipboard) beyond the host
# - no build-time npm cache (/root/.npm) shipped in the image
# - esbuild compiles + clipboard native loads at every install site
# - esbuild compiles everywhere; clipboard present iff pi declares it
# - image size within threshold
set -euo pipefail
@@ -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'
@@ -986,11 +988,27 @@ run "no build-time npm cache shipped (/root/.npm)" \
run "esbuild works at every install site (prune removed weight, not function)" \
'sites=$(find /usr/lib/node_modules /opt -type d -path "*/node_modules/esbuild" -prune 2>/dev/null); if [ -z "$sites" ]; then echo "no esbuild install found at all" >&2; exit 1; fi; for d in $sites; do node -e "require(\"$d\").transformSync(\"const x:number=1\",{loader:\"ts\"})" || { echo "esbuild broken at $d" >&2; exit 1; }; done; echo ok'
# Clipboard is the family pruned second, and its napi-rs loader picks its native
# binding at require() time — so a successful load IS the proof that the kept
# platform package is the one this image needs.
run "clipboard native loads at every install site" \
'sites=$(find /usr/lib/node_modules /opt -type d -path "*/node_modules/@mariozechner/clipboard" -prune 2>/dev/null); if [ -z "$sites" ]; then echo "no @mariozechner/clipboard install found at all" >&2; exit 1; fi; for d in $sites; do node -e "var c=require(\"$d\"); if (typeof c.setText !== \"function\") { throw new Error(\"native binding missing\"); }" || { echo "clipboard native broken at $d" >&2; exit 1; }; done; echo ok'
# Clipboard is the family pruned second. Upstream pi 0.86.0 (#9163) REPLACED the
# external `@mariozechner/clipboard` dependency with bundled macOS/Windows/X11
# helpers plus the OSC 52 fallback, so from 0.86.0 on there is nothing external to
# prune and nothing to require(). pi 0.85.1 declared it; 0.87.1 and 1.0.0 do not.
# This assertion therefore derives its expectation from what the image's pi
# actually DECLARES rather than hardcoding either state: it re-arms by itself if a
# future pi re-adds the dependency, and it still fails if an install lingers that
# nothing depends on. Hardcoding "must exist" is what failed the v1.10.0 tag build
# (100 passed, 1 failed, nothing published) against a perfectly correct image.
run "clipboard native: present iff pi declares it (prune removed weight, not function)" \
'PKG=/usr/lib/node_modules/@earendil-works/pi-coding-agent/package.json;
if grep -q "\"@mariozechner/clipboard\"" "$PKG" 2>/dev/null; then declared=yes; else declared=no; fi;
sites=$(find /usr/lib/node_modules /opt -type d -path "*/node_modules/@mariozechner/clipboard" -prune 2>/dev/null);
if [ "$declared" = yes ]; then
[ -n "$sites" ] || { echo "pi declares @mariozechner/clipboard but NO install found - prune removed function" >&2; exit 1; };
for d in $sites; do node -e "var c=require(\"$d\"); if (typeof c.setText !== \"function\") { throw new Error(\"native binding missing\"); }" || { echo "clipboard native broken at $d" >&2; exit 1; }; done;
echo "ok: pi declares it and the native binding loads at every site";
else
[ -z "$sites" ] || { echo "pi no longer declares @mariozechner/clipboard yet installs remain: $sites" >&2; exit 1; };
echo "ok: pi bundles clipboard since upstream 0.86.0 (#9163); no external install expected";
fi'
# ── Image size ────────────────────────────────────────────────────────
echo ""
+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 ]