Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47b450694c | |||
| 7737156aad | |||
| 644be67ea2 | |||
| f7e35962d6 | |||
| b06941d714 | |||
| 00345d3536 | |||
| 057827a84b | |||
| 206fd1ec35 | |||
| 5d6ff537c4 | |||
| 779efbf4fe | |||
| e0611c787f | |||
| 12e66aa52e | |||
| fb2cb2305c | |||
| 8ae1659273 | |||
| 3b1c67a9bf | |||
| c693aa341d | |||
| ea6605bfae | |||
| a6abe17edc | |||
| 5becbba6c8 | |||
| 77929b73df | |||
| 10a8030620 | |||
| a8f5b35d2a | |||
| 1e4c238a83 | |||
| bf265f6d54 | |||
| 4efa84bb42 | |||
| fa38e3e102 | |||
| 1d53e008d4 | |||
| 28e324d68f | |||
| 3da7312166 | |||
| 4ee94da523 | |||
| 3f46f5fd29 | |||
| 09ce100c92 | |||
| 11e512e497 | |||
| eb70928b16 | |||
| 32448c7353 | |||
| 56e3742a2d | |||
| 784fad78f3 | |||
| c2c42c34a2 | |||
| f3b3748223 | |||
| 12f99c49e3 | |||
| 93eb3dbca7 | |||
| e026f6f65e | |||
| 9d0b3dec0b | |||
| cb8969ef2c | |||
| 31f36182bf | |||
| 2278b22ba7 | |||
| 37fcbfcf04 |
@@ -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.
|
||||
|
||||
@@ -9,9 +9,13 @@ name: Publish Docker Image
|
||||
# Pipeline shape:
|
||||
# 1. base-decide compute base hash from Dockerfile.base + rootfs/
|
||||
# + entrypoints; probe Docker Hub for existing tag.
|
||||
# 2. resolve-versions resolve pi @ npm 'latest', pi-fork/pi-obsmem refs
|
||||
# to commit SHAs (defeats registry-buildcache
|
||||
# cache-hit footgun on byte-identical build args).
|
||||
# 2. resolve-versions pi and mempalace from their pins (checked on npm/
|
||||
# PyPI); pi-obsmem and pi-atelier from their pins in
|
||||
# Dockerfile.variant; pi-fork and the Gitea repos to
|
||||
# their branch heads, pi-studio to its newest tag;
|
||||
# all refs to commit SHAs (defeats
|
||||
# registry-buildcache cache-hit footgun on
|
||||
# byte-identical build args).
|
||||
# 3. build-base only if probe missed; multi-arch push of base-<hash>.
|
||||
# 4. smoke amd64-only build of the variant FROMing the base
|
||||
# tag; runs scripts/smoke-test.sh.
|
||||
@@ -86,6 +90,12 @@ env:
|
||||
IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/pi-devbox
|
||||
RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || inputs.release_tag }}
|
||||
PROMOTE_LATEST: ${{ github.ref_type == 'tag' && 'true' || inputs.promote_latest }}
|
||||
# Label that protects a smoke job's loaded image from the disk reclaim every
|
||||
# job runs on the shared runner daemon (scripts/ci-reclaim-docker.sh says
|
||||
# why: v1.12.2 run 740 lost pi-devbox:smoke-studio to build-variant's prune
|
||||
# 30 ms after loading it). Set once here; the smoke builds apply it, the
|
||||
# reclaim script honours it, the smoke preflight asserts it.
|
||||
SMOKE_IMAGE_LABEL: se.jordbo.pi-devbox.ci-smoke-image
|
||||
|
||||
jobs:
|
||||
# ── Phase 1: decide whether base needs rebuilding ──────────────────
|
||||
@@ -108,7 +118,14 @@ jobs:
|
||||
id: compute
|
||||
run: |
|
||||
# Hash inputs that determine the base image's contents.
|
||||
# Order is fixed via `find -print0 | sort -z` for reproducibility.
|
||||
# Order is fixed via `find -print0 | LC_ALL=C sort -z`. The LC_ALL=C is
|
||||
# load-bearing: sort collates per locale, and a dictionary locale
|
||||
# (sv_SE/en_US.UTF-8) orders rootfs differently from byte order, giving
|
||||
# a different hash for identical content (measured 2026-09-22:
|
||||
# base-f40c4b7b103d under C/C.UTF-8 vs base-d8df62216a81 under
|
||||
# sv_SE.UTF-8). The runner ships LANG=C.UTF-8 today, so this pin
|
||||
# changes nothing now; it stops the hash depending on that accident.
|
||||
# Predicting base_tag locally MUST use the same prefix on this sort.
|
||||
# Junk filters: __pycache__/*.pyc and macOS metadata are gitignored
|
||||
# locally but still picked up by `find rootfs -type f` on a clean CI
|
||||
# checkout. Exclude them defensively.
|
||||
@@ -120,7 +137,7 @@ jobs:
|
||||
! -name '*.pyc' \
|
||||
! -name '.DS_Store' \
|
||||
! -name '._*' \
|
||||
-print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
|
||||
-print0 2>/dev/null | LC_ALL=C sort -z | xargs -0 cat 2>/dev/null
|
||||
cat entrypoint.sh entrypoint-user.sh
|
||||
# mempalace-toolkit is cloned in Dockerfile.base at a ref CI
|
||||
# resolves to a SHA; fold it in so base_tag changes when the
|
||||
@@ -179,7 +196,7 @@ jobs:
|
||||
# gap -- and for it the gap is strictly worse. Shellcheck judges the TREE:
|
||||
# green on main is still green at the tag, because the bytes did not move.
|
||||
# Check 9 judges the tree against UPSTREAM NOW, and the floating refs it
|
||||
# watches (PI_OBSMEM_REF=master and friends, which resolve-versions below turns
|
||||
# watches (PI_FORK_REF=master and friends, which resolve-versions below turns
|
||||
# into SHAs) move with no commit in this repo at all -- so a green reading on
|
||||
# main carries no information about tag time, and that window is exactly where
|
||||
# releases live. Worked example: pi-observational-memory moved cba0334 ->
|
||||
@@ -215,6 +232,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]
|
||||
@@ -372,15 +396,37 @@ jobs:
|
||||
fi
|
||||
echo "mempalace_version=${MEMPALACE_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# pi-fork / pi-observational-memory (GitHub) → commit SHAs.
|
||||
# pi-fork (GitHub) → master's commit SHA. Floats by design.
|
||||
FORK_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||
"https://api.github.com/repos/elpapi42/pi-fork/commits/master" || true)
|
||||
require_sha PI_FORK_REF "$FORK_REF"
|
||||
OBSMEM_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||
"https://api.github.com/repos/elpapi42/pi-observational-memory/commits/master" || true)
|
||||
require_sha PI_OBSMEM_REF "$OBSMEM_REF"
|
||||
echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# pi-observational-memory → the PINNED SHA in Dockerfile.variant.
|
||||
# Until v1.11.2 this resolved `master` and passed it as --build-arg,
|
||||
# which OVERRIDES the ARG default: the pin bound only local builds,
|
||||
# and CI baked it only while master == pin (v1.9.5, v1.10.2, v1.11.0).
|
||||
# The pin is a master SHA, not a tag, on purpose: obsmem tags rarely
|
||||
# and lands what pi needs on master (PR #83, pi 0.87's finishTurn,
|
||||
# never reached a tag). Its peerDependencies are `*`, so a mismatch
|
||||
# with pi is silent at runtime. Adopting master is therefore a
|
||||
# deliberate one-line bump after audit, never a side effect of the day
|
||||
# CI ran. Read like atelier's tag, and require the full 40 chars: a
|
||||
# short SHA would also downgrade check-doc-drift.sh's check to a SKIP.
|
||||
OBSMEM_REF=$(sed -n 's/^ARG PI_OBSMEM_REF=\([^[:space:]]*\).*/\1/p' Dockerfile.variant | head -n1)
|
||||
if ! printf '%s' "${OBSMEM_REF:-}" | grep -qiE '^[0-9a-f]{40}$'; then
|
||||
echo "::error::ARG PI_OBSMEM_REF in Dockerfile.variant is not a full 40-char commit SHA (got '${OBSMEM_REF:-<empty>}'). pi-observational-memory must stay pinned to an audited commit — see the note above that ARG."
|
||||
exit 1
|
||||
fi
|
||||
echo "obsmem_ref=${OBSMEM_REF}" >> "$GITHUB_OUTPUT"
|
||||
# Informational only, like pi's npm drift warning: master moving past
|
||||
# the pin is how a fix pi needs becomes visible. `|| true` so a GitHub
|
||||
# hiccup cannot fail a release whose pin is already in the repo.
|
||||
OBSMEM_MASTER=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||
"https://api.github.com/repos/elpapi42/pi-observational-memory/commits/master" || true)
|
||||
if printf '%s' "${OBSMEM_MASTER:-}" | grep -qiE '^[0-9a-f]{40}$' && [ "${OBSMEM_MASTER}" != "${OBSMEM_REF}" ]; then
|
||||
echo "::warning::pi-observational-memory master is ${OBSMEM_MASTER:0:7}; this build ships the audited pin ${OBSMEM_REF:0:7}. To adopt it: read https://github.com/elpapi42/pi-observational-memory/compare/${OBSMEM_REF}...${OBSMEM_MASTER} (the observer/reflector/dropper workers and their pi API use), re-run the turn-cap probe against the pinned pi, then bump ARG PI_OBSMEM_REF in Dockerfile.variant and name the SHA in CHANGELOG.md."
|
||||
fi
|
||||
|
||||
# pi-atelier → the PINNED TAG's commit SHA. Unlike fork/obsmem
|
||||
# (which track a branch head) atelier wraps pi's private TUI
|
||||
@@ -469,7 +515,7 @@ jobs:
|
||||
echo "Resolved PI_VERSION=${PI_VERSION} (pinned in Dockerfile.variant; npm latest is ${PI_NPM_LATEST:-unknown})"
|
||||
echo "Resolved MEMPALACE_VERSION=${MEMPALACE_VERSION} (pinned in Dockerfile.base; PyPI latest is ${MEMPALACE_PYPI_LATEST:-unknown})"
|
||||
echo "Resolved PI_ATELIER_REF=${ATELIER_REF} (pi-atelier ${ATELIER_TAG}, pinned)"
|
||||
echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}"
|
||||
echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF} (pinned; master is ${OBSMEM_MASTER:-unknown})"
|
||||
echo "Resolved PI_TOOLKIT_REF=${TOOLKIT_REF}, PI_EXTENSIONS_REF=${EXTENSIONS_REF}"
|
||||
echo "Resolved PI_STUDIO_REF=${STUDIO_REF} (pi-studio ${STUDIO_TAG})"
|
||||
echo "Resolved MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}"
|
||||
@@ -498,8 +544,7 @@ jobs:
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
apt-get clean || true
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
bash scripts/ci-reclaim-docker.sh
|
||||
df -h / || true
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
@@ -557,6 +602,10 @@ jobs:
|
||||
needs.resolve-versions.result == 'success' &&
|
||||
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Per run, so two runs on different refs cannot test or delete each
|
||||
# other's image on the shared daemon. run_id is stable across re-runs.
|
||||
SMOKE_IMAGE: pi-devbox:smoke-${{ github.run_id }}
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
@@ -570,8 +619,7 @@ jobs:
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
/usr/local/share/chromium /usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
bash scripts/ci-reclaim-docker.sh
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
with: {driver-opts: network=host}
|
||||
- uses: docker/login-action@v3
|
||||
@@ -586,7 +634,8 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: pi-devbox:smoke
|
||||
tags: ${{ env.SMOKE_IMAGE }}
|
||||
labels: ${{ env.SMOKE_IMAGE_LABEL }}=true
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
|
||||
@@ -608,7 +657,19 @@ jobs:
|
||||
# Asserting the BUILT image matches it also catches a stale cached layer.
|
||||
EXPECTED_NODE_MAJOR=$(sed -n 's/^ARG NODE_VERSION=\([0-9][0-9]*\).*/\1/p' Dockerfile.base)
|
||||
export EXPECTED_NODE_MAJOR
|
||||
bash scripts/smoke-test.sh pi-devbox:smoke
|
||||
bash scripts/ci-reclaim-docker.sh assert-protected "$SMOKE_IMAGE"
|
||||
# Pin the image with a running container for the whole test, so a
|
||||
# raw prune from a job we do not control (other repos, the other
|
||||
# Gitea instance's runner on the same VM) cannot remove it either.
|
||||
bash scripts/ci-reclaim-docker.sh hold "$SMOKE_IMAGE"
|
||||
bash scripts/smoke-test.sh "$SMOKE_IMAGE"
|
||||
# The label keeps this image from every job's reclaim and the holder pins
|
||||
# it, so both are removed here. A job killed before this step leaves an
|
||||
# orphan: the holder exits after 1 h, and the reclaim's age sweep deletes
|
||||
# the image once it is older than SMOKE_IMAGE_MAX_AGE.
|
||||
- name: Remove smoke image
|
||||
if: always()
|
||||
run: bash scripts/ci-reclaim-docker.sh release "$SMOKE_IMAGE"
|
||||
|
||||
# ── Phase 3b: amd64 smoke for the studio variant ────────────────────
|
||||
# Additive + independent of the core `smoke` job: gates ONLY
|
||||
@@ -622,6 +683,10 @@ jobs:
|
||||
needs.resolve-versions.result == 'success' &&
|
||||
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
# Per run, so two runs on different refs cannot test or delete each
|
||||
# other's image on the shared daemon. run_id is stable across re-runs.
|
||||
SMOKE_IMAGE: pi-devbox:smoke-studio-${{ github.run_id }}
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
@@ -635,8 +700,7 @@ jobs:
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
/usr/local/share/chromium /usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
bash scripts/ci-reclaim-docker.sh
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
with: {driver-opts: network=host}
|
||||
- uses: docker/login-action@v3
|
||||
@@ -651,7 +715,8 @@ jobs:
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: pi-devbox:smoke-studio
|
||||
tags: ${{ env.SMOKE_IMAGE }}
|
||||
labels: ${{ env.SMOKE_IMAGE_LABEL }}=true
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
|
||||
@@ -676,7 +741,19 @@ jobs:
|
||||
# Asserting the BUILT image matches it also catches a stale cached layer.
|
||||
EXPECTED_NODE_MAJOR=$(sed -n 's/^ARG NODE_VERSION=\([0-9][0-9]*\).*/\1/p' Dockerfile.base)
|
||||
export EXPECTED_NODE_MAJOR
|
||||
bash scripts/smoke-test.sh pi-devbox:smoke-studio
|
||||
bash scripts/ci-reclaim-docker.sh assert-protected "$SMOKE_IMAGE"
|
||||
# Pin the image with a running container for the whole test, so a
|
||||
# raw prune from a job we do not control (other repos, the other
|
||||
# Gitea instance's runner on the same VM) cannot remove it either.
|
||||
bash scripts/ci-reclaim-docker.sh hold "$SMOKE_IMAGE"
|
||||
bash scripts/smoke-test.sh "$SMOKE_IMAGE"
|
||||
# The label keeps this image from every job's reclaim and the holder pins
|
||||
# it, so both are removed here. A job killed before this step leaves an
|
||||
# orphan: the holder exits after 1 h, and the reclaim's age sweep deletes
|
||||
# the image once it is older than SMOKE_IMAGE_MAX_AGE.
|
||||
- name: Remove smoke image
|
||||
if: always()
|
||||
run: bash scripts/ci-reclaim-docker.sh release "$SMOKE_IMAGE"
|
||||
|
||||
# ── Phase 4: multi-arch publish ─────────────────────────────────────
|
||||
build-variant:
|
||||
@@ -701,8 +778,7 @@ jobs:
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
/usr/local/share/chromium /usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
bash scripts/ci-reclaim-docker.sh
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
with: {platforms: arm64}
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
@@ -794,8 +870,7 @@ jobs:
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
/usr/local/share/chromium /usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
bash scripts/ci-reclaim-docker.sh
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
with: {platforms: arm64}
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
|
||||
@@ -90,6 +90,33 @@ jobs:
|
||||
# actionlint so the more precise diagnostic surfaces first.
|
||||
run: bash scripts/check-workflow-shell.sh .gitea/workflows
|
||||
|
||||
- name: No raw docker prune in workflows (shared runner daemon)
|
||||
# Everything on one runner host shares its Docker daemon (runner-a1
|
||||
# runs several jobs at once; runner-2 also serves gitea.egl.lan), and
|
||||
# a bare `docker system prune -a` in one job deletes the smoke image
|
||||
# another job is testing (v1.12.2 run 740 shipped without its -studio
|
||||
# tags that way). Reclaim goes through scripts/ci-reclaim-docker.sh,
|
||||
# which spares labelled smoke images; this keeps it that way.
|
||||
run: bash scripts/ci-reclaim-docker.sh lint .gitea/workflows/*.yml
|
||||
|
||||
- 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: link-pi-extensions.sh contract (two-sided, no docker needed)
|
||||
# Same reasoning as the models step above: the entrypoint links top-level
|
||||
# *.ts from the mounted fleet directory into ~/.pi/agent/extensions, and
|
||||
# must never replace a user's own extension of the same name.
|
||||
run: bash scripts/test-link-pi-extensions.sh
|
||||
|
||||
- name: Dockerfile.variant still opens with its BuildKit check directive
|
||||
# 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
|
||||
@@ -222,3 +249,49 @@ jobs:
|
||||
|
||||
- name: Doc claims match the build files
|
||||
run: bash scripts/check-doc-drift.sh
|
||||
|
||||
skill-frontmatter:
|
||||
# Gate the image-baked skills' frontmatter against the spec pi's own loader
|
||||
# measures. Its own job, for the same reason as skill-floor and doc-drift:
|
||||
# "a shipped skill violates the spec" should be a distinct red name, not a
|
||||
# line buried in a job about workflow syntax.
|
||||
#
|
||||
# The gap it closes, measured 2026-10-23 while adding pi-devbox-firstboot:
|
||||
# that skill's description was drafted at 1056 chars against the loader's
|
||||
# MAX_DESCRIPTION_LENGTH of 1024. pi does NOT reject it — dist/core/skills.js
|
||||
# pushes a diagnostic and then loads the skill anyway ("Still load the skill
|
||||
# even with warnings, unless description is missing or empty"). So the only
|
||||
# thing that would ever report the violation is a diagnostic inside a live pi
|
||||
# session, and nothing in CI starts one. A spec violation that still works is
|
||||
# the most durable kind of rot: nothing forces the fix.
|
||||
#
|
||||
# Not free in the way skill-floor was free: two of the five baked skills are
|
||||
# already within ~20 chars of the limit (credential-incident-response 1022,
|
||||
# pi-extensions 1003), so this fires on a plausible next edit rather than on
|
||||
# a hypothetical one.
|
||||
#
|
||||
# The sibling failure it also catches is a silent DROP rather than a warning:
|
||||
# a missing/empty description returns skill: null, so the file ships, the
|
||||
# symlink resolves, smoke-test's `test -f` passes, pi-devbox-version lists
|
||||
# it — and no agent can ever see it.
|
||||
#
|
||||
# Exit codes 0 valid / 1 violation / 2 cannot-run, matching lint-shell.sh,
|
||||
# check-skill-floor.sh and check-doc-drift.sh. A missing skills tree or a
|
||||
# python3 without the yaml module is a red 2, not a green tick.
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
# python3-yaml because `description: >-` is a folded block scalar: the
|
||||
# value spans lines and joins with spaces, so a line-oriented length
|
||||
# check measures the wrong string. Same package the actionlint job
|
||||
# installs, same parser check-doc-drift.sh already relies on.
|
||||
- name: Install python3-yaml
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install -y --no-install-recommends python3-yaml
|
||||
|
||||
- name: Baked skill frontmatter is valid
|
||||
run: bash scripts/check-skill-frontmatter.sh
|
||||
|
||||
@@ -45,6 +45,16 @@ re-brand of opencode-devbox's `pi-only` variant.
|
||||
update-description). The `-studio` variant adds independent
|
||||
`smoke-studio` + `build-variant-studio` jobs that gate only the
|
||||
`-studio` tags (never the core `:latest` release).
|
||||
- `scripts/ci-reclaim-docker.sh` — the ONLY way a CI job frees disk. Everything
|
||||
on one runner host shares its Docker daemon (runner-a1 runs several jobs at
|
||||
once; runner-2 also hosts a runner for gitea.egl.lan), so a bare `docker system prune -a` in one
|
||||
job deletes the smoke image another job is mid-test on (v1.12.2 run 740 lost
|
||||
its `-studio` tags that way). The script spares images labelled
|
||||
`se.jordbo.pi-devbox.ci-smoke-image`; the smoke jobs apply that label,
|
||||
assert it (`assert-protected`), pin the image with a running container for
|
||||
the test (`hold`, against raw prunes from jobs we do not control) and
|
||||
`release` it at the end.
|
||||
`lint.yml` fails on any raw `docker system|image prune` in a workflow.
|
||||
|
||||
## Versioning scheme
|
||||
|
||||
@@ -354,14 +364,24 @@ deprecated artifacts (to be removed in opencode-devbox v2.0.0).
|
||||
|
||||
## What we DON'T install (and why)
|
||||
|
||||
- **No texlive** (~600 MB–1 GB). PDF export from pandoc / pi-studio works
|
||||
out of the box via **`typst`** (~30 MB static binary), which the base ships
|
||||
as the pandoc PDF engine (`pandoc --pdf-engine=typst`) — small enough to live
|
||||
in base rather than a dedicated `:latest-studio-tex` variant. We don't bake in
|
||||
a full TeX Live: it's heavy and typst covers the common Markdown→PDF case.
|
||||
Users needing LaTeX-exact output can install the higher-fidelity fallback on
|
||||
demand: `sudo apt-get install texlive-xetex texlive-latex-recommended` (then
|
||||
`pandoc --pdf-engine=xelatex`).
|
||||
- **No texlive** (~0.5–1 GB). The base ships **`typst`** (~30 MB static
|
||||
binary) as a PDF engine for pandoc, so
|
||||
`pandoc doc.md --pdf-engine=typst -o doc.pdf` works out of the box. That
|
||||
covers the common Markdown→PDF case, small enough to live in base rather
|
||||
than in a dedicated `:latest-studio-tex` variant. Callers must ask for
|
||||
typst: pandoc's own default engine is `pdflatex`, which is not installed.
|
||||
**pi-studio's PDF export (`/studio-pdf`, `studio_export_pdf`) does not use
|
||||
typst.** It runs pandoc with `$PANDOC_PDF_ENGINE`, defaulting to `xelatex`,
|
||||
so in this image it fails with `xelatex not found`. Do not "fix" that with
|
||||
`ENV PANDOC_PDF_ENGINE=typst`. pi-studio adds its own LaTeX header to the
|
||||
document, and typst prints that header as page text while pandoc exits 0.
|
||||
That ENV only becomes a fix once pi-studio stops sending its LaTeX header
|
||||
to non-LaTeX engines. Until then, users who need Studio's PDF export, or
|
||||
LaTeX output, install TeX Live on demand with the recipe in README
|
||||
"PDF export from Studio needs LaTeX" (37 packages, ~490 MB with
|
||||
`--no-install-recommends`). A plain `sudo apt-get install texlive-xetex`
|
||||
also works, but its recommends make it 202 packages and ~1 GB, including
|
||||
systemd and a Java runtime.
|
||||
- **pi-studio** ships in the `:latest-studio` variant (since v1.1.0),
|
||||
vendored to `/opt/pi-studio` and registered at container start via
|
||||
`pi install /opt/pi-studio` (see Dockerfile.variant `INSTALL_STUDIO`).
|
||||
|
||||
+1555
File diff suppressed because it is too large
Load Diff
+6
-3
@@ -49,16 +49,19 @@ Full setup guide — authentication for each provider (Anthropic, OpenAI, Gemini
|
||||
- **pi `{{PI_VERSION}}`** ([`@earendil-works/pi-coding-agent`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)) — installed at `/usr/bin/pi`, pinned to an audited version (not npm `latest`)
|
||||
- **pi-atelier** — TUI sidebar (ordered panels, split-pane, themes), vendored at `/opt/pi-atelier` and pinned to an audited tag; the exact tag is in the image labels (`se.jordbo.pi-devbox.pi-atelier-version`) and `/etc/pi-devbox/build-manifest.json`
|
||||
- **[pi-toolkit](https://gitea.jordbo.se/joakimp/pi-toolkit)** — keybindings (mosh/tmux-friendly Shift+Enter, Ctrl+J, Alt+J newline bindings), AWS env loader, settings template
|
||||
- **[pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions)** — 7 user-facing extensions: `ext-toggle`, `mcp-loader`, `todo`, `ssh-controlmaster`, `notify`, `git-checkpoint`, `confirm-destructive`
|
||||
- **[pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions)** — 10 user-facing extensions: `ext-toggle`, `todo`, `task`, `fork-gate`, `secret-guard`, `aws-sso`, `ssh-controlmaster`, `notify`, `git-checkpoint`, `confirm-destructive`
|
||||
- **MCP servers** — pi's built-in MCP support: declare servers in `~/.pi/agent/mcp.json` (or `pi mcp add`), manage them with `/mcp`
|
||||
- **`fork`** ([pi-fork](https://github.com/elpapi42/pi-fork)) and **`recall`** ([pi-observational-memory](https://github.com/elpapi42/pi-observational-memory)) tools
|
||||
- **mempalace bridge** — MCP extension auto-symlinked so pi reads/writes the host-mounted palace
|
||||
- **image-baked agent skills** — skills under `/usr/local/share/pi-devbox/skills/` (e.g. `pi-devbox-environment`, which teaches agents the container's persistence/networking/DNS/tmux/REPL specifics) are symlinked into `~/.agents/skills/` on start, available with or without a mounted skillset repo
|
||||
|
||||
The entrypoint deploys/registers all of these on first container start. Re-running is idempotent and preserves user edits.
|
||||
|
||||
**Terminal UI mode — fullscreen by default.** pi 1.0.0 made the TUI fullscreen, and this image adopts upstream's default. Fullscreen uses the terminal's alternate screen, so the transcript no longer lands in your terminal's (or tmux's) native scrollback. To get the previous behaviour back, set `"tuiMode": "regular"` in `~/.pi/agent/settings.json`, or pass `pi --tui-mode regular` for a single session. The bundled pi-atelier sidebar works in both modes. See the README for the related `fullscreenExitOutput` / `fullscreenScrollbar` / `fullscreenCopyOnSelect` / `fullscreenWheelScrollLines` settings — the last one behaves differently over SSH, which is how this container is usually driven.
|
||||
|
||||
### MemPalace (persistent agent memory)
|
||||
|
||||
- **MemPalace** + MCP server — semantic search over conversation history, knowledge graph, diary; queryable via 29 `mempalace_*` tools inside pi
|
||||
- **MemPalace** + MCP server — semantic search over conversation history, knowledge graph, diary; queryable via `mempalace_*` tools inside pi (the palace server decides the set: the baked 3.11.0 lists 47, a 3.10.0 central server 45)
|
||||
- ChromaDB ONNX embedding model pre-warmed at build time (`all-MiniLM-L6-v2`)
|
||||
- Bind-mount your host's `~/.mempalace` and the host-pi and container-pi share one brain
|
||||
|
||||
@@ -80,7 +83,7 @@ The entrypoint deploys/registers all of these on first container start. Re-runni
|
||||
- **Editor**: neovim (system-wide `termguicolors` default; bring your own config/plugins), tmux (configured for 0-indexed sessions)
|
||||
- **Search/nav**: ripgrep, fd, fzf, zoxide
|
||||
- **Display**: bat, eza, htop, tree
|
||||
- **Data**: jq, yq
|
||||
- **Data**: jq, yq, sqlite3, bc/dc, column
|
||||
- **Help**: tldr (tealdeer — Rust port; run `tldr --update` once to populate cache)
|
||||
- **Git**: git-lfs, git-crypt, gitleaks (for pre-commit secret scanning)
|
||||
- **Build**: gcc, g++, make, patch
|
||||
|
||||
+77
-9
@@ -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-25 (v1.13.0 — rootfs gains the pi-devbox-firstboot skill and mempalace-toolkit moves to c04d488; apt and base tools re-resolve (upstream on 2026-10-25: gosu 1.20, gitea-mcp 1.11.0, tealdeer 1.9.1; Node v24.22.1, git-lfs 3.8.1, uv 0.13.5 unchanged); previous marker 2026-10-22 / v1.12.2)
|
||||
#
|
||||
# ── Lineage note ─────────────────────────────────────────────────────
|
||||
# Adapted from opencode-devbox/Dockerfile.base (commit before v1.16.2).
|
||||
@@ -162,6 +162,42 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
# /dev/tcp does reachability checks with zero packages
|
||||
# (verified against gitea.egl.lan:3000). Recorded here so the
|
||||
# omission reads as a decision rather than an oversight.
|
||||
# sqlite3 — the `sqlite3` CLI. Added 2026-09-28 at ALC's request. 587 KB.
|
||||
# This is the one that was genuinely missing rather than merely
|
||||
# absent: MemPalace keeps BOTH the palace and the logstream as
|
||||
# SQLite files (palace/chroma.sqlite3, logstream.sqlite3),
|
||||
# mempalace_status reports a sqlite_integrity block, and every
|
||||
# integrity or forensics check on this fleet has so far been
|
||||
# done through a python3 -c one-liner because the CLI did not
|
||||
# exist in the image.
|
||||
# bc, dc — arbitrary-precision calculators. 236 KB + 149 KB. SEPARATE
|
||||
# binary packages on Debian (dc split out of bc before
|
||||
# bookworm), so both must be named — verified with apt-cache on
|
||||
# a trixie host, not assumed. Added 2026-09-28. HONEST
|
||||
# RATIONALE: low value on their own, because awk, python3 and
|
||||
# perl are all already baked and each is strictly more capable.
|
||||
# They are here because copy-pasted shell snippets assume bc
|
||||
# exists. Note what actually went wrong on 2026-09-28, since it
|
||||
# was NOT bc's absence: `printf '%6.2f'` was handed the empty
|
||||
# output of the missing bc and rendered it as a confident
|
||||
# "0.00 days" for a figure that was really 4.81 days. A missing
|
||||
# tool that formats as a plausible number is worse than one
|
||||
# that fails loudly, and no package fixes that — only not
|
||||
# taking a formatted value on trust does.
|
||||
# bsdextrautils— ships /usr/bin/column. 339 KB. Added 2026-09-28. Confirmed
|
||||
# with `dpkg -S /usr/bin/column` on trixie rather than inferred
|
||||
# from the pre-bullseye bsdmainutils name, which is where column
|
||||
# used to live and is the obvious way to get this wrong.
|
||||
# MEASURED COST OF ALL FOUR: 1311 KB total and ZERO transitive
|
||||
# packages — libsqlite3-0, libreadline8t64, zlib1g,
|
||||
# libsmartcols1 and libtinfo6 are each already present in the
|
||||
# image (checked with dpkg-query, all five report
|
||||
# "install ok installed"), so under --no-install-recommends
|
||||
# nothing new is pulled.
|
||||
# NOT added — datamash, xsv/csvkit: considered 2026-09-28 and
|
||||
# declined. Python's stdlib csv module handled a real 7-file
|
||||
# Excel-export concatenation that day (BOM, CRLF, embedded
|
||||
# newlines inside quoted fields) correctly and without them.
|
||||
# python3-yaml — PyYAML. Added 2026-09-10 for precisely the same reason as
|
||||
# shellcheck above: a gate this repo ALREADY OWNS could not be
|
||||
# run locally by anyone. scripts/check-workflow-shell.sh — the
|
||||
@@ -220,6 +256,10 @@ RUN apt-get update && \
|
||||
ldap-utils \
|
||||
xxd \
|
||||
python3-yaml \
|
||||
sqlite3 \
|
||||
bc \
|
||||
dc \
|
||||
bsdextrautils \
|
||||
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -599,7 +639,30 @@ ARG INSTALL_MEMPALACE=true
|
||||
# positive control logged one). So 3.10.0's new CLI write-routing policy never
|
||||
# runs against the hub from this image; the client pin touches first-run
|
||||
# `mempalace init` and the on-disk layout, nothing else in remote mode.
|
||||
ARG MEMPALACE_VERSION=3.10.0
|
||||
#
|
||||
# v1.12.0: 3.10.0 -> 3.11.0 (PyPI 2026-10-14). Core requirements unchanged.
|
||||
# Three "Upgrade notes", checked against our callers and measured with both
|
||||
# wheels in throwaway uv venvs and a throwaway palace:
|
||||
# - `EntityRegistry.research()` / `confirm_research()` removed: 0 callers in
|
||||
# pi-devbox, mempalace-toolkit, skillset or myconfigs.
|
||||
# - The first conversation mine re-mines each covered conversation file once
|
||||
# (the exchange chunker stopped discarding text). LOCAL palaces only from
|
||||
# this image: in remote mode the hub does the mining. Measured: a palace
|
||||
# built by 3.10.0 (84 drawers) re-mined its one convo file under 3.11.0
|
||||
# (83 -> 84 drawers, old ones replaced, 2 s); the next mine skipped it.
|
||||
# - A palace refuses to open with a different embedder than the one that
|
||||
# built it. The default (MiniLM) is unchanged and nothing here sets
|
||||
# `embedding_model`; the 3.10.0-built palace opened, searched and served
|
||||
# MCP under 3.11.0 with no warning.
|
||||
# `mempalace init --yes` (entrypoint) still writes config.json, rc 0. MCP over
|
||||
# stdio: 45 -> 47 tools, none removed or renamed (+delete_drawers,
|
||||
# +get_drawers); 14 read-only tools now carry `readOnlyHint`. Skew: synlig's
|
||||
# hub is still 3.10.0 (measured 2026-10-18), the newer-client direction, and
|
||||
# the bridge's tool list comes from the hub, so nothing changes for shared-
|
||||
# palace devices until synlig upgrades. When it does, ITS first convo mine
|
||||
# re-mines every inbox transcript still on disk (upstream: 40 min to 3 h per
|
||||
# million drawers); back it up and run it at a quiet time.
|
||||
ARG MEMPALACE_VERSION=3.11.0
|
||||
# Recorded as a label HERE, not in Dockerfile.variant, for three reasons: the
|
||||
# value lives next to the ARG that defines it (a second copy in the variant
|
||||
# would be one more pin able to drift, which is the class check-doc-drift.sh
|
||||
@@ -782,12 +845,16 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64"
|
||||
|
||||
# ── typst — lightweight PDF engine for pandoc (Markdown→PDF) ─────────
|
||||
# pandoc (apt-installed above) is only a front-end; rendering PDF needs a
|
||||
# back-end engine. Rather than a ~600 MB TeX Live install, we ship typst:
|
||||
# a single ~30 MB static Rust binary with no LaTeX dependency. pi-studio's
|
||||
# PDF export (studio_export_pdf) and pandoc invocations use it via
|
||||
# `pandoc --pdf-engine=typst`. A fuller TeX Live remains the higher-
|
||||
# fidelity fallback for anyone who needs LaTeX-exact output (not shipped
|
||||
# here — install on demand or in a future variant).
|
||||
# back-end engine. Rather than a ~0.5–1 GB TeX Live install, we ship typst:
|
||||
# a single ~30 MB static Rust binary with no LaTeX dependency. Callers must
|
||||
# ask for it with `pandoc --pdf-engine=typst`, because pandoc's own default
|
||||
# engine is pdflatex, which is not installed. pi-studio's PDF export
|
||||
# (studio_export_pdf, /studio-pdf) does NOT use typst. It runs pandoc with
|
||||
# $PANDOC_PDF_ENGINE, defaulting to xelatex, so it fails with "xelatex not
|
||||
# found". Setting PANDOC_PDF_ENGINE=typst is not a fix: pi-studio adds its
|
||||
# own LaTeX header, and typst prints that header as page text while pandoc
|
||||
# exits 0. TeX Live is not shipped here; README "PDF export from Studio needs
|
||||
# LaTeX" has the on-demand recipe (~490 MB).
|
||||
#
|
||||
# Follows the `latest` GitHub-release convention (like tealdeer/uv/bat).
|
||||
# typst ships a `.tar.xz` asset (hence xz-utils in the apt layer above)
|
||||
@@ -799,7 +866,8 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64"
|
||||
# document font to an empty tuple (`font: ()`), so a naked
|
||||
# `pandoc --pdf-engine=typst` fails with "font fallback list must not be empty"
|
||||
# unless the caller passes `-V mainfont=...`. We default it to Libertinus Serif
|
||||
# (typst's own bundled default font) so PDF export works out-of-the-box.
|
||||
# (typst's own bundled default font) so a naked `pandoc --pdf-engine=typst`
|
||||
# works out of the box.
|
||||
ARG TYPST_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${TYPST_VERSION}" && \
|
||||
|
||||
+314
-9
@@ -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
|
||||
@@ -134,7 +168,191 @@ ARG USER_NAME=developer
|
||||
# contract) and its registerTool calls spread the built-in tools so they
|
||||
# carry parameter schemas (#9300). 0.86.1 as an intermediate is untested and
|
||||
# not worth the pty matrix for a stop that #83 will make moot.
|
||||
ARG PI_VERSION=0.85.1
|
||||
#
|
||||
# v1.9.5: 0.85.1 -> 0.87.1, and pi-obsmem moves off `master` to a pinned SHA in
|
||||
# the SAME commit, because neither is safe alone. 0.87.0 REMOVED
|
||||
# `shouldStopAfterTurn`, which 3.1.4 still used; the pinned tip uses
|
||||
# `finishTurn`, which does not exist before 0.87.0. So 3.1.4 + 0.87.1 silently
|
||||
# ignores turn caps, and the new tip + 0.85.1 breaks the workers outright — the
|
||||
# pair only works together, exactly as the v1.9.4 note predicted.
|
||||
# MEASURED 2026-10-01 across src/agents/{observer,reflector,dropper}/agent.ts,
|
||||
# deliberately the same counting method as the v1.9.4 audit above so the two
|
||||
# numbers are comparable:
|
||||
# 3.1.4 (e7d77dc): shouldStopAfterTurn 3, finishTurn 0, systemPrompt 3
|
||||
# 731c3d4 (pinned) : shouldStopAfterTurn 0, finishTurn 3, systemPrompt 0
|
||||
# i.e. all three workers migrated, and the 0.86.0 `AgentContext.systemPrompt`
|
||||
# reads are gone too. #82/#83 merged 2026-09-23.
|
||||
# WHY A SHA AND NOT A TAG, departing from the v1.9.4 instruction to wait for a
|
||||
# release: #83 is merged, but the newest obsmem tag is STILL 3.1.4, cut
|
||||
# 2026-09-20 — before the merge. Upstream tags slowly while moving master often
|
||||
# (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.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.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,
|
||||
# 0.80.8, 0.80.7 and 0.75.0 — none to 0.88+..1.0.0. The major is a
|
||||
# milestone (fullscreen default, leaner codemode), not an API break. The
|
||||
# last break that touched us was 0.87.0's `shouldStopAfterTurn` removal,
|
||||
# which v1.9.5 already absorbed.
|
||||
# 2. `finishTurn` IS STILL IN 1.0.0's dist, so the obsmem SHA pinned below
|
||||
# keeps the API it migrated to. (`shouldStopAfterTurn`: absent from both
|
||||
# 0.87.1 and 1.0.0, as expected after its 0.87.0 removal.)
|
||||
# 3. EVERY pi.* API our extensions call exists in 1.0.0's dist — all 8 of
|
||||
# registerTool, registerCommand, registerFlag, getFlag, on, exec,
|
||||
# sendMessage, sendUserMessage, extracted from mempalace.ts, the
|
||||
# pi-extensions tree and obsmem's src/.
|
||||
# 4. engines.node is `>=22.19.0` on both; the image ships 24.x.
|
||||
# THE 0.99.2 CHANGE THAT LOOKED FATAL AND IS NOT: from 0.99.2 the DEFAULT MCP
|
||||
# `exposure` is `codemode`, i.e. such tools are "neither declared to the model
|
||||
# nor listed" and must be found with `searchTools()`. That would gut the
|
||||
# MemPalace protocol if MemPalace were a builtin-MCP server. It is not: both
|
||||
# mempalace.ts runs its OWN MCP client and surfaces tools via
|
||||
# `pi.registerTool()`, which is why they are named `mempalace_search` and not
|
||||
# `mcp__mempalace__search`. (So did pi-extensions' mcp-loader.ts until it was
|
||||
# retired after v1.11.0 in favour of builtin:mcp. Servers in ~/.pi/agent/mcp.json
|
||||
# ARE exposure-governed: they need `"exposure": "direct"` to be declared.) Confirmed on a second route — settings.json has
|
||||
# neither an `mcpServers` block (builtin, exposure-governed) nor an `mcp` block.
|
||||
# Extension-registered tools are declared like built-ins, so `exposure` cannot
|
||||
# reach them.
|
||||
# WHAT IS NOT PROVEN, stated so acceptance does not mistake this for cleared:
|
||||
# grepping dist shows the SYMBOLS survive, not that their SIGNATURES are
|
||||
# unchanged — necessary, not sufficient. 1.0.0 is also four days of upstream old
|
||||
# (published 2026-10-01T19:15Z) and neither obsmem nor atelier has a commit
|
||||
# naming it. Acceptance must prove the obsmem workers CAP TURNS (peerDeps are
|
||||
# `*`, so a mismatch is silent) and that the atelier sidebar PAINTS.
|
||||
# USER-VISIBLE BEHAVIOUR CHANGE, decided rather than inherited: 1.0.0 makes the
|
||||
# TUI fullscreen by default, which replaces the terminal's normal scrollback.
|
||||
# Upstream's default is ADOPTED on purpose and no `tuiMode` is baked here, so
|
||||
# the image follows pi instead of pinning the fleet to either mode. The revert
|
||||
# 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.
|
||||
#
|
||||
# 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).
|
||||
#
|
||||
# v1.11.2: 1.1.0 -> 1.2.0 (npm latest, 2026-10-12). MEASURED 2026-10-14 on
|
||||
# tor-ms22 from an isolated npm prefix (nothing in /usr or ~/.pi touched):
|
||||
# 1. FOUR `### Breaking Changes` in 1.2.0, none of which reaches us:
|
||||
# - before_agent_start also fires for sendMessage(triggerTurn) runs and is
|
||||
# a union on `messageType`; `systemPrompt` is on the shared base. Our 3
|
||||
# handlers: ssh-controlmaster reads only systemPrompt, aws-sso and
|
||||
# mempalace read no event field. Nothing in the stack uses triggerTurn.
|
||||
# - the session counts as busy while a run starts (isIdle() false,
|
||||
# sendUserMessage needs deliverAs). aws-sso already passes deliverAs;
|
||||
# mempalace's /mempalace-diary is a command and runs while idle.
|
||||
# - themes are schema-validated at startup and the schema moved to
|
||||
# pi.dev. pi-studio's themes still name the old pi-mono $schema URL, but
|
||||
# `--use-theme pi-studio-dark` applies (palette differs from the
|
||||
# default) and prints no warning.
|
||||
# - `pi mcp list --json` reports `resources: true`. Nothing parses it.
|
||||
# 2. pi-agent-core 1.1.0 -> 1.2.0: dist is BYTE-IDENTICAL (24 files, same
|
||||
# tree hash). The turn-cap probe drove the real 1.2.0 agentLoop: uncapped
|
||||
# ran its chosen 19 turns, maxTurns=3 stopped at exactly 3 + agent_end.
|
||||
# 3. 1.2.0's `-p` now exits 1 on a reply cut off at the output limit, in TEXT
|
||||
# mode only (print-mode.js). pi-task and pi-fork run `--mode json`.
|
||||
# 4. engines.node is still `>=22.19.0`; the image ships 24.x. Peer ranges:
|
||||
# atelier >=0.84.0, fork >=0.75.0, obsmem and studio `*`.
|
||||
# Exercised before tagging, 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); a copy of tor-ms22's real ~/.pi/agent (all packages, pi-extensions
|
||||
# 750b2a8) starts with the same [Extensions] list as on 1.1.0 and no
|
||||
# [Extension issues]; `pi -p` answers PONG via ERDC in 1.0 s, Bedrock in 5.2 s.
|
||||
#
|
||||
# v1.12.0: 1.2.0 -> 1.3.0 (npm latest, 2026-10-15). MEASURED 2026-10-18 on
|
||||
# tor-ms22 from an isolated npm prefix, against the 1.2.0 tarballs unpacked
|
||||
# side by side:
|
||||
# 1. NO `### Breaking Changes` section in 1.3.0. Behaviour changes, none of
|
||||
# which reaches us: `--model`/`enabledModels` resolve a bare id across
|
||||
# providers by credentials (ours are all `amazon-bedrock/...`, and
|
||||
# pi-fork's effortProfiles name a provider); resumed sessions restore
|
||||
# their active tools; thinking from another model is dropped on a model
|
||||
# switch; `kimi-coding` base-URL overrides need `/v1` (not used).
|
||||
# 2. pi-agent-core and pi-tui 1.2.0 -> 1.3.0: dist BYTE-IDENTICAL (tree
|
||||
# hashes 6a2819f6 / 92eecf92, 24 / 184 files), and so is pi-coding-agent's
|
||||
# dist/core/extensions. So obsmem's turn cap (agent-core) and atelier's
|
||||
# painting (pi-tui) cannot have moved there; atelier's internals
|
||||
# TuiMainScreen / renderLayoutFrame are in the same number of dist files.
|
||||
# 3. Public API: the only removed export in any .d.ts is
|
||||
# `parseModelPattern` (model-resolver, never re-exported from the package
|
||||
# index), and pi-ai dropped its `assistant-message-frame` re-export. 0
|
||||
# consumers of either. All 54 runtime-imported names across pi-toolkit,
|
||||
# pi-extensions, pi-fork, obsmem, pi-studio, pi-atelier, mempalace-toolkit
|
||||
# and /opt/fleet-pi resolve in 1.3.0 (control: an injected fake name is
|
||||
# reported missing).
|
||||
# 4. engines.node is still `>=22.19.0`; internal deps are now exact pins.
|
||||
# Exercised before tagging, with a copy of tor-ms22's real ~/.pi/agent:
|
||||
# `pi -p` exits 0 in 10 s (1.2.0: 9 s), nothing on stderr; the TUI loads every
|
||||
# extension with no [Extension issues], the bridge registers its 45 tools,
|
||||
# /om:status, /om:view and the /studio commands are offered, and atelier
|
||||
# paints ACTIVITY/READY/TOOLS in both renderers (controls with -ne: none).
|
||||
#
|
||||
# v1.12.2: 1.3.0 -> 1.4.0 (npm latest, published 2026-10-21T22:48Z). MEASURED
|
||||
# 2026-10-22 on tor-ms22 from isolated npm prefixes for 1.3.0 and 1.4.0, with
|
||||
# the published tarballs unpacked side by side:
|
||||
# 1. NO `### Breaking Changes` section in 1.4.0. Behaviour changes, none of
|
||||
# which reaches us: attribution headers come from pi-ai's client identity,
|
||||
# so `before_provider_headers` handlers no longer see them, and
|
||||
# `generateSummary()` ignores `reserveTokens`. 0 uses of either
|
||||
# (`before_provider_headers`, `generateSummary`, `reserveTokens`) in
|
||||
# pi-extensions, pi-fork, obsmem, pi-atelier and pi-studio. Control: the
|
||||
# same grep finds `registerTool` in 18 files.
|
||||
# 2. pi-agent-core 1.3.0 -> 1.4.0 is NOT byte-identical this time, but every
|
||||
# changed line in dist (4 files) is the new optional `identity` field and
|
||||
# `DEFAULT_AGENT_IDENTITY`; 0 changed lines mention finishTurn, so obsmem's
|
||||
# turn cap is untouched. pi-tui adds only exit-restore methods
|
||||
# (`restoreOnExit`, `getExitRestorePrefix/Suffix`, #11170); atelier's
|
||||
# internals TuiMainScreen / renderLayoutFrame are in the same 10 dist
|
||||
# files. The extension loader only gains the `@earendil-works/pi-ai/models`
|
||||
# alias (#11156). pi-mcp and pi-codemode dist are byte-identical.
|
||||
# 3. Public API: the only removed .d.ts exports are
|
||||
# `mergeProviderAttributionHeaders` (pi-coding-agent) and `getPiUserAgent`
|
||||
# (pi-ai). 0 consumers. All 58 runtime-imported names across pi-toolkit,
|
||||
# pi-extensions, pi-fork, obsmem, pi-studio (0.10.2 and 0.11.1),
|
||||
# pi-atelier, mempalace-toolkit, ~/.pi/agent/extensions and /opt/fleet-pi
|
||||
# resolve in 1.4.0, and in 1.3.0 (control: an injected fake name is
|
||||
# reported missing).
|
||||
# 4. engines.node is still `>=22.19.0`. Peer ranges: atelier >=0.84.0, fork
|
||||
# >=0.75.0, obsmem and studio `*`.
|
||||
# Exercised before tagging, with a copy of tor-ms22's real ~/.pi/agent
|
||||
# (pi-studio swapped for v0.11.1): `pi -p` answers PONG, rc 0 in 10 s (1.3.0:
|
||||
# 9 s), nothing on stderr. In the TUI the [Extensions] list is the same as on
|
||||
# 1.3.0, with no [Extension issues]. The bridge registers its 45 tools, and
|
||||
# TOOLS shows 58 / 64 on both. /om:status, /om:view and the /studio commands
|
||||
# are offered, and atelier paints ACTIVITY/READY/TOOLS in both renderers
|
||||
# (control with -ne: none).
|
||||
ARG PI_VERSION=1.4.0
|
||||
ARG PI_TOOLKIT_REF=main
|
||||
ARG PI_EXTENSIONS_REF=main
|
||||
# Repo URLs default to the canonical gitea origin but are overridable so a
|
||||
@@ -149,7 +367,29 @@ ARG PI_EXTENSIONS_REPO=https://gitea.jordbo.se/joakimp/pi-extensions.git
|
||||
ARG PI_FORK_REPO=https://github.com/elpapi42/pi-fork.git
|
||||
ARG PI_FORK_REF=master
|
||||
ARG PI_OBSMEM_REPO=https://github.com/elpapi42/pi-observational-memory.git
|
||||
ARG PI_OBSMEM_REF=master
|
||||
# PINNED to a full 40-char SHA as of v1.9.5, not `master` — see the PI_VERSION
|
||||
# note above for the measurement and the reasoning. Moves together with
|
||||
# PI_VERSION by necessity, not by convention. The width matters: 731c3d4 is the
|
||||
# same commit, but check-doc-drift.sh recognises a literal SHA only via a
|
||||
# 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.
|
||||
# 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.
|
||||
# v1.11.2: unchanged (master is still 022da5e, 6 commits past the 3.1.4 tag).
|
||||
# CI NOW BUILDS THIS PIN. Until v1.11.2 docker-publish.yml's resolve-versions
|
||||
# resolved obsmem from `master` and passed it as --build-arg, which overrides
|
||||
# this default (verified two-sided on buildx 2026-10-12), so CI got the pin only
|
||||
# while master == pin. resolve-versions now reads this line, refuses anything
|
||||
# but a 40-char SHA, and WARNS when master has moved past it. obsmem tags
|
||||
# rarely and lands what pi needs on master (PR #83 never reached a tag), so
|
||||
# adopting master is still the norm: bump this SHA, deliberately, after audit.
|
||||
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.
|
||||
@@ -202,9 +442,57 @@ ARG PI_ATELIER_REPO=https://github.com/michaelmjhhhh/pi-atelier.git
|
||||
# the no-`npm install` reasoning above holds) and peerDependencies are still
|
||||
# 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.
|
||||
ARG PI_ATELIER_REF=v0.10.3
|
||||
#
|
||||
# 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
|
||||
# checked, 2026-10-02:
|
||||
# - v0.13.0 CARRIES ITS OWN BREAKING CHANGE, unrelated to pi: "The package
|
||||
# entry point now exports only the sidebar contribution protocol
|
||||
# (`registerSidebarPanel`, guards, size limits, event types). The internal
|
||||
# registry and layout helpers are no longer exported." Harmless HERE only
|
||||
# because nothing of ours imports them: a grep for `pi-atelier` and
|
||||
# `registerSidebarPanel` across mempalace-toolkit, pi-devbox,
|
||||
# pi-extensions, pi-fork, pi-observational-memory and pi-studio returns
|
||||
# ZERO matches in all six. atelier is a leaf here — it registers its own
|
||||
# sidebar and no one consumes its API.
|
||||
# - Settings churn in the gap, checked against our own tree: v0.12.0 REMOVED
|
||||
# the `showSessionActions` setting (0 references here) and migrated the
|
||||
# Control Center shortcut Alt+A -> F6 (0 references here, so no doc goes
|
||||
# stale). `showSidebarAgent` / `showSidebarTodos`, the only atelier keys
|
||||
# README names, are both still documented in v0.13.0's README.
|
||||
# - The TUI internals atelier patches (`TuiMainScreen`, `renderLayoutFrame`)
|
||||
# are both still present in pi 1.0.0's dist, and atelier's changelog shows
|
||||
# it has handled fullscreen vs regular renderers explicitly since 0.84.
|
||||
# - 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.
|
||||
#
|
||||
# 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).
|
||||
# v1.11.2: unchanged (still the newest tag); painted in both renderers on
|
||||
# pi 1.2.0 before tagging.
|
||||
# v1.12.0: v0.15.0 -> v0.16.0 (3b42825, 2026-10-15). Additive: contributed
|
||||
# panels may send label/value rows with an optional progress bar (`keyValues`;
|
||||
# plain `rows` stay the fallback) and two new roles, and the context meter
|
||||
# loses its hard-coded dark background. Protocol unchanged (channel
|
||||
# `pi-atelier:sidebar-panels`, version 1, 24 rows, 160 chars), peer floor
|
||||
# unchanged (pi >=0.84.0), still zero runtime dependencies. On pi 1.3.0 it
|
||||
# paints in both renderers, and the fleet's contributed `fleet:todos` panel
|
||||
# (/opt/fleet-pi/fleet-todos-panel.ts, plain rows) shows a todo written in a
|
||||
# real turn, as on v0.15.0. The v0.15.0 arm still draws the old meter
|
||||
# background (48;2;48;53;56) and the v0.16.0 arm does not, so the arms ran the
|
||||
# code they claim.
|
||||
ARG PI_ATELIER_REF=v0.16.0
|
||||
# Human-readable tag PI_ATELIER_REF was resolved from; recorded as a label.
|
||||
ARG PI_ATELIER_VERSION=v0.10.3
|
||||
ARG PI_ATELIER_VERSION=v0.16.0
|
||||
|
||||
RUN set -e && \
|
||||
# git_fetch_ref: clone-equivalent helper that accepts EITHER a branch name
|
||||
@@ -314,6 +602,22 @@ RUN set -e && \
|
||||
echo "pi-observational-memory at $(cd /opt/pi-observational-memory && git rev-parse --short HEAD)" && \
|
||||
echo "pi-atelier at $(cd /opt/pi-atelier && git rev-parse --short HEAD) (${PI_ATELIER_VERSION})"
|
||||
|
||||
# ── git: let the unprivileged user read the root-owned /opt clones ──────────
|
||||
# The clones above (and /opt/mempalace-toolkit from the base, /opt/pi-studio in
|
||||
# the studio variant) are root-owned; the container runs as `developer`. Since
|
||||
# git 2.35.2 (CVE-2022-24765) any git command in a repo owned by another user
|
||||
# fails with "dubious ownership" — so `git -C /opt/pi-extensions rev-parse`
|
||||
# returns nothing, `install.sh` used to abort on it, and acceptance checks that
|
||||
# read a baked ref via git silently measured "" (v1.9.4 first-boot run: one
|
||||
# false FAIL from exactly this). Listing the paths (not `*`) keeps the check
|
||||
# meaningful for everything else, e.g. the virtiofs-mounted /workspace.
|
||||
# Entries for paths absent in a variant (pi-studio) are inert.
|
||||
RUN for d in pi-toolkit pi-extensions pi-fork pi-observational-memory pi-atelier \
|
||||
mempalace-toolkit pi-studio; do \
|
||||
git config --system --add safe.directory "/opt/${d}"; \
|
||||
done && \
|
||||
git config --system --get-all safe.directory
|
||||
|
||||
# ── Image-baked skill refresh: pi-extensions (Option 1 over Option 2) ──
|
||||
# rootfs ships a VENDORED snapshot of the pi-extensions skill at
|
||||
# /usr/local/share/pi-devbox/skills/pi-extensions/ (the "floor" — guarantees the
|
||||
@@ -446,10 +750,11 @@ RUN if [ "${INSTALL_STUDIO}" = "true" ]; then \
|
||||
|
||||
# STUDIO_PORT: advisory default consumed by docker-compose port publishing
|
||||
# and the recommended `/studio --no-browser --port "$STUDIO_PORT"` launch.
|
||||
# Harmless in the non-studio variant. NOTE: pi-studio hard-binds the server
|
||||
# to 127.0.0.1 inside the container (index.ts: .listen(port,"127.0.0.1")),
|
||||
# so reaching it from a browser needs a loopback bridge or host networking —
|
||||
# see the "Using pi-studio" section in README.md.
|
||||
# Harmless in the non-studio variant. NOTE: by default pi-studio binds the
|
||||
# server to 127.0.0.1 inside the container, so reaching it from a browser
|
||||
# needs host networking, the studio-expose loopback bridge, or the opt-in
|
||||
# `/studio --listen-all` (pi-studio >= 0.9.60; never together with the
|
||||
# bridge on one port) — see the "Using pi-studio" section in README.md.
|
||||
ENV STUDIO_PORT=8765
|
||||
|
||||
# ── Optional: Go toolchain ───────────────────────────────────────────
|
||||
@@ -513,7 +818,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
|
||||
|
||||
@@ -175,10 +175,12 @@ Currently published:
|
||||
| `joakimp/pi-devbox:latest-studio` | `latest` + [pi-studio](https://github.com/omaclaren/pi-studio) (browser prompt editor, KaTeX/Mermaid preview, tmux-backed literate REPLs) | ~3.25 GB |
|
||||
| `joakimp/pi-devbox:vX.Y.Z-studio` | pinned-version studio equivalent | ~3.25 GB |
|
||||
|
||||
Both variants ship **`typst`** as the pandoc PDF engine
|
||||
(`pandoc --pdf-engine=typst`), a single ~30 MB static binary, so PDF export from
|
||||
Studio/pandoc works out of the box — no separate `-tex` variant needed.
|
||||
`texlive-xetex` stays the higher-fidelity fallback (install on demand).
|
||||
Both variants ship **`typst`**, a single ~30 MB static binary, as a PDF engine
|
||||
for pandoc, so `pandoc doc.md --pdf-engine=typst -o doc.pdf` works out of the
|
||||
box (see [Generating a PDF with pandoc + typst](#generating-a-pdf-with-pandoc--typst)).
|
||||
**pi-studio's PDF export does not use typst.** `/studio-pdf` and
|
||||
`studio_export_pdf` need a LaTeX engine, which neither variant ships (see
|
||||
[PDF export from Studio needs LaTeX](#pdf-export-from-studio-needs-latex)).
|
||||
|
||||
## Using pi-studio (`-studio` variant)
|
||||
|
||||
@@ -198,12 +200,11 @@ Inside a pi session in the container:
|
||||
|
||||
### Reaching the UI from your browser (the container caveat)
|
||||
|
||||
pi-studio **hard-binds its server to `127.0.0.1` inside the container**
|
||||
(`index.ts`: `.listen(port, "127.0.0.1")`) and serves a tokenized URL.
|
||||
There is no `--host`/bind flag. This matters for a container: a plain
|
||||
By default pi-studio **binds its server to `127.0.0.1` inside the
|
||||
container** and serves a tokenized URL. This matters for a container: a plain
|
||||
`docker run -p 8765:8765` publish forwards to the container's *external*
|
||||
interface, **not** its loopback, so it will not reach Studio. Two paths
|
||||
work:
|
||||
interface, **not** its loopback, so it will not reach a default Studio.
|
||||
Three paths work (A and B keep Studio itself on loopback; C does not):
|
||||
|
||||
**A. Host networking (simplest — OrbStack / single-host, no bridge).**
|
||||
Run the container with host networking so the container's loopback is the
|
||||
@@ -215,8 +216,9 @@ services:
|
||||
network_mode: host # container 127.0.0.1 == host 127.0.0.1
|
||||
```
|
||||
|
||||
Then `http://127.0.0.1:8765/?token=…` works in a browser on the Docker
|
||||
host. This is the most secure option (Studio never leaves loopback). Note:
|
||||
Then the URL Studio prints (`http://127.0.0.1:8765/?token=…`) works in a
|
||||
browser on the Docker host. This is the most secure option (Studio never
|
||||
leaves loopback). Note:
|
||||
host networking changes `host.docker.internal` semantics, so weigh it
|
||||
against the LAN-jump SSH feature if you use that.
|
||||
|
||||
@@ -253,6 +255,20 @@ studio-expose & # bridges $STUDIO_PORT (default 8765); --help for detai
|
||||
> its tokenized URL is the only auth. Keep the host-side publish on
|
||||
> `127.0.0.1:` and use `ssh -L` for remote access. Default is **off**.
|
||||
|
||||
**C. `/studio --listen-all` (pi-studio ≥ 0.9.60, no bridge).** pi-studio has
|
||||
its own opt-in flag: `/studio --no-browser --listen-all --port 8765` binds
|
||||
`0.0.0.0`, so the published port reaches it without `socat`. The URL it
|
||||
prints still says `127.0.0.1`, so it works verbatim through a same-port
|
||||
publish. Keep the publish on `127.0.0.1:` as in B: anyone who can reach the
|
||||
port and has the full URL controls Studio, including running prompts and
|
||||
reading or writing files, and there is no TLS.
|
||||
|
||||
> **Use B or C on a port, not both.** Whichever starts second fails with
|
||||
> `EADDRINUSE`: `0.0.0.0` includes the egress IP the bridge binds. With
|
||||
> `STUDIO_EXPOSE=1` the bridge holds `$STUDIO_PORT` from boot, so
|
||||
> `/studio --listen-all --port 8765` fails. If the bridge is running, start
|
||||
> Studio *without* `--listen-all`.
|
||||
|
||||
### Remote host (SSH / mosh)
|
||||
|
||||
When the Docker host is remote, keep Studio on localhost and forward the
|
||||
@@ -298,25 +314,79 @@ Assuming the compose file publishes `127.0.0.1:8765:8765` (see method B):
|
||||
TUI, not a shell command:
|
||||
```
|
||||
/studio --no-browser --port 8765
|
||||
/studio --no-browser --port 8765 --watch notes.md # or: a rendered, read-only preview of a file
|
||||
/studio --status # reprint the URL anytime
|
||||
```
|
||||
Copy the `http://…:8765/?token=<token>` it prints. **This** is where
|
||||
the real token comes from — not `studio-expose`.
|
||||
**This** is where the real token comes from — not `studio-expose`.
|
||||
The port must be the **published** one (`8765` here): the tunnel, the
|
||||
publish and the bridge all lead to it, and Studio on any other port is
|
||||
unreachable from outside. `--port` does not move a running server (Studio
|
||||
only warns), so run `/studio --stop` first to change it.
|
||||
3. **On your laptop** — open the ssh port-forward alongside mosh:
|
||||
```bash
|
||||
ssh -L 8765:127.0.0.1:8765 user@docker-host
|
||||
ssh -N -o ExitOnForwardFailure=yes -L 8765:127.0.0.1:8765 user@docker-host
|
||||
```
|
||||
4. **In your laptop browser** — open `http://127.0.0.1:8765/?token=<token>`
|
||||
(keep the port and token verbatim; only the host part is `127.0.0.1`).
|
||||
Only the **left** port is yours to choose. If `8765` is already taken on
|
||||
the laptop (for example by the laptop's own devbox publish), use
|
||||
`-L 18765:127.0.0.1:8765` and change only the port in the URL.
|
||||
4. **In your laptop browser** — open the **whole** URL Studio printed. It
|
||||
already says `127.0.0.1`, and it carries more than the token
|
||||
(`token`, `studioTabState`, `hostedWorkspace` since pi-studio 0.10, or
|
||||
`docPath`/`watchFile`/… for `--watch`). A URL cut down to just the token
|
||||
is answered `409`.
|
||||
The URL is long and wraps across TUI lines; a side panel next to it (such
|
||||
as the pi-atelier sidebar) can end up inside the copied text, and Studio
|
||||
then answers *"This link looks damaged…"*. Rejoin the lines without the
|
||||
panel text, or widen the terminal and copy it again.
|
||||
|
||||
> **Order check:** nothing listens on the container's `127.0.0.1:8765`
|
||||
> until step 2 runs. If the browser can't connect, verify Studio is up
|
||||
> (`/studio --status`) and the bridge is running (`ps aux | grep socat`).
|
||||
> **on the published port** (`/studio --status`) and the bridge is running
|
||||
> (`ps aux | grep socat`). On the Docker host,
|
||||
> `curl -s http://127.0.0.1:8765/` answers `403` when the whole chain is up;
|
||||
> an empty reply means the bridge has nothing behind it, i.e. Studio is down
|
||||
> or on another port.
|
||||
|
||||
> PDF export (`/studio-pdf`, `studio_export_pdf`) uses **`typst`**, shipped in
|
||||
> the base image as the pandoc PDF engine (`pandoc --pdf-engine=typst`). For
|
||||
> LaTeX-exact output you can install `texlive-xetex` on demand as a heavier
|
||||
> fallback. HTML export, KaTeX, Mermaid, and all REPL features work regardless.
|
||||
### PDF export from Studio needs LaTeX
|
||||
|
||||
Studio's PDF export (`/studio-pdf` and the `studio_export_pdf` tool) fails out
|
||||
of the box:
|
||||
|
||||
```text
|
||||
pandoc PDF export failed with exit code 47: xelatex not found
|
||||
```
|
||||
|
||||
pi-studio runs pandoc with the engine named in `PANDOC_PDF_ENGINE`, and uses
|
||||
`xelatex` when that is unset. The image ships no LaTeX engine and does not set
|
||||
`PANDOC_PDF_ENGINE`. HTML export, KaTeX, Mermaid and the REPLs are not
|
||||
affected.
|
||||
|
||||
**Do not set `PANDOC_PDF_ENGINE=typst`**, even though the error message
|
||||
suggests setting it. pi-studio adds its own LaTeX header
|
||||
(`\usepackage{titlesec}`, `\titleformat…`) to the document, and typst does
|
||||
not understand LaTeX. pandoc exits 0, and the header is printed as text above
|
||||
your first heading. There is no error, only a wrong PDF.
|
||||
|
||||
There are two ways out:
|
||||
|
||||
- **Run pandoc yourself** (nothing to install):
|
||||
`pandoc doc.md --pdf-engine=typst -o doc.pdf`. See the next section.
|
||||
- **Install the LaTeX packages Studio needs.** This adds 37 packages and
|
||||
about 490 MB, and they are gone when the container is recreated:
|
||||
|
||||
```bash
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y --no-install-recommends \
|
||||
texlive-xetex lmodern texlive-plain-generic texlive-fonts-recommended
|
||||
```
|
||||
|
||||
The image starts with an empty package index, so run `apt-get update`
|
||||
first. With `--no-install-recommends`, `texlive-xetex` on its own is not
|
||||
enough: Studio's export then lacks `lmodern.sty`, `ulem.sty` and the `pzdr`
|
||||
font, which the other three packages provide. A plain
|
||||
`sudo apt-get install texlive-xetex` also works, but its recommended
|
||||
packages make it 202 packages and about 1 GB, including systemd, D-Bus and
|
||||
a Java runtime. (Measured on v1.11.2 with pi-studio 0.10.1.)
|
||||
|
||||
### Generating a PDF with pandoc + typst
|
||||
|
||||
@@ -358,6 +428,61 @@ 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.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
|
||||
the terminal's alternate screen, so pi's transcript no longer accumulates in your
|
||||
terminal's native scrollback — you scroll inside pi instead, and on exit pi
|
||||
prints the transcript (`fullscreenExitOutput`).
|
||||
|
||||
That is a real behaviour change if you were used to the old mode, so here is the
|
||||
way back. Nothing in the image is pinned, so all three routes below work:
|
||||
|
||||
| Scope | How |
|
||||
|---|---|
|
||||
| **Permanently, for every session** | add `"tuiMode": "regular"` to `~/.pi/agent/settings.json` |
|
||||
| **One session** | `pi --tui-mode regular` |
|
||||
| **One project only** | add `"tuiMode": "regular"` to that project's `.pi/settings.json` — project settings override the agent directory |
|
||||
|
||||
The settings file is plain JSON and `tuiMode` is top-level, so the minimal
|
||||
permanent change is:
|
||||
|
||||
```bash
|
||||
# merge the key without disturbing the rest of the file (python3 is always present)
|
||||
python3 - <<'PY'
|
||||
import json, pathlib
|
||||
p = pathlib.Path.home() / ".pi/agent/settings.json"
|
||||
d = json.loads(p.read_text()) if p.exists() else {}
|
||||
d["tuiMode"] = "regular" # "fullscreen" is pi's default
|
||||
p.write_text(json.dumps(d, indent=2) + "\n")
|
||||
PY
|
||||
```
|
||||
|
||||
If you stay on fullscreen, four related settings are worth knowing — all
|
||||
documented in pi's own `docs/settings.md` under *Terminal and display*:
|
||||
|
||||
- `fullscreenExitOutput` — `"transcript"` (default) or `"resume-hint"`: what pi
|
||||
leaves behind in the terminal when fullscreen exits.
|
||||
- `fullscreenScrollbar` — `"auto"` (default), `"always"`, `"hidden"`.
|
||||
- `fullscreenCopyOnSelect` — `true` by default; selecting text copies it.
|
||||
- `fullscreenWheelScrollLines` — `"auto"` by default. Relevant **here** in
|
||||
particular: this container is normally driven over SSH, and over SSH `"auto"`
|
||||
accelerates fast wheel spins to at most 6 lines per event (local macOS
|
||||
terminals accelerate on their own, so there it moves one line). `Alt`+wheel
|
||||
moves five times as far.
|
||||
|
||||
Two notes specific to this image:
|
||||
|
||||
- **tmux.** Fullscreen uses the alternate screen, so `tmux` copy-mode scrollback
|
||||
shows the pane's history *around* pi, not pi's transcript. Scroll within pi,
|
||||
or use `"tuiMode": "regular"` if you rely on tmux copy-mode to search the
|
||||
conversation.
|
||||
- **pi-atelier.** The bundled sidebar works in both modes — upstream has handled
|
||||
regular and fullscreen renderers separately since pi 0.84, including
|
||||
fullscreen divider dragging and keeping sidebar text out of fullscreen
|
||||
selection — so switching back to `regular` does not cost you the sidebar.
|
||||
|
||||
## Using pi-atelier (TUI sidebar)
|
||||
|
||||
`pi-atelier` is bundled in **both** variants (vendored at `/opt/pi-atelier`,
|
||||
@@ -409,7 +534,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
|
||||
|
||||
@@ -676,7 +804,14 @@ directory, and they compose:
|
||||
mechanisms, the interactive-vs-tool-shell alias gotcha (`dssh`/`dscp`),
|
||||
tmux 0-indexing, uv-first Python, and pi-studio reachability, all as
|
||||
*mechanisms* (deployment-specific hostnames/domains/nameservers are
|
||||
discovered at runtime, never hardcoded).
|
||||
discovered at runtime, never hardcoded). **`pi-devbox-firstboot`** ships the
|
||||
same way and is the entry point to the fleet's first-boot acceptance round:
|
||||
it carries the *state-based scope gate* (two claim lookups, keyed by release
|
||||
tag and by architecture, deciding which tiers of checks this device still
|
||||
owes) rather than the policy itself, which it locates in the logstream so a
|
||||
policy revision needs no release. Baked rather than skillset-owned on purpose
|
||||
— the device that most needs it is a brand-new one with an empty mailbox and
|
||||
no coordination chain, so nothing would deliver it.
|
||||
- **Vendored fallback skills.** The pi-toolkit global `AGENTS.md` tells every
|
||||
pi session to read `~/.agents/skills/pi-extensions/SKILL.md` at start (to fix
|
||||
fork/recall under-utilisation). That pointer would dangle in a container
|
||||
@@ -824,6 +959,61 @@ 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.
|
||||
|
||||
### The same directory can carry pi extensions
|
||||
|
||||
pi auto-loads every `.ts` in `~/.pi/agent/extensions/`, which lives in the same
|
||||
volume and so has the same problem. The entrypoint therefore links top-level
|
||||
`*.ts` files from the mounted directory in as well, one line each:
|
||||
|
||||
pi extension: linked erdc-classifiers.ts -> /opt/fleet-pi/erdc-classifiers.ts
|
||||
|
||||
- Only **top-level** `*.ts` files are linked: `models.json`, a README or a
|
||||
nested checkout in that directory is not an extension, and one is never loaded
|
||||
by accident.
|
||||
- Same never-destructive rule, with the same `NOTE` lines, and the same
|
||||
reasoning that makes a link right where a single-file mount is not (a pulled
|
||||
change is picked up at once).
|
||||
- **Your own copy wins.** A real file of the same name in
|
||||
`~/.pi/agent/extensions/` is left alone, so a local edit of a shared extension
|
||||
is never overwritten.
|
||||
- `PI_EXTENSIONS_SRC` points at another directory, and `PI_EXTENSIONS_SRC=off`
|
||||
disables it. `recreate-sanity-check.sh` verifies each link whenever the shared
|
||||
directory holds `*.ts`.
|
||||
|
||||
## Build pipeline
|
||||
|
||||
pi-devbox is built from this repo's CI in two phases:
|
||||
@@ -941,6 +1131,7 @@ pi-devbox v1.8.14
|
||||
credential-incident-response baked
|
||||
mempalace live /workspace/skillset @ 4d7c0ea (identical to baked snapshot)
|
||||
pi-devbox-environment baked
|
||||
pi-devbox-firstboot baked
|
||||
pi-extensions baked
|
||||
```
|
||||
|
||||
@@ -991,12 +1182,20 @@ published image (they are a fact about your LAN, not the image). Alternatively,
|
||||
set `DEVBOX_LAN_AUTOJUMP_PRIVATE=1` to ProxyJump *any* RFC1918 address through
|
||||
the host without naming peers (see `.env.example`).
|
||||
|
||||
Once the file exists it is re-read on every connection, so *edits* take effect
|
||||
immediately — no container or session restart. **Creating it for the first time
|
||||
does need one restart**, because `setup-lan-access.sh` only emits the
|
||||
`Include ~/.config/devbox-shell/ssh-lan.conf` line when the file is already
|
||||
readable at container start (`if [ -r "$SSH_LAN_CONF" ]`). Until then ssh never
|
||||
looks at it — which reads exactly like "my override is being ignored".
|
||||
ssh reads the file on every connection, so creating it and editing it both take
|
||||
effect immediately — no container or session restart. Since v1.12.1,
|
||||
`setup-lan-access.sh` writes the `Include ~/.config/devbox-shell/ssh-lan.conf`
|
||||
line whether or not the file exists yet (ssh skips an `Include` whose file is
|
||||
missing). Images up to and including v1.12.0 wrote that line only if the file
|
||||
was readable at container start. There, a file created afterwards is ignored
|
||||
until a restart, or until you run `bash /usr/local/lib/pi-devbox/setup-lan-access.sh`
|
||||
in the container.
|
||||
That looks exactly like "my override is being ignored". One case still needs
|
||||
action: a file that exists but the container cannot read is left out, because
|
||||
ssh would otherwise refuse every connection. The script then prints a warning at
|
||||
start. Two other permission traps apply to this file as to any ssh config.
|
||||
ssh rejects it if it is world-writable (`Bad owner or permissions`). And on native
|
||||
Linux, the host user's uid must match the container's `developer` (1000).
|
||||
|
||||
#### Giving the container its own key for a peer
|
||||
|
||||
@@ -1106,7 +1305,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 0.85.1 # assert the pi coding agent version
|
||||
./scripts/recreate-sanity-check.sh --expected-version 1.4.0 # assert the pi coding agent version
|
||||
```
|
||||
|
||||
Those are **two different versions**, and the flags are not interchangeable:
|
||||
@@ -1140,14 +1339,15 @@ user's pull.
|
||||
|
||||
### Version pins: pi, pi-atelier, mempalace
|
||||
|
||||
Three components are pinned to an exact version **in the repo** instead of being
|
||||
Four components are pinned to an exact version **in the repo** instead of being
|
||||
resolved to `latest` at build time:
|
||||
|
||||
| Component | Pin | Where |
|
||||
|---|---|---|
|
||||
| pi | `0.85.1` | `ARG PI_VERSION` — `Dockerfile.variant` |
|
||||
| pi-atelier | `v0.10.3` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
|
||||
| mempalace | `3.10.0` | `ARG MEMPALACE_VERSION` — `Dockerfile.base` |
|
||||
| pi | `1.4.0` | `ARG PI_VERSION` — `Dockerfile.variant` |
|
||||
| pi-obsmem | `022da5e5554d04dfdc876ed7dac4a42c6b83dfd4` | `ARG PI_OBSMEM_REF` — `Dockerfile.variant` |
|
||||
| pi-atelier | `v0.16.0` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
|
||||
| mempalace | `3.11.0` | `ARG MEMPALACE_VERSION` — `Dockerfile.base` |
|
||||
|
||||
The objective is **not** to freeze versions. Bumping is routine — usually one
|
||||
line plus a changelog note. The objective is that adopting a new upstream
|
||||
|
||||
+56
-1
@@ -108,7 +108,13 @@ if command -v mempalace &>/dev/null && [ -d /workspace ]; then
|
||||
# own resolution can no longer disagree about where the palace lives — a
|
||||
# disagreement that would make this branch fire on every start.
|
||||
PALACE_DIR="${MEMPALACE_CONFIG_DIR:-${HOME}/.mempalace}"
|
||||
if [ ! -d "$PALACE_DIR/palace" ]; then
|
||||
# Sentinel = config.json, because that is what `mempalace init` writes.
|
||||
# It does NOT create palace/ — mining does — so the earlier test on palace/
|
||||
# re-fired on every start of a container that had never mined locally
|
||||
# (v1.9.4 acceptance: 1 "Initializing" line after one boot, 2 after a
|
||||
# restart). Harmless (init is idempotent) but the log lied. Populated
|
||||
# volumes (config.json present) skip either way.
|
||||
if [ ! -f "$PALACE_DIR/config.json" ]; then
|
||||
echo "Initializing MemPalace for workspace (non-interactive)..."
|
||||
# </dev/null: mempalace init has an interactive "Mine this directory
|
||||
# now? [Y/n]" prompt that --yes does not auto-answer in all paths.
|
||||
@@ -313,6 +319,35 @@ fi
|
||||
if [ -f "$HOME/.gitignore_global" ] && ! git config --global core.excludesFile &>/dev/null; then
|
||||
git config --global core.excludesFile "$HOME/.gitignore_global"
|
||||
fi
|
||||
# Route git-over-ssh through the WRITABLE ssh sidecar. ~/.ssh is commonly
|
||||
# bind-mounted read-only from the host, and a per-host
|
||||
# ControlPath ~/.ssh/cm/%r@%h:%p
|
||||
# inherited from that config (the standard CGNAT multiplexing recipe) kills every
|
||||
# push with
|
||||
# unix_listener: cannot bind to path ~/.ssh/cm/...: Read-only file system
|
||||
# hidden behind git's misleading "Please make sure you have the correct access
|
||||
# rights", which sends the reader hunting for a key problem that does not exist.
|
||||
# setup-lan-access.sh (run near the top of this script) already wrote
|
||||
# ~/.ssh-local/config, whose leading `Host *` block overrides ControlPath into
|
||||
# the writable ~/.ssh-local/cm and only THEN `Include`s the user's own config —
|
||||
# so -F repairs the socket path while keeping every per-host User/Port/
|
||||
# IdentityFile. Wiring it here means no caller has to know any of that.
|
||||
#
|
||||
# WHY THIS IS NOT LEFT TO DOCUMENTATION: measured 2026-09-22 on tor-ms22, an
|
||||
# agent with the remedy in its system prompt, in a loaded skill, in 24 palace
|
||||
# drawers, AND printed verbatim by recreate-sanity-check.sh two hours earlier
|
||||
# still hit this and reinvented a /tmp/sshcm workaround. The knowledge was
|
||||
# available four times over, so a fifth copy is not the fix — removing the need
|
||||
# to know is.
|
||||
#
|
||||
# The [ -r ] guard is load-bearing, not decoration: setup-lan-access.sh only
|
||||
# writes the sidecar on VM-backed hosts (OrbStack / Docker Desktop). On native
|
||||
# Linux Docker there is none, and pointing -F at a missing file would break EVERY
|
||||
# git-over-ssh operation instead of fixing one. Respect a value the user already
|
||||
# set — same first-wins convention as the three settings above.
|
||||
if [ -r "$HOME/.ssh-local/config" ] && ! git config --global core.sshCommand &>/dev/null; then
|
||||
git config --global core.sshCommand "ssh -F $HOME/.ssh-local/config"
|
||||
fi
|
||||
|
||||
# ── pi: deploy toolkit + extensions + mempalace bridge ─────────────
|
||||
# pi is always installed in pi-devbox; no INSTALL_PI guard needed.
|
||||
@@ -336,6 +371,26 @@ 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
|
||||
|
||||
# Shared pi extensions: link each TOP-LEVEL *.ts in that same mounted directory
|
||||
# into ~/.pi/agent/extensions (default /opt/fleet-pi; PI_EXTENSIONS_SRC overrides,
|
||||
# =off disables). Same reasoning and same never-destructive rule as models.json
|
||||
# above: without it every fresh devbox-pi-config volume needed a manual `ln -s`
|
||||
# per extension per device, and the copies drift. A user's own file of the same
|
||||
# name wins over the fleet copy. Rationale and cases: the script's header.
|
||||
if [ -r /usr/local/lib/pi-devbox/link-pi-extensions.sh ]; then
|
||||
bash /usr/local/lib/pi-devbox/link-pi-extensions.sh || true
|
||||
fi
|
||||
|
||||
# Bootstrap settings.json from template if absent (pi rewrites this
|
||||
# file at runtime — lastChangelogVersion, etc — so we can't symlink it).
|
||||
_pi_settings="$HOME/.pi/agent/settings.json"
|
||||
|
||||
@@ -3,13 +3,17 @@
|
||||
# through a published Docker port.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# pi-studio hard-binds its HTTP/WebSocket server to 127.0.0.1 inside the
|
||||
# container (index.ts: `.listen(port, "127.0.0.1")`) and there is no
|
||||
# --host / bind flag. A plain `docker run -p 8765:8765` forwards to the
|
||||
# By default pi-studio binds its HTTP/WebSocket server to 127.0.0.1 inside
|
||||
# the container. A plain `docker run -p 8765:8765` forwards to the
|
||||
# container's EXTERNAL interface (eth0), not its loopback, so it cannot
|
||||
# reach Studio. This helper runs a socat TCP relay that listens on the
|
||||
# container's egress IP and forwards to 127.0.0.1:<port>, so a published
|
||||
# port (and an `ssh -L` tunnel from your laptop) can reach Studio.
|
||||
# reach a default Studio. This helper runs a socat TCP relay that listens
|
||||
# on the container's egress IP and forwards to 127.0.0.1:<port>, so a
|
||||
# published port (and an `ssh -L` tunnel from your laptop) can reach Studio.
|
||||
#
|
||||
# The alternative (pi-studio >= 0.9.60) is `/studio --listen-all`, which
|
||||
# binds 0.0.0.0 and needs no bridge. Use one or the other on a port, never
|
||||
# both: whichever starts second fails with EADDRINUSE (0.0.0.0 includes
|
||||
# the egress IP this relay binds).
|
||||
#
|
||||
# SECURITY
|
||||
# This intentionally exposes Studio beyond loopback — anything that can
|
||||
@@ -34,7 +38,7 @@ set -euo pipefail
|
||||
PORT="${1:-${STUDIO_PORT:-8765}}"
|
||||
|
||||
if [ "$PORT" = "--help" ] || [ "$PORT" = "-h" ]; then
|
||||
sed -n '2,31p' "$0" | sed 's/^# \{0,1\}//'
|
||||
sed -n '2,35p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# link-pi-extensions.sh — point ~/.pi/agent/extensions/*.ts at shared, mounted files.
|
||||
#
|
||||
# pi auto-loads every .ts file in ~/.pi/agent/extensions/. In pi-devbox that
|
||||
# directory lives in the devbox-pi-config named volume, which makes both obvious
|
||||
# ways of providing a fleet extension wrong — the same two ways as models.json
|
||||
# (see link-pi-models.sh, whose header this mirrors):
|
||||
# - baked into the image, it would only seed a FRESH volume, once;
|
||||
# - copied by hand into each volume, it is one more per-device step, and the
|
||||
# copies drift the first time the extension changes.
|
||||
# Instead, keep the .ts files in the same host directory that already holds
|
||||
# models.json, mount that directory read-only, and let this script link them in
|
||||
# at every container start:
|
||||
#
|
||||
# volumes:
|
||||
# - ./pi-shared:/opt/fleet-pi:ro # a directory whose top-level *.ts are extensions
|
||||
#
|
||||
# A SYMLINK, not a single-file bind mount, and for the measured reason given in
|
||||
# link-pi-models.sh: `git pull` replaces a file by rename, so a single-file mount
|
||||
# keeps the old inode and serves stale content until the next recreate. A link
|
||||
# follows the path, so a pulled change reaches the next pi session.
|
||||
#
|
||||
# Only TOP-LEVEL *.ts files are linked — no recursive search. The directory is
|
||||
# fleet-shared pi config: models.json, its own docs and any sub-checkout are not
|
||||
# extensions, and silently loading them would be worse than missing one.
|
||||
#
|
||||
# PI_EXTENSIONS_SRC overrides the directory (default /opt/fleet-pi).
|
||||
# PI_EXTENSIONS_SRC=off (or empty) disables this script.
|
||||
#
|
||||
# Never destructive, per file. It only ever creates a link where nothing exists:
|
||||
# - no file at the destination → link created
|
||||
# - the destination is already ours → nothing
|
||||
# - a real file with that name exists → left alone; one NOTE line says why
|
||||
# (the user's own extension of that name deliberately wins over the fleet copy)
|
||||
# - the destination links elsewhere → left alone; a deliberate choice
|
||||
# A link this script made whose target later disappears (mount removed) is
|
||||
# harmless and needs no cleanup: pi reports the unreadable extension and the rest
|
||||
# of the session is unaffected.
|
||||
#
|
||||
# Called by entrypoint-user.sh. Safe to run by hand at any time.
|
||||
set -u
|
||||
|
||||
src="${PI_EXTENSIONS_SRC-/opt/fleet-pi}"
|
||||
case "$src" in
|
||||
"" | off | OFF | Off) exit 0 ;;
|
||||
esac
|
||||
[ -d "$src" ] || exit 0
|
||||
|
||||
agent_dir="${PI_CODING_AGENT_DIR:-$HOME/.pi/agent}"
|
||||
dest_dir="$agent_dir/extensions"
|
||||
|
||||
for f in "$src"/*.ts; do
|
||||
[ -f "$f" ] || continue
|
||||
name="$(basename "$f")"
|
||||
dest="$dest_dir/$name"
|
||||
|
||||
if [ -L "$dest" ]; then
|
||||
# Ours already, or someone else's deliberate link: either way, nothing to do.
|
||||
[ "$(readlink "$dest")" = "$f" ] || \
|
||||
echo "NOTE: $dest links to $(readlink "$dest"), not $f; left alone"
|
||||
continue
|
||||
fi
|
||||
if [ -e "$dest" ]; then
|
||||
echo "NOTE: $dest is a real file; not linking $f (move it away to use the fleet copy)"
|
||||
continue
|
||||
fi
|
||||
|
||||
mkdir -p "$dest_dir" 2>/dev/null || true
|
||||
if ln -s "$f" "$dest" 2>/dev/null; then
|
||||
echo "pi extension: linked $name -> $f"
|
||||
else
|
||||
echo "WARN: could not link $dest -> $f (continuing)"
|
||||
fi
|
||||
done
|
||||
exit 0
|
||||
+64
@@ -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
|
||||
@@ -51,8 +51,8 @@
|
||||
# HOST-OWNED PEER POLICY (portable; keeps this image generic)
|
||||
# Named LAN peers are facts about a *specific* host's network, not about the
|
||||
# image — a roaming laptop sees different LANs. So we never bake peer names
|
||||
# here. Instead, if the host bind-mounts ~/.config/devbox-shell/ssh-lan.conf
|
||||
# (the same devbox-shell bridge dir used for shared aliases), we Include it
|
||||
# here. Instead we Include ~/.config/devbox-shell/ssh-lan.conf (the host
|
||||
# bind-mounts the devbox-shell bridge dir, also used for shared aliases)
|
||||
# *before* ~/.ssh/config. That file holds the host's own jump overrides, e.g.
|
||||
# Host pve pve-2 pbs-vm
|
||||
# ProxyJump host
|
||||
@@ -151,8 +151,22 @@ EOF
|
||||
|
||||
# Optional host-owned named-peer jump overrides (portable: lives on the host,
|
||||
# not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins.
|
||||
#
|
||||
# Included even when the file does not exist yet. ssh skips an Include whose
|
||||
# file is missing, and reads it on the next connection once it appears.
|
||||
# Through v1.12.0 the Include was written only if the file was readable at
|
||||
# container start. A file created later was then visible in the container but
|
||||
# ignored, until a restart (EMB-3HXGNXJ0, 2026-10-18: its tor-ms22 override
|
||||
# was skipped, so ssh fell back to a passphrase key and BatchMode failed).
|
||||
# Only a file that EXISTS but cannot be read is left out. ssh aborts every
|
||||
# connection on one ("Can't open user config file"), which would break all
|
||||
# ssh, git included. A directory at that path is harmless (ssh ignores it).
|
||||
# Measured with this image's OpenSSH 10.0p2; scripts/smoke-test.sh checks
|
||||
# both cases.
|
||||
SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf"
|
||||
if [ -r "$SSH_LAN_CONF" ]; then
|
||||
if [ -e "$SSH_LAN_CONF" ] && [ ! -r "$SSH_LAN_CONF" ]; then
|
||||
echo "setup-lan-access: $SSH_LAN_CONF exists but is not readable, so it is not Included (ssh would refuse every connection). Fix its permissions, then rerun this script or restart the container." >&2
|
||||
else
|
||||
LAN_CONF_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# Host-owned named-peer jump overrides (bind-mounted; edit on the host).
|
||||
@@ -182,8 +196,17 @@ EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
# Same rule as ssh-lan.conf above: Included even when ~/.ssh/config does not
|
||||
# exist yet, so a file that appears after container start (a host provisioned
|
||||
# after its first `docker compose up`) is read on the next ssh. Entries added to
|
||||
# a file that already existed were always picked up: ssh reads Includes on every
|
||||
# connection and the mount is live. Through v1.12.1 the line was written only if
|
||||
# the file was readable at start. Only a file that EXISTS but cannot be read is
|
||||
# left out, because ssh would then abort every connection, git included.
|
||||
INCLUDE_BLOCK=""
|
||||
if [ -r "${HOME}/.ssh/config" ]; then
|
||||
if [ -e "${HOME}/.ssh/config" ] && [ ! -r "${HOME}/.ssh/config" ]; then
|
||||
echo "setup-lan-access: ${HOME}/.ssh/config exists but is not readable, so it is not Included (ssh would refuse every connection). Fix its permissions, then rerun this script or restart the container." >&2
|
||||
else
|
||||
INCLUDE_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# Your own target hosts. Scope reset to match-all so this Include applies to
|
||||
|
||||
@@ -71,6 +71,26 @@ rather than merely confusing you:
|
||||
the MCP tools while both look correct. Use the MCP tools for the central
|
||||
palace; the CLI only for a local one.
|
||||
|
||||
## First boot on a new release: scope the acceptance round
|
||||
|
||||
If you are in a pi-devbox container that was just recreated, or one running a
|
||||
release this device has not accepted before — or a `firstboot-acceptance` ask is
|
||||
in your mailbox, or your mailbox is **empty and this device has never run that
|
||||
round** — read `~/.agents/skills/pi-devbox-firstboot/SKILL.md` before starting.
|
||||
|
||||
That round is **scoped by published state, not run in full by every device**:
|
||||
two claim lookups, keyed by release tag and by architecture, decide whether you
|
||||
owe the release tier, the per-architecture image tier, or only the device tier.
|
||||
Three rules are load-bearing enough to repeat here. **Derive the release from
|
||||
`/etc/pi-devbox/build-manifest.json`; never predict it** — four tags in this
|
||||
image's history published nothing. **Honour a peer's claim only if its keys
|
||||
match your own first-hand readings**, or one stale claim silently switches off
|
||||
verification across the whole fleet while every device still reports PASS. And
|
||||
**the device tier always runs**, because the same image reads differently per
|
||||
device and both readings are correct (a warn count is not a device property
|
||||
unless the invocation is pinned). An empty mailbox on a device with no chain
|
||||
means *you* plant the first ask — nobody is watching for new devices.
|
||||
|
||||
## Before you file a finding: second measurement, different route
|
||||
|
||||
This is here rather than in a skill because it has to fire *without* a matching
|
||||
|
||||
@@ -10,6 +10,7 @@ one", which was a bug).
|
||||
|-------|-------|------------------|
|
||||
| `pi-devbox-environment` | pi-devbox (this repo) | authored here; the canonical copy |
|
||||
| `credential-incident-response` | pi-devbox (this repo) | authored here; the canonical copy |
|
||||
| `pi-devbox-firstboot` | pi-devbox (this repo) | authored here; the canonical copy |
|
||||
| `pi-extensions` | the `pi-extensions` package repo (`skill/`) | **vendored fallback** + refreshed at build |
|
||||
| `mempalace` | the `skillset` repo | **vendored fallback** (snapshot only) |
|
||||
|
||||
@@ -27,6 +28,20 @@ actually picks it up rather than relying on description-matching.
|
||||
`pi-extensions`'s directive already ships in pi-toolkit's `AGENTS.md`, so only
|
||||
its skill file needed baking.
|
||||
|
||||
`pi-devbox-firstboot` is the third case: authored here, baked canonical, **and**
|
||||
pointed at from the managed block, for a reason specific to what it does. It is
|
||||
the entry point to the fleet's first-boot acceptance round, and the device that
|
||||
most needs it is a **brand-new** one whose mailbox is empty and whose chain does
|
||||
not exist yet — so nothing will deliver the procedure to it, and
|
||||
description-matching on an empty mailbox is exactly the case that fails. It is
|
||||
deliberately a *finder*: it locates the current policy artifact in the logstream
|
||||
rather than restating it, so a policy revision does not require a release and a
|
||||
device booting an older image still reaches the current policy. It is **not**
|
||||
skillset-owned, because the round verifies the image and should therefore be
|
||||
pinned to the image — and because a skillset-owned skill needs the vendoring
|
||||
machinery (`ARG SKILLSET_SNAPSHOT_REF`, the manifest/label pair, and a
|
||||
per-skill refresh script), which today exists only for `mempalace`.
|
||||
|
||||
## Freshness model (layered — see Dockerfile.variant)
|
||||
|
||||
- **`pi-extensions`** — Option 1 + Option 2. The committed copy here is the
|
||||
@@ -61,7 +76,7 @@ its skill file needed baking.
|
||||
`docker build` records the same thing CI does.
|
||||
|
||||
Verifying "is this snapshot current?" is **not** a CI job and was deliberately
|
||||
not made one — see the Unreleased CHANGELOG entry for why (private repo;
|
||||
not made one — see the v1.8.8 CHANGELOG entry for why (private repo;
|
||||
another repo's branch must not be able to fail this build; and the artefact it
|
||||
would guard is read by no host on this fleet). The check belongs where the
|
||||
skillset actually is: `vendor-mempalace-skill.sh --check` for a maintainer,
|
||||
|
||||
@@ -6,13 +6,7 @@ description: >-
|
||||
agent-authored note. Load this whenever a task involves a leaked/exposed
|
||||
secret, a token rotation, a "is this credential still live?" question, deciding
|
||||
whether to delete or scrub stored content, proving a corpus is clean, or
|
||||
choosing scopes for a new API token. Covers the mandatory order of operations
|
||||
(probe the issuer FIRST — severity before cleanliness), leak-free identity via
|
||||
sha256[:8] fingerprints and when publishing one is safe,
|
||||
why revocation beats deletion for anything already replicated, scopes derived
|
||||
from measured consumers, the three places a secret hides in a Chroma palace, how to prove ABSENCE rather than assume it (instrument strength,
|
||||
census vs class passes, the tokenisation trap where quoting decides detectability, why git filters never run on symlinks, self-tests that abort),
|
||||
where this fleet's secrets live, and what rotation does NOT fix.
|
||||
choosing scopes for a new API token.
|
||||
---
|
||||
|
||||
# Credential incident response
|
||||
|
||||
@@ -6,14 +6,7 @@ description: >-
|
||||
shell prompt is prefixed `[devbox]`, or `~/.ssh-local/config` is present) and
|
||||
the task touches any of: reaching the Docker host or its LAN, SSH, DNS name
|
||||
resolution, what survives container recreate (persistence vs ephemerality),
|
||||
running Python or other REPLs, tmux, or the pi-studio browser UI. Covers the
|
||||
persistence model, the interactive-vs-tool-shell alias gotcha
|
||||
(dssh/dscp/cat=bat exist only in interactive bash), host + LAN SSH
|
||||
reachability and ControlMaster, split-horizon DNS mechanisms, the tmux
|
||||
0-index constraint, uv-first Python, and pi-studio reachability. This skill
|
||||
teaches MECHANISMS only — concrete hostnames, usernames, internal domains,
|
||||
nameservers, and even the host OS vary per deployment and MUST be discovered
|
||||
at runtime, never assumed or hardcoded.
|
||||
running Python or other REPLs, tmux, or the pi-studio browser UI.
|
||||
---
|
||||
|
||||
# pi-devbox environment
|
||||
@@ -356,8 +349,8 @@ window: 0". Leave the indexing alone in this environment.
|
||||
## 7. pi-studio reachability (only in the `-studio` variant)
|
||||
|
||||
Present only if `/opt/pi-studio` exists / the `studio_*` tools are in your tool
|
||||
list. pi-studio **binds to `127.0.0.1` inside the container** with no host-bind
|
||||
flag, so a plain `docker -p` publish can't reach it. Two supported paths:
|
||||
list. By default pi-studio **binds to `127.0.0.1` inside the container**, so a
|
||||
plain `docker -p` publish can't reach it. Three supported paths:
|
||||
|
||||
- **Host networking** (`network_mode: host`): container loopback == host
|
||||
loopback; open the tokenized URL on the host. (Changes
|
||||
@@ -365,10 +358,25 @@ flag, so a plain `docker -p` publish can't reach it. Two supported paths:
|
||||
- **`studio-expose` bridge** (`STUDIO_EXPOSE=1` or run `studio-expose &`): a
|
||||
`socat` relay from the container's external interface to its loopback, so a
|
||||
published `127.0.0.1:PORT` + `ssh -L PORT:127.0.0.1:PORT host` reaches it.
|
||||
- **`/studio --listen-all`** (pi-studio ≥ 0.9.60): binds `0.0.0.0`, so the
|
||||
published port reaches it with no bridge; the printed URL still says
|
||||
`127.0.0.1`. **Never on the same port as the bridge**: whichever starts
|
||||
second fails with `EADDRINUSE`. `ss -ltnp | grep ":$STUDIO_PORT"` showing a
|
||||
`socat` line means the bridge is up, so start Studio without `--listen-all`.
|
||||
|
||||
The real auth token comes from the `/studio` slash command (`/studio --status`
|
||||
to reprint), **not** from `studio-expose`. For Graphviz, use `dot-watch` →
|
||||
PNG (Studio renders Mermaid natively and previews PNG, but not SVG/DOT).
|
||||
to reprint), **not** from `studio-expose`. Hand over the **whole** printed
|
||||
URL: since pi-studio 0.10 it also carries `studioTabState` + `hostedWorkspace`
|
||||
(or `docPath`/`watchFile`/… for `--watch`), and a token-only URL is answered
|
||||
`409`. The URL wraps across TUI lines; if a side panel's text lands inside the
|
||||
copy, Studio answers *"This link looks damaged…"*. Studio must run on the
|
||||
**published** port (only the laptop side of `ssh -L` is free to choose), and
|
||||
`--port` on a running server only warns, so `/studio --stop` first. On the
|
||||
Docker host, `curl -s http://127.0.0.1:$STUDIO_PORT/` answering `403` means
|
||||
the chain is up; an empty reply means Studio is down or on another port.
|
||||
|
||||
For Graphviz, use `dot-watch` → PNG (Studio renders Mermaid natively and
|
||||
previews PNG, but not SVG/DOT).
|
||||
|
||||
## 8. MemPalace is the shared brain
|
||||
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
---
|
||||
name: pi-devbox-firstboot
|
||||
description: >-
|
||||
Run and correctly scope the fleet's first-boot acceptance round for a
|
||||
pi-devbox container. Load this when a container was just recreated or runs a
|
||||
release it has not accepted before, when a firstboot-acceptance ask is in
|
||||
your mailbox, when verifying a new pi-devbox release, or when your mailbox is
|
||||
EMPTY and this device has never run the round — the self-enrollment case,
|
||||
where no chain exists and nobody will plant one for you. Covers the
|
||||
state-based scope gate (two claim lookups keyed by release tag and
|
||||
architecture), which checks settle once per release, which settle once per
|
||||
architecture, and which must be re-measured per device whatever a peer
|
||||
proved; validating a claim against your own readings before honouring it;
|
||||
planting and perpetuating your own chain; and the invariants that keep it
|
||||
alive (derive the release from the manifest instead of predicting it, reply
|
||||
terminal before planting a successor, never score a cannot-run as a pass).
|
||||
---
|
||||
|
||||
# First-boot acceptance for a pi-devbox container
|
||||
|
||||
**This skill is a FINDER, not the policy.** The authoritative procedure lives in
|
||||
the logstream as a versioned artifact, because a policy baked into an image is
|
||||
pinned to that image and cannot be corrected without a release. Your first job
|
||||
is therefore to fetch the current policy, not to follow this file from memory.
|
||||
|
||||
```
|
||||
mempalace_event_list(topic="firstboot-acceptance", type="policy.update", limit=5)
|
||||
```
|
||||
|
||||
Take the newest, read `metadata.policy_artifact`, and fetch it:
|
||||
|
||||
```
|
||||
mempalace_artifact_get(artifact_id="<policy_artifact>")
|
||||
```
|
||||
|
||||
**If that artifact and this file disagree, the artifact wins** — it is newer by
|
||||
construction. Treat this file as the thing that tells you the artifact exists.
|
||||
(As of writing: policy v2, `art_20261023T204306_b388e93c3d64`, sha256
|
||||
`40048d5d…`, 11252 B. Verify rather than trust that id; policies get revised.)
|
||||
|
||||
## Why there is a gate at all
|
||||
|
||||
Every device used to run the entire check sequence on every release. That was
|
||||
not a decision — it was drift. Each device plants its own successor ask carrying
|
||||
whatever the previous round contained, so scope only ever grew (4 checks at
|
||||
v1.10.2 → B1–B5 at v1.11.2 → B1–B7 at v1.12.2), while a peer's PASS on the
|
||||
*identical image bytes* was recorded as interesting context rather than as
|
||||
grounds to skip anything. The fleet had actually decided the opposite in
|
||||
2026-08 (v1.8.10: "IMAGE HALF — ALREADY SETTLED, DO NOT REPEAT"), and nothing
|
||||
ever retired that decision; it simply lapsed.
|
||||
|
||||
The gate restores it, in a form that needs **no roster**. Nothing addresses you,
|
||||
nothing enrolls you, and a device that leaves the fleet leaves nothing behind to
|
||||
clean up. You derive your own scope by reading published state.
|
||||
|
||||
## The gate
|
||||
|
||||
```
|
||||
release_tag, source_revision <- /etc/pi-devbox/build-manifest.json
|
||||
arch, my_arch_digest <- your architecture, your PER-ARCH manifest digest
|
||||
|
||||
R_claim <- event_list(correlation_id="firstboot-release-<TAG>", type="firstboot.release.settled")
|
||||
I_claim <- event_list(correlation_id="firstboot-image-<TAG>-<ARCH>", type="firstboot.image.settled")
|
||||
```
|
||||
|
||||
| R valid | I valid | you run | meaning |
|
||||
|---|---|---|---|
|
||||
| no | no | **R + I + D** | first device on this release, anywhere |
|
||||
| yes | no | **I + D** | first device on this *architecture* for this release |
|
||||
| yes | yes | **D only** | cite the claim ids you inherited |
|
||||
|
||||
Emit a claim for each tier you newly settle. **Tier D always runs.**
|
||||
|
||||
The `correlation_id` *is* the state key, which makes this two exact server-side
|
||||
lookups — no scanning, no metadata filtering, and the same cost whether the
|
||||
fleet has three devices or thirty.
|
||||
|
||||
### RULE ZERO: derive the release, never predict it
|
||||
|
||||
Read `release_tag` from the manifest at boot. Do not write an expected release
|
||||
into an ask, a drawer or an expectation file. Seven plants on this fleet have
|
||||
died of predicting a version: v1.9.0, v1.10.0, v1.10.1 and v1.11.1 were all
|
||||
tagged and then published nothing, so any ask naming them was wrong before it
|
||||
was read.
|
||||
|
||||
### Validate before you honour — the one way this fails unsafely
|
||||
|
||||
A claim is honoured **only if its keys equal your own first-hand readings**:
|
||||
|
||||
- release claim: `release_tag` **and** `source_revision`
|
||||
- image claim: `release_tag`, `arch` **and the arch digest you measured**
|
||||
|
||||
A mismatch means **absent**: run the tier. Without this check, a single
|
||||
mis-keyed or stale claim silently switches off real verification across the
|
||||
whole fleet, and the failure is invisible because every device still reports
|
||||
PASS. A `firstboot.release.disputed` / `firstboot.image.disputed` event newer
|
||||
than the newest claim on that key **voids** it — run the tier yourself.
|
||||
|
||||
Duplicate claims are harmless and expected: two devices booting the same release
|
||||
before replication catches up both run full scope and both claim. The gate wants
|
||||
**at least one** valid claim, not exactly one. There is deliberately no
|
||||
reservation protocol, because a reservation can strand a tier if its holder dies
|
||||
mid-round.
|
||||
|
||||
### The digest is the per-arch one
|
||||
|
||||
Your image key is the **per-architecture registry manifest digest**. On OrbStack
|
||||
`docker image inspect` reports `Id` == `RepoDigests` == the multi-arch **index**
|
||||
digest, while the `com.docker.compose.image` label carries the arch digest.
|
||||
Comparing an index digest with an arch digest is a category error, not a
|
||||
mismatch. An arm64 pass licenses **nothing** on amd64 — different bytes.
|
||||
|
||||
## The three tiers
|
||||
|
||||
**Tier R — release-level, architecture-independent.** The seven component refs
|
||||
are resolved once per build and handed to every variant job, and the harness
|
||||
lives in the repo rather than the image: component refs, CI run health,
|
||||
`Dockerfile.variant` harness wiring, the directives truth table, host buildx,
|
||||
changelog-vs-repo. Key: `release_tag` + `source_revision`.
|
||||
|
||||
**Tier I — image-level, per architecture.** Different bytes per arch: the obsmem
|
||||
turn cap (two-sided, in both `src` and `dist` forms), dormancy-bridge
|
||||
byte-identity against the manifest commit, base tool versions as installed, the
|
||||
component `/opt` HEADs. Key: `release_tag` + `arch` + arch digest.
|
||||
|
||||
**Tier D — device-level, always, never inheritable.** Recreate timeline,
|
||||
is-the-release-really-here, your own digest reading, the sanity script on every
|
||||
invocation you report, ControlPath, volume survival, palace wiring and
|
||||
extensions, `.env` and credential probes, KG and fleet triples, chain
|
||||
bookkeeping — and the **config-coupled runtime checks**.
|
||||
|
||||
That last group is *why* D can never be inherited. The same image reads
|
||||
differently per device and **both readings are correct**:
|
||||
|
||||
- `showSidebarOnStartup` is FALSE on some devices and TRUE on others, so an
|
||||
absent startup sidebar is a defect on one device and by-design on another.
|
||||
- Sanity warn counts differ by device **and by invocation** — bare vs
|
||||
`--expected-image-version` gives 2 vs 1 on the same image in the same minute.
|
||||
**A warn count is not a device property unless the invocation is pinned.**
|
||||
Record the invocation next to every number.
|
||||
- ControlPath warnings track the *host's* ssh config, not any image defect.
|
||||
|
||||
Carrying a peer's number across has already manufactured false defects here.
|
||||
|
||||
## Self-enrollment: if your mailbox is empty
|
||||
|
||||
The mempalace skill tells you to check your mailbox and that an empty result is
|
||||
a fine answer. For this round, empty has two very different causes:
|
||||
|
||||
1. **You have a chain and its ask is dormant** — correct, nothing is owed.
|
||||
2. **You have no chain at all** — you are a new device. Nothing is owed and
|
||||
nothing ever will be, because asks are planted per device *by that device*.
|
||||
|
||||
In case 2, **plant your own first ask** rather than waiting for a peer to notice
|
||||
you joined. Historically a peer did plant it cross-device (one device's ask was
|
||||
authored by another), which is exactly the roster dependency the state design
|
||||
removes. Query your own chain before concluding:
|
||||
|
||||
```
|
||||
mempalace_event_list(correlation_id="firstboot-acceptance-<device>", limit=5)
|
||||
```
|
||||
|
||||
## Chain invariants — the ways this chain dies quietly
|
||||
|
||||
- **Reply terminal FIRST, then plant the successor.** `isAnswered` joins on
|
||||
`correlation_id` with a strictly-after comparison, so planting first silently
|
||||
retires your own successor and the chain dies with no error at the next
|
||||
recreate.
|
||||
- **Dormancy baselines must be ISO-with-Z**, and verified against the *real*
|
||||
`evaluateDormancy` from the booted bridge before you append. UTC digits
|
||||
without the `Z` parse 7200 s low on a CEST host; an epoch (string or number)
|
||||
is `NaN` and makes the condition unevaluable, so the ask nags forever while
|
||||
looking like a broken feature. Import the function; do not regex a copy of it.
|
||||
- **A cannot-run is never a pass.** Gates here deliberately separate rc=1
|
||||
(violation) from rc=2 (cannot run). An empty file is a violation; an absent
|
||||
path is a cannot-run. Scoring a cannot-run as a pass is how a gate that never
|
||||
ran reports green.
|
||||
- **Only the original requester can withdraw an ask.** `isWithdrawn` requires
|
||||
the terminal event to be addressed to the exact mailbox, so you cannot retire
|
||||
another device's ask and no broadcast can empty every mailbox at once.
|
||||
- **Claims are `status=ready`.** Not `open` — the owed-work query selects
|
||||
`status=open` and a `to_agent="*"` broadcast matches every mailbox, so an open
|
||||
claim would appear as an owed ask on every device in the fleet. Not terminal
|
||||
either, because terminal events join `isAnswered` and could clear real work.
|
||||
|
||||
## A command gate on the device can fail your probes
|
||||
|
||||
A device may run a bash gate that the image does not ship. Today that is
|
||||
`erdc-guard`, a fleet extension mounted from docker-compose-repo at
|
||||
`/opt/fleet-pi`. It scores every bash statement with an ERDC classifier and
|
||||
asks before running anything at or above its threshold (0.8 by default). In
|
||||
the TUI it waits 120 s, then blocks; under `pi -p` it blocks at once.
|
||||
|
||||
A probe it stops failed for a reason that has nothing to do with the image.
|
||||
**That is a cannot-run: never a pass, and never a defect either.** Read
|
||||
`/guard status` before you probe, so you know whether the gate is on.
|
||||
|
||||
The gate judges the **text** of a command, not what will actually run.
|
||||
Measured on tor-ms22 on 2026-10-24 with the guard's own `assess()`:
|
||||
|
||||
| probe shape | flagged |
|
||||
|---|---|
|
||||
| `ERDC_GUARD=off pi -p "… rm -rf data …"` | yes, 0.95 |
|
||||
| the same prompt, as `pi -p @/tmp/…/prompt.md` | no |
|
||||
| `cat >> f <<'EOF'` with test lines holding `bash("rm -rf build/")` | yes, 0.93 |
|
||||
| `cat > f <<'EOF'` with one `"rm -rf build/"` string | no |
|
||||
| `rm -rf /tmp/<scratch>` | no (skipped as scratch) |
|
||||
| `git rebase origin/main` | yes, 0.89 |
|
||||
|
||||
Try these in order:
|
||||
|
||||
1. **Write fixture files and prompt files with the `write` tool**, which the
|
||||
gate does not watch. Whether a heredoc is flagged depends on its contents,
|
||||
so you cannot predict it. Do not route file content through bash.
|
||||
2. **Give a child pi its prompt as `@file`.** `ERDC_GUARD=off` in the child's
|
||||
environment switches off only the *child's* gate. The parent has already
|
||||
judged the prompt text and blocked it.
|
||||
3. **Keep scratch under `/tmp`.** The gate skips removals confined to `/tmp`
|
||||
without sending a request.
|
||||
4. **Use `/guard off` only as a last resort.** It is not scoped to your round:
|
||||
- The setting lives in `~/.pi/agent/erdc-guard.json` and is re-read before
|
||||
every bash call. Switching it off turns off the gate in every pi session on
|
||||
the volume at once, and it stays off if your round dies.
|
||||
- While off, erdc-guard keeps its bash-gate claim, so confirm-destructive
|
||||
stays stood down too. The result is no gate at all.
|
||||
- It also changes the Tier D reading of `/guard status`.
|
||||
|
||||
If you must use it: take that reading first, switch the gate off, switch it
|
||||
back on afterwards, and record both switches in the evidence.
|
||||
|
||||
## When you copy a probe forward, grep it for your own recorded landmines
|
||||
|
||||
Not just stale paths and the previous run's parameters. This fleet has a
|
||||
documented instance of an agent re-importing a probe carrying the exact
|
||||
`set -u` bug that its *own* retraction list named as having already killed that
|
||||
probe. **Reading a retraction is not the same as applying it to the artefact the
|
||||
retraction is about.**
|
||||
|
||||
## Limits of this file
|
||||
|
||||
- It tells you the procedure exists and where the real one lives. It cannot be
|
||||
the procedure, because it ships inside the image it would be verifying — if
|
||||
this file were wrong you would validate a bad image with its own instructions.
|
||||
- It arrives only with a release. A device booting an older image gets an older
|
||||
copy of this finder, which is the main reason it carries the *lookup* rather
|
||||
than the policy: the lookup is stable across policy revisions.
|
||||
- Nothing here is automatic. A session must load this skill (the pi-devbox
|
||||
managed block in the global `AGENTS.md` points at it), and the device must
|
||||
then act on it.
|
||||
@@ -23,11 +23,13 @@ Pi has **two distinct extension locations** and it's easy to look in the wrong o
|
||||
|
||||
| Location | Mechanism | Examples |
|
||||
|---|---|---|
|
||||
| `~/.pi/agent/extensions/*.ts` (or `.ts.off`) | **Local extensions** — TypeScript files, usually symlinks into `/opt/pi-extensions/extensions/` or similar. Toggled via `/ext` slash command. | `ssh-controlmaster`, `git-checkpoint`, `notify`, `todo`, `mempalace`, `mcp-loader`, `ext-toggle`, `confirm-destructive` |
|
||||
| `~/.pi/agent/extensions/*.ts` (or `.ts.off`) | **Local extensions** — TypeScript files, usually symlinks into `/opt/pi-extensions/extensions/` or similar. Toggled via `/ext` slash command. | `ssh-controlmaster`, `git-checkpoint`, `notify`, `todo`, `mempalace`, `ext-toggle`, `task`, `fork-gate`, `secret-guard`, `aws-sso`, `confirm-destructive` |
|
||||
| `~/.pi/agent/git/<host>/<owner>/<repo>/` | **Package extensions (git-installed)** — git-cloned npm packages registered via the `packages` array in `~/.pi/agent/settings.json`. | `pi-fork` (`github.com/elpapi42/pi-fork`), `pi-observational-memory` (`github.com/elpapi42/pi-observational-memory`, **default branch `master`** — a `main` branch does not exist, so `pi install git:...` resolves against `master`) |
|
||||
| `~/.pi/agent/npm/node_modules/<pkg>/` | **Package extensions (npm-installed)** — `pi install npm:<pkg>`; recorded in `packages[]` as `npm:<pkg>`. | `pi-atelier` (status rail + sidebar TUI) |
|
||||
| `/opt/<pkg>/` — **pi-devbox containers only** | **Vendored package extensions** — cloned into an image layer at build time with `node_modules` baked, then registered at container start by `entrypoint-user.sh` via `pi install /opt/<pkg>`. Recorded in `packages[]` as a **relative** path (`../../../../opt/pi-fork`) that resolves out of `~/.pi/agent` into the image layer, so it survives volume recreate. | `/opt/pi-fork`, `/opt/pi-observational-memory`, `/opt/pi-studio` |
|
||||
|
||||
**MCP servers are not an extension here.** pi's built-in MCP support (`builtin:mcp`) reads `~/.pi/agent/mcp.json`; manage it with `/mcp` or `pi mcp add|list`. Its tools are named `mcp__<server>__<tool>` and default to `codemode` exposure (not declared to the model) unless the server sets `"exposure": "direct"`. The `mempalace_*` tools are different: `mempalace.ts` registers them itself, so exposure never applies. The old `mcp-loader.ts` (an `mcp` block in `settings.json`) was retired on 2026-10-12, because an extension that registers `/mcp` replaces the built-in.
|
||||
|
||||
When the user asks how to use "the X extension", **check all of these** — `find ~/.pi/agent -maxdepth 4 -name "*X*"` covers the first three, and `ls -d /opt/*X*` the fourth. The `/ext` slash command shows the local-extensions list with enable/disable state. There is also a distinct skill-bundled-script category (e.g. `ci-release-watcher`'s `ssh-control-master-setup.sh`) which is **not** a pi extension at all — it's a helper script inside a skill. Don't conflate the three.
|
||||
|
||||
**In a pi-devbox container, do not conclude "pi-fork isn't installed" because `~/.pi/agent/git/` is empty.** It is deliberately absent: `Dockerfile.variant` vendors to `/opt` and installs by local path, because a build-time `pi install git:...` would write into `~/.pi/agent`, which the named volume then shadows on first run.
|
||||
|
||||
@@ -181,8 +181,14 @@ check_pin() {
|
||||
PI_ACTUAL="$(read_arg "$DF_VARIANT" PI_VERSION)"
|
||||
ATELIER_ACTUAL="$(read_arg "$DF_VARIANT" PI_ATELIER_REF)"
|
||||
MEMPALACE_ACTUAL="$(read_arg "$DF_BASE" MEMPALACE_VERSION)"
|
||||
# pi-obsmem became a PIN in v1.9.5 (was the floating `master`), so it joins the
|
||||
# reviewable table. It is also covered by the ref-move check below, but that one
|
||||
# can only ever report "unchanged" for a pinned SHA -- it answers "did upstream
|
||||
# move?", never "does the table still say what we bake?", which is this check.
|
||||
OBSMEM_PIN_ACTUAL="$(read_arg "$DF_VARIANT" PI_OBSMEM_REF)"
|
||||
|
||||
check_pin pi "$(read_pin_row pi)" "$PI_ACTUAL" "ARG PI_VERSION in $DF_VARIANT"
|
||||
check_pin pi-obsmem "$(read_pin_row pi-obsmem)" "$OBSMEM_PIN_ACTUAL" "ARG PI_OBSMEM_REF in $DF_VARIANT"
|
||||
check_pin pi-atelier "$(read_pin_row pi-atelier)" "$ATELIER_ACTUAL" "ARG PI_ATELIER_REF in $DF_VARIANT"
|
||||
check_pin mempalace "$(read_pin_row mempalace)" "$MEMPALACE_ACTUAL" "ARG MEMPALACE_VERSION in $DF_BASE"
|
||||
|
||||
|
||||
Executable
+69
@@ -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."
|
||||
Executable
+189
@@ -0,0 +1,189 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-skill-frontmatter.sh — fail when an image-baked skill's frontmatter
|
||||
# violates the Agent Skills spec that pi's own loader measures against.
|
||||
#
|
||||
# THE DEFECT THIS EXISTS TO CATCH, measured 2026-10-23 while adding the
|
||||
# pi-devbox-firstboot skill. Its `description` was drafted at 1056 characters.
|
||||
# pi's shipped loader (dist/core/skills.js) defines MAX_DESCRIPTION_LENGTH =
|
||||
# 1024 and pushes a diagnostic when a description is longer — and then says, in
|
||||
# a comment directly above the return:
|
||||
#
|
||||
# // Still load the skill even with warnings, unless description is
|
||||
# // missing or empty.
|
||||
#
|
||||
# So an over-long description does NOT break the skill. That is exactly why it
|
||||
# needs a gate rather than trust: the skill works, the violation is real, and
|
||||
# the only thing that would ever tell you is a diagnostic emitted inside a pi
|
||||
# session. Nothing in CI starts a pi session and reads skill diagnostics, so the
|
||||
# warning is invisible here by construction. A spec violation that still works
|
||||
# is the most durable kind of rot — nothing ever forces the fix.
|
||||
#
|
||||
# One failure in this family is NOT a warning but a silent drop: a missing or
|
||||
# empty description returns `{ skill: null }`, so the skill is never offered to
|
||||
# the model at all. The skill file ships, `test -f` passes in smoke-test.sh, the
|
||||
# symlink resolves, `pi-devbox-version` lists it — and the skill does not exist
|
||||
# as far as any agent is concerned. This gate separates the two cases in its
|
||||
# output, because they need different urgency even though both are red.
|
||||
#
|
||||
# WHY THE BAKED TREE SPECIFICALLY. These skills are the FALLBACK layer (see
|
||||
# skills/VENDORED.md): they are what a container resolves with no skillset repo
|
||||
# mounted, which is also the case of a brand-new device that has nothing else.
|
||||
# A skillset-owned skill has its own upstream review; a baked one has only this.
|
||||
#
|
||||
# MEASURED HEADROOM when this gate was added, which is why it is worth having:
|
||||
# credential-incident-response 1022 (2 chars spare)
|
||||
# pi-extensions 1003 (21)
|
||||
# pi-devbox-firstboot 952 (72)
|
||||
# pi-devbox-environment 923 (101)
|
||||
# mempalace 254 (770)
|
||||
# Two of five are within one sentence of the limit, so "nobody would write a
|
||||
# description that long" is already false here.
|
||||
#
|
||||
# A KNOWN COUPLING, stated rather than discovered later: `pi-extensions` and
|
||||
# `mempalace` are VENDORED copies, so an upstream edit that pushes one over the
|
||||
# limit turns this gate red on a file this repo does not author. That is
|
||||
# deliberate and precedented — check-skill-floor.sh already couples to the
|
||||
# pi-extensions package — because the image SHIPS those bytes, so the violation
|
||||
# is ours to carry regardless of who typed it. The fix in that case is to
|
||||
# refresh the snapshot after upstream trims, not to exempt the file.
|
||||
#
|
||||
# WHY NAME IS CHECKED TOO, including against the directory. The loader falls
|
||||
# back to the PARENT DIRECTORY NAME when frontmatter has no usable `name`
|
||||
# (`frontmatterName || parentDirName`), so a mismatch does not fail — it
|
||||
# silently renames the skill, and every pointer written as
|
||||
# `~/.agents/skills/<dir>/SKILL.md` then refers to a skill the model knows under
|
||||
# a different name. The managed AGENTS.md block points at skills by path, so
|
||||
# that divergence is load-bearing here.
|
||||
#
|
||||
# EXIT CODES (same contract as lint-shell.sh / check-skill-floor.sh /
|
||||
# check-doc-drift.sh — a gate that cannot run must not pass):
|
||||
# 0 every baked skill's frontmatter is valid
|
||||
# 1 VIOLATION — at least one skill breaks the spec
|
||||
# 2 cannot run — no skills directory, or no python3 with a YAML module
|
||||
#
|
||||
# WHY PYTHON AND NOT grep/awk. `description: >-` is a YAML folded block scalar:
|
||||
# its value spans lines and is joined with spaces, so a line-oriented length
|
||||
# check measures the wrong string. python3-yaml is already installed in the
|
||||
# actionlint job and already used by check-doc-drift.sh, so this adds no new
|
||||
# dependency. NB the heredoc form below is `python3 - "$arg"` (script on stdin,
|
||||
# argument on the command line); `echo x | python3 <<'EOF'` cannot work, which
|
||||
# is the SC2259 defect the shellcheck gate was added for.
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
||||
SKILLS_DIR="${REPO_ROOT}/rootfs/usr/local/share/pi-devbox/skills"
|
||||
|
||||
usage() {
|
||||
cat <<'USAGE'
|
||||
Usage: scripts/check-skill-frontmatter.sh [options]
|
||||
|
||||
Options:
|
||||
--skills-dir DIR Validate DIR instead of the repo's baked skills tree
|
||||
(used by the truth-table self-test).
|
||||
-h, --help This text.
|
||||
|
||||
Exit: 0 valid | 1 violation | 2 cannot run
|
||||
USAGE
|
||||
}
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--skills-dir) shift; [ $# -gt 0 ] || { echo "::error::--skills-dir needs a value" >&2; exit 2; }; SKILLS_DIR="$1" ;;
|
||||
-h|--help) usage; exit 0 ;;
|
||||
*) echo "::error::unknown option: $1" >&2; usage >&2; exit 2 ;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
if [ ! -d "$SKILLS_DIR" ]; then
|
||||
echo "::error::skills directory not found: ${SKILLS_DIR}"
|
||||
echo "A gate that cannot run must not pass — this is a red 2, not a green tick."
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! command -v python3 >/dev/null 2>&1; then
|
||||
echo "::error::python3 not found; cannot parse YAML frontmatter"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
if ! python3 -c 'import yaml' >/dev/null 2>&1; then
|
||||
echo "::error::python3 has no yaml module (apt: python3-yaml); cannot parse frontmatter"
|
||||
exit 2
|
||||
fi
|
||||
|
||||
python3 - "$SKILLS_DIR" <<'PY'
|
||||
import os, re, sys
|
||||
import yaml
|
||||
|
||||
# Mirrors pi's dist/core/skills.js. If pi changes these, change them here.
|
||||
MAX_NAME_LENGTH = 64
|
||||
MAX_DESCRIPTION_LENGTH = 1024
|
||||
|
||||
skills_dir = sys.argv[1]
|
||||
fatal, violations, checked = [], [], 0
|
||||
|
||||
for name in sorted(os.listdir(skills_dir)):
|
||||
d = os.path.join(skills_dir, name)
|
||||
if not os.path.isdir(d):
|
||||
continue
|
||||
path = os.path.join(d, "SKILL.md")
|
||||
rel = os.path.relpath(path, skills_dir)
|
||||
|
||||
if not os.path.isfile(path):
|
||||
fatal.append(f"{rel}: directory has no SKILL.md — the loader cannot see it")
|
||||
continue
|
||||
checked += 1
|
||||
|
||||
raw = open(path, encoding="utf-8").read()
|
||||
m = re.match(r"^---\n(.*?)\n---\n", raw, re.S)
|
||||
if not m:
|
||||
fatal.append(f"{rel}: no YAML frontmatter block (must open on line 1 with ---)")
|
||||
continue
|
||||
try:
|
||||
fm = yaml.safe_load(m.group(1)) or {}
|
||||
except Exception as e:
|
||||
fatal.append(f"{rel}: frontmatter is not valid YAML: {e}")
|
||||
continue
|
||||
if not isinstance(fm, dict):
|
||||
fatal.append(f"{rel}: frontmatter is not a mapping")
|
||||
continue
|
||||
|
||||
desc = fm.get("description")
|
||||
if not isinstance(desc, str) or desc.strip() == "":
|
||||
# The one hard drop: the loader returns skill: null for this.
|
||||
fatal.append(f"{rel}: description missing or empty — THE SKILL IS NEVER LOADED")
|
||||
elif len(desc) > MAX_DESCRIPTION_LENGTH:
|
||||
violations.append(
|
||||
f"{rel}: description is {len(desc)} chars, exceeds {MAX_DESCRIPTION_LENGTH} "
|
||||
f"(loader warns and still loads, so nothing else will tell you)"
|
||||
)
|
||||
|
||||
fm_name = fm.get("name") if isinstance(fm.get("name"), str) else None
|
||||
eff = fm_name or name
|
||||
if len(eff) > MAX_NAME_LENGTH:
|
||||
violations.append(f"{rel}: name is {len(eff)} chars, exceeds {MAX_NAME_LENGTH}")
|
||||
if not re.fullmatch(r"[a-z0-9-]+", eff):
|
||||
violations.append(f"{rel}: name {eff!r} must be lowercase a-z, 0-9 and hyphens only")
|
||||
if eff.startswith("-") or eff.endswith("-"):
|
||||
violations.append(f"{rel}: name {eff!r} must not start or end with a hyphen")
|
||||
if "--" in eff:
|
||||
violations.append(f"{rel}: name {eff!r} must not contain a double hyphen")
|
||||
if fm_name is not None and fm_name != name:
|
||||
violations.append(
|
||||
f"{rel}: frontmatter name {fm_name!r} != directory {name!r} — the loader "
|
||||
f"would use {fm_name!r} while every path-based pointer says {name!r}"
|
||||
)
|
||||
|
||||
print(f"checked {checked} baked skill(s) in {skills_dir}")
|
||||
|
||||
if not fatal and not violations:
|
||||
print("OK: every baked skill's frontmatter is valid.")
|
||||
sys.exit(0)
|
||||
|
||||
for f in fatal:
|
||||
print(f"::error::{f}")
|
||||
for v in violations:
|
||||
print(f"::error::{v}")
|
||||
print(f"FAIL: {len(fatal)} fatal, {len(violations)} spec violation(s).")
|
||||
sys.exit(1)
|
||||
PY
|
||||
Executable
+141
@@ -0,0 +1,141 @@
|
||||
#!/usr/bin/env bash
|
||||
# ci-reclaim-docker.sh — free disk on the CI runner's Docker daemon WITHOUT
|
||||
# deleting a smoke image that another job is still testing.
|
||||
#
|
||||
# Used by every docker-publish.yml job that reclaims disk (build-base, smoke,
|
||||
# smoke-studio, build-variant, build-variant-studio). Not shipped in the image.
|
||||
#
|
||||
# bash scripts/ci-reclaim-docker.sh # reclaim (default)
|
||||
# bash scripts/ci-reclaim-docker.sh assert-protected IMG # smoke preflight
|
||||
# bash scripts/ci-reclaim-docker.sh hold IMG # pin IMG while testing
|
||||
# bash scripts/ci-reclaim-docker.sh release IMG # drop the pin + IMG
|
||||
# bash scripts/ci-reclaim-docker.sh lint WORKFLOW... # lint.yml guard
|
||||
#
|
||||
# WHY THIS EXISTS. Everything that runs on one runner host shares its Docker
|
||||
# daemon: concurrent jobs of a runner with capacity > 1 (runner-a1 had
|
||||
# smoke-studio and build-variant going together in run 740), and the jobs of
|
||||
# other act_runner containers on the same VM (runner-2 has one registered with
|
||||
# gitea.jordbo.se and one with gitea.egl.lan). Each pi-devbox job used to start
|
||||
# with `docker system prune -af --volumes`. That removes every image no
|
||||
# container is using at that instant, and a smoke image is
|
||||
# exactly that for most of its life: smoke-test.sh runs one `docker run --rm`
|
||||
# per assertion, so between assertions nothing holds the image. v1.12.2 run 740
|
||||
# (2026-10-22): smoke passed, build-variant started and pruned, and its log
|
||||
# shows `untagged: pi-devbox:smoke-studio` at 22:58:49.11Z, 30 ms after
|
||||
# smoke-studio had imported that image (22:58:49.08Z). Every smoke-studio
|
||||
# assertion then failed with "Unable to find image 'pi-devbox:smoke-studio'
|
||||
# locally", build-variant-studio was skipped, and the release went out without
|
||||
# its -studio tags. Job ORDER cannot close this: smoke and smoke-studio prune
|
||||
# each other too whenever their start times drift apart, and other runs share
|
||||
# the daemon. Two defences, because we only control our own jobs:
|
||||
# - the smoke images carry a label, and this script never prunes an image
|
||||
# that has it (covers pi-devbox's own jobs, before and during the test);
|
||||
# - `hold` starts a container from the image for the whole smoke step. No
|
||||
# prune removes an image a running container uses, so this also covers
|
||||
# jobs that never call this script (opencode-devbox's raw prunes,
|
||||
# anything gitea.egl.lan runs). What is left is the few seconds between
|
||||
# the build step loading the image and `hold` starting.
|
||||
#
|
||||
# WHAT IT DOES. The same reclaim as `docker system prune -af --volumes` +
|
||||
# `docker builder prune -af`, done per object type so the image step can take
|
||||
# a filter:
|
||||
# 1. stopped containers (running ones are never touched)
|
||||
# 2. unused images WITHOUT the label (the old `system prune -a` image step)
|
||||
# 3. unused images WITH the label, older than SMOKE_IMAGE_MAX_AGE
|
||||
# — orphans of a smoke job that died before its own cleanup step
|
||||
# 4. unused networks, anonymous volumes (what `system prune --volumes` did)
|
||||
# 5. the build cache
|
||||
# Each step is `|| true`, as before: failing to reclaim disk must not fail the
|
||||
# job. A filter the daemon rejected would show up as an error line in the log.
|
||||
#
|
||||
# `assert-protected IMG` is the smoke jobs' preflight: it fails unless IMG is in
|
||||
# the daemon AND carries the label. Without it, a build that silently lost the
|
||||
# label would look fine until the next time a sibling job's prune landed
|
||||
# mid-test; and a missing image gets one clear error line instead of every
|
||||
# assertion failing with "Unable to find image ... locally".
|
||||
#
|
||||
# `lint WORKFLOW...` fails if any workflow runs `docker system prune` or
|
||||
# `docker image prune` itself. One such line in any job reopens the race, so
|
||||
# lint.yml runs this on every push rather than relying on review.
|
||||
#
|
||||
# Environment:
|
||||
# SMOKE_IMAGE_LABEL label key that protects an image (default below; the
|
||||
# workflow sets it once in its top-level env)
|
||||
# SMOKE_IMAGE_MAX_AGE `until=` value for the orphan sweep (default 6h; a
|
||||
# smoke job takes ~10 min). Compared against the image's
|
||||
# Created time, which is the build time: nothing here
|
||||
# sets SOURCE_DATE_EPOCH.
|
||||
set -u
|
||||
|
||||
SMOKE_IMAGE_LABEL="${SMOKE_IMAGE_LABEL:-se.jordbo.pi-devbox.ci-smoke-image}"
|
||||
SMOKE_IMAGE_MAX_AGE="${SMOKE_IMAGE_MAX_AGE:-6h}"
|
||||
|
||||
step() { printf '+ %s\n' "$*"; "$@" || true; }
|
||||
|
||||
reclaim() {
|
||||
step docker container prune -f
|
||||
step docker image prune -af --filter "label!=${SMOKE_IMAGE_LABEL}"
|
||||
step docker image prune -af --filter "label=${SMOKE_IMAGE_LABEL}" \
|
||||
--filter "until=${SMOKE_IMAGE_MAX_AGE}"
|
||||
step docker network prune -f
|
||||
step docker volume prune -f
|
||||
step docker builder prune -af
|
||||
}
|
||||
|
||||
assert_protected() {
|
||||
local img="${1:?usage: $0 assert-protected <image>}" val
|
||||
if ! val=$(docker image inspect \
|
||||
--format "{{ index .Config.Labels \"${SMOKE_IMAGE_LABEL}\" }}" "$img" 2>&1); then
|
||||
echo "::error::${img} is not in the runner's Docker daemon (${val}). Was it removed by a concurrent prune that does not honour ${SMOKE_IMAGE_LABEL}?"
|
||||
return 1
|
||||
fi
|
||||
if [ "$val" != "true" ]; then
|
||||
echo "::error::${img} lacks the label ${SMOKE_IMAGE_LABEL}=true (got '${val}'), so a sibling job's reclaim could delete it mid-test. Check the build step's labels: input."
|
||||
return 1
|
||||
fi
|
||||
echo "OK: ${img} is present and carries ${SMOKE_IMAGE_LABEL}=true"
|
||||
}
|
||||
|
||||
holder_name() { printf 'ci-hold-%s' "$(printf '%s' "$1" | tr -c 'A-Za-z0-9_.-' '_')"; }
|
||||
|
||||
# The holder exits on its own after an hour (--rm), so a job killed before
|
||||
# `release` does not pin its image forever; the age sweep then removes it.
|
||||
hold() {
|
||||
local img="${1:?usage: $0 hold <image>}" name
|
||||
name=$(holder_name "$img")
|
||||
docker rm -f "$name" >/dev/null 2>&1 || true
|
||||
if ! docker run -d --rm --name "$name" --network none --entrypoint "" \
|
||||
"$img" sleep 3600 >/dev/null; then
|
||||
echo "::error::could not start holder ${name} for ${img}; is the image still in the daemon?"
|
||||
return 1
|
||||
fi
|
||||
echo "OK: ${img} pinned by running container ${name} (exits by itself after 1 h)"
|
||||
}
|
||||
|
||||
release() {
|
||||
local img="${1:?usage: $0 release <image>}"
|
||||
docker rm -f "$(holder_name "$img")" >/dev/null 2>&1 || true
|
||||
docker image rm "$img" >/dev/null 2>&1 || true
|
||||
echo "released ${img}"
|
||||
}
|
||||
|
||||
lint_workflows() {
|
||||
[ "$#" -gt 0 ] || { echo "usage: $0 lint <workflow.yml>..." >&2; return 2; }
|
||||
local hits
|
||||
hits=$(grep -nE '^[^#]*docker[[:space:]]+(system|image)[[:space:]]+prune' "$@" || true)
|
||||
if [ -n "$hits" ]; then
|
||||
echo "::error::raw docker prune in a workflow. It deletes smoke images other jobs are testing on the shared runner daemon; call 'bash scripts/ci-reclaim-docker.sh' instead."
|
||||
printf '%s\n' "$hits"
|
||||
return 1
|
||||
fi
|
||||
echo "OK: no raw 'docker system|image prune' in $# workflow file(s)"
|
||||
}
|
||||
|
||||
case "${1:-reclaim}" in
|
||||
reclaim) reclaim ;;
|
||||
assert-protected) shift; assert_protected "$@" ;;
|
||||
hold) shift; hold "$@" ;;
|
||||
release) shift; release "$@" ;;
|
||||
lint) shift; lint_workflows "$@" ;;
|
||||
*) echo "usage: $0 [reclaim | assert-protected <image> | hold <image> | release <image> | lint <workflow.yml>...]" >&2; exit 2 ;;
|
||||
esac
|
||||
Regular → Executable
@@ -277,6 +277,46 @@ 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
|
||||
|
||||
# shared fleet extensions (link-pi-extensions.sh) — same shape as models.json
|
||||
# above: checked only when the fleet directory actually holds *.ts, so a device
|
||||
# without a shared directory is not judged by it.
|
||||
_le_src="${PI_EXTENSIONS_SRC-/opt/fleet-pi}"
|
||||
case "$_le_src" in "" | off | OFF | Off) _le_src="" ;; esac
|
||||
if [ -n "$_le_src" ] && ls -1 "$_le_src"/*.ts >/dev/null 2>&1; then
|
||||
for _le_f in "$_le_src"/*.ts; do
|
||||
_le_name="$(basename "$_le_f")"; _le_dest="$HOME/.pi/agent/extensions/$_le_name"
|
||||
if [ -L "$_le_dest" ] && [ "$(readlink "$_le_dest")" = "$_le_f" ]; then
|
||||
pass "~/.pi/agent/extensions/$_le_name -> $_le_f (shared fleet extension)"
|
||||
elif [ -L "$_le_dest" ]; then
|
||||
warn "~/.pi/agent/extensions/$_le_name links to $(readlink "$_le_dest"), not the mounted $_le_f"
|
||||
elif [ -e "$_le_dest" ]; then
|
||||
warn "~/.pi/agent/extensions/$_le_name is a real file, so the mounted $_le_f is NOT in use"
|
||||
else
|
||||
fail "$_le_src holds $_le_name but ~/.pi/agent/extensions/$_le_name is missing (image without link-pi-extensions.sh? run: ln -s $_le_f ~/.pi/agent/extensions/)"
|
||||
fi
|
||||
done
|
||||
unset _le_f _le_name _le_dest
|
||||
fi
|
||||
unset _le_src
|
||||
|
||||
# extensions deployed (pi-extensions) — expect ≥4 *.ts
|
||||
EXT_COUNT=$(ls -1 "$HOME"/.pi/agent/extensions/*.ts 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$EXT_COUNT" -ge 4 ]; then
|
||||
@@ -285,6 +325,24 @@ else
|
||||
fail "only $EXT_COUNT extensions deployed (expected ≥4)"
|
||||
fi
|
||||
|
||||
# no dangling extension links. The count above includes them (ls lists a link
|
||||
# whether or not its target exists) but they load nothing, and pi skips them
|
||||
# without a word. That is exactly how a retired extension outlives its image on
|
||||
# a preserved volume: mcp-loader.ts (retired after v1.11.0) left
|
||||
# ~/.pi/agent/extensions/mcp-loader.ts -> /opt/pi-extensions/... behind. The
|
||||
# pi-extensions install.sh run by the entrypoint now prunes links into ITS repo;
|
||||
# anything this still finds points somewhere else, hence WARN, not FAIL.
|
||||
_dangling=""
|
||||
for _e in "$HOME"/.pi/agent/extensions/*.ts "$HOME"/.pi/agent/extensions/*.ts.off; do
|
||||
if [ -L "$_e" ] && [ ! -e "$_e" ]; then _dangling="$_dangling $(basename "$_e")"; fi
|
||||
done
|
||||
if [ -z "$_dangling" ]; then
|
||||
pass "no dangling extension links in ~/.pi/agent/extensions"
|
||||
else
|
||||
warn "dangling extension links (target gone, pi ignores them):$_dangling"
|
||||
fi
|
||||
unset _dangling _e
|
||||
|
||||
# mempalace.ts bridge symlink
|
||||
if [ -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
|
||||
pass "~/.pi/agent/extensions/mempalace.ts bridge symlink"
|
||||
@@ -502,13 +560,33 @@ _ssh_cm_probe() {
|
||||
elif [ "${#bad[@]}" -eq 0 ]; then
|
||||
pass "$label: $n ControlMaster host(s), every ControlPath dir exists and is writable"
|
||||
elif [ "$sev" = "warn" ]; then
|
||||
warn "$label: ${#bad[@]}/$n host(s) resolve ControlPath to a missing or unwritable dir [$shown] — expected when ~/.ssh/config pins ControlPath inside the read-only ~/.ssh; use 'ssh -F ~/.ssh-local/config' (see Dockerfile.base CAVEAT)"
|
||||
warn "$label: ${#bad[@]}/$n host(s) resolve ControlPath to a missing or unwritable dir [$shown] — STRUCTURAL and permanent while ~/.ssh/config pins ControlPath inside the read-only ~/.ssh, so this line can never reach zero and is not a to-do; $_git_ssh_note (see Dockerfile.base CAVEAT)"
|
||||
else
|
||||
fail "$label: ${#bad[@]}/$n host(s) resolve ControlPath to a missing or unwritable dir [$shown] — ssh dies rc=255 'unix_listener: cannot bind to path' and the remote command never runs"
|
||||
fi
|
||||
}
|
||||
|
||||
if command -v ssh >/dev/null 2>&1; then
|
||||
# Whether git-over-ssh already routes through the sidecar decides how much the
|
||||
# permanent default-route warning below actually matters, so state it IN that
|
||||
# message rather than leaving each reader to work it out. Asserted properly as
|
||||
# its own pass/fail in the next section.
|
||||
#
|
||||
# THREE states, not two, and the [ -r ] test is why: core.sshCommand NAMING the
|
||||
# sidecar does not mean the sidecar EXISTS. Without that test, the arm where the
|
||||
# path is wired but the file is gone printed "git IS wired ... unaffected" about
|
||||
# a state in which every single git-over-ssh call fails. Found by exercising all
|
||||
# five arms of this check rather than only the healthy one.
|
||||
if [ ! -r "$HOME/.ssh-local/config" ]; then
|
||||
_git_ssh_note="there is no ssh sidecar on this host, so nothing for -F to point at and these hosts cannot multiplex at all — see the git-over-ssh check below"
|
||||
elif command -v git >/dev/null 2>&1 &&
|
||||
git config --global --get core.sshCommand 2>/dev/null |
|
||||
grep -qF -- "-F $HOME/.ssh-local/config"; then
|
||||
_git_ssh_note="git IS wired to the sidecar (core.sshCommand), so git push/fetch is unaffected; bare 'ssh' to these hosts still needs 'ssh -F ~/.ssh-local/config'"
|
||||
else
|
||||
_git_ssh_note="git is NOT wired to the sidecar (see the git-over-ssh check below), so both git and bare 'ssh' need 'ssh -F ~/.ssh-local/config'"
|
||||
fi
|
||||
|
||||
# Concrete Host aliases only: patterns (*, ?) and negations (!) are not
|
||||
# connectable targets, so `ssh -G` on them proves nothing.
|
||||
_cm_cfgs=()
|
||||
@@ -535,6 +613,46 @@ else
|
||||
warn "ssh not on PATH — effective ControlPath not verified"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- git-over-ssh routed through the writable ssh sidecar --"
|
||||
# The one question in this area that is BINARY, FIXABLE, and therefore worth a
|
||||
# check that can reach zero and stay there.
|
||||
#
|
||||
# The ControlPath warning above cannot: while a bind-mounted ~/.ssh/config pins
|
||||
# ControlPath inside the read-only ~/.ssh, the default route will ALWAYS resolve
|
||||
# to an unwritable dir, so that line fires benignly on every run of every device
|
||||
# forever — and the script's own comment above says why that is dangerous: it is
|
||||
# a warning you learn to skip. MEASURED 2026-09-22, exactly that: an agent ran
|
||||
# this script, recorded "two by-design warnings", then two hours later hit
|
||||
# `unix_listener: cannot bind ... Read-only file system` on `git push`, failed to
|
||||
# connect it to the warning it had already read, and reinvented a /tmp/sshcm
|
||||
# workaround — while the remedy string sat inside the dismissed warning.
|
||||
# entrypoint-user.sh now wires core.sshCommand to the sidecar so nobody has to
|
||||
# know; this section asserts the wiring actually happened, which is the part a
|
||||
# reader can act on.
|
||||
if ! command -v git >/dev/null 2>&1; then
|
||||
warn "git not on PATH — sidecar wiring not verified"
|
||||
elif [ ! -r "$HOME/.ssh-local/config" ]; then
|
||||
# No sidecar (native Linux Docker, where setup-lan-access.sh writes none). The
|
||||
# correct state is UNSET: -F pointing at a missing file breaks every
|
||||
# git-over-ssh call, which is worse than the problem being solved.
|
||||
if [ -z "$(git config --global --get core.sshCommand 2>/dev/null || true)" ]; then
|
||||
pass "no ssh sidecar on this host and core.sshCommand correctly unset (guard holds)"
|
||||
else
|
||||
fail "core.sshCommand is set but ~/.ssh-local/config does not exist — every git-over-ssh call dies on a missing -F file; entrypoint-user.sh's [ -r ] guard did not hold"
|
||||
fi
|
||||
else
|
||||
_git_ssh_cmd=$(git config --global --get core.sshCommand 2>/dev/null || true)
|
||||
case "$_git_ssh_cmd" in
|
||||
*"-F $HOME/.ssh-local/config"*)
|
||||
pass "git core.sshCommand routes through the sidecar ($_git_ssh_cmd)" ;;
|
||||
'')
|
||||
fail "a sidecar exists but git core.sshCommand is unset — 'git push' to any host whose config pins ControlPath inside the read-only ~/.ssh dies rc=255 behind git's misleading 'correct access rights'. entrypoint-user.sh should set it; expected on images built before that wiring landed, where the fix is: git config --global core.sshCommand \"ssh -F \$HOME/.ssh-local/config\"" ;;
|
||||
*)
|
||||
warn "git core.sshCommand set to something else and left alone (first-wins, deliberate): $_git_ssh_cmd" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- Shell defaults re-seeded from /etc/skel-devbox --"
|
||||
if [ -f "$HOME/.bash_aliases" ]; then
|
||||
|
||||
+158
-10
@@ -30,7 +30,8 @@
|
||||
# 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 present iff something declares it, and compiles everywhere;
|
||||
# clipboard present iff pi declares it
|
||||
# - image size within threshold
|
||||
|
||||
set -euo pipefail
|
||||
@@ -250,6 +251,22 @@ run_expect "remote-palace-without-inbox skip is announced, not silent" \
|
||||
"MemPalace catch-up skipped"
|
||||
run "...and the skip notice names the variable that fixes it" \
|
||||
"grep -A6 'MemPalace catch-up skipped' /usr/local/bin/entrypoint-user.sh | grep -q 'MEMPALACE_PI_SSH_TARGET'"
|
||||
# git-over-ssh must be wired to the writable ssh sidecar, because ~/.ssh is
|
||||
# commonly bind-mounted READ-ONLY and a per-host `ControlPath ~/.ssh/cm/...`
|
||||
# inherited from it kills every push with `unix_listener: cannot bind ...:
|
||||
# Read-only file system` behind git's misleading "correct access rights".
|
||||
#
|
||||
# STATIC assertion, deliberately. `run` executes `docker run --entrypoint=""`, so
|
||||
# entrypoint-user.sh never runs here and `git config --global core.sshCommand` is
|
||||
# necessarily unset — asserting the VALUE in this harness would repeat the v1.8.0
|
||||
# mistake (an assertion that cannot pass, unvalidated until the next tag). What
|
||||
# IS checkable at build time is that the wiring code shipped in the image. The
|
||||
# runtime value is asserted below in the Runtime deployment section, where the
|
||||
# real entrypoint chain has run.
|
||||
run "entrypoint wires git core.sshCommand to the ssh sidecar" \
|
||||
"grep -q 'core.sshCommand \"ssh -F' /usr/local/bin/entrypoint-user.sh"
|
||||
run "...and guards it on the sidecar existing (native Linux has none)" \
|
||||
"grep -B2 'core.sshCommand \"ssh -F' /usr/local/bin/entrypoint-user.sh | grep -q '\\[ -r \"\$HOME/.ssh-local/config\" \\]'"
|
||||
# A remote mine that FAILS must not report success. MCP answers a hard tool
|
||||
# failure with HTTP 200 and the tool's own JSON escaped inside
|
||||
# result.content[].text, so the feeder's old `'\"error\"' in body` check could
|
||||
@@ -274,12 +291,30 @@ run "image-baked pi-devbox-environment skill" \
|
||||
"test -f /usr/local/share/pi-devbox/skills/pi-devbox-environment/SKILL.md"
|
||||
run "image-baked credential-incident-response skill" \
|
||||
"test -f /usr/local/share/pi-devbox/skills/credential-incident-response/SKILL.md"
|
||||
run "image-baked pi-devbox-firstboot skill" \
|
||||
"test -f /usr/local/share/pi-devbox/skills/pi-devbox-firstboot/SKILL.md"
|
||||
run "global-AGENTS append snippet present" \
|
||||
"test -f /usr/local/share/pi-devbox/pi-global-AGENTS.append.md"
|
||||
run "pi-devbox block merged into pi-global-AGENTS.md" \
|
||||
"grep -q 'pi-devbox:managed-block' /opt/pi-toolkit/pi-global-AGENTS.md"
|
||||
run "mempalace session-start pointer merged into global AGENTS.md" \
|
||||
"grep -q 'load the mempalace skill' /opt/pi-toolkit/pi-global-AGENTS.md"
|
||||
# The firstboot pointer is asserted by PATH, not prose: a brand-new device with
|
||||
# an empty mailbox reaches the acceptance round only through this line, and
|
||||
# rewording the paragraph must not be able to silently break the link.
|
||||
run "firstboot skill pointer merged into global AGENTS.md" \
|
||||
"grep -q 'pi-devbox-firstboot/SKILL.md' /opt/pi-toolkit/pi-global-AGENTS.md"
|
||||
# Same path assertion for the two skills whose descriptions were deliberately
|
||||
# TRIMMED to a routing sentence. Their frontmatter no longer enumerates what
|
||||
# they cover, on the explicit premise that this block names them by path and so
|
||||
# they never depend on description-matching to be found. That premise is only
|
||||
# true while these lines exist, which is exactly why it is asserted rather than
|
||||
# trusted: reword the block, drop a pointer, and the skill silently stops being
|
||||
# reachable for a device that did not already know it was there.
|
||||
run "pi-devbox-environment pointer merged into global AGENTS.md" \
|
||||
"grep -q 'pi-devbox-environment/SKILL.md' /opt/pi-toolkit/pi-global-AGENTS.md"
|
||||
run "credential-incident-response pointer merged into global AGENTS.md" \
|
||||
"grep -q 'credential-incident-response/SKILL.md' /opt/pi-toolkit/pi-global-AGENTS.md"
|
||||
# Vendored fallback skills (so a no-skillset container still resolves the
|
||||
# AGENTS.md 'read the pi-extensions skill' pointer).
|
||||
run "image-baked pi-extensions fallback skill" \
|
||||
@@ -656,9 +691,74 @@ 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 "link-pi-extensions.sh links a mounted *.ts" 'mkdir -p /tmp/lpe/src && echo "export default {}" > /tmp/lpe/src/x.ts && PI_EXTENSIONS_SRC=/tmp/lpe/src PI_CODING_AGENT_DIR=/tmp/lpe/agent bash /usr/local/lib/pi-devbox/link-pi-extensions.sh >/dev/null && test "$(readlink /tmp/lpe/agent/extensions/x.ts)" = /tmp/lpe/src/x.ts && rm -rf /tmp/lpe'
|
||||
exec_test "pi-devbox-environment skill linked" 'test -L $HOME/.agents/skills/pi-devbox-environment && test -f $HOME/.agents/skills/pi-devbox-environment/SKILL.md && echo ok'
|
||||
exec_test "pi-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'
|
||||
# git-over-ssh sidecar wiring, asserted as a BICONDITIONAL rather than "is set".
|
||||
# setup-lan-access.sh writes ~/.ssh-local/config only on VM-backed hosts, and a
|
||||
# CI runner is native Linux Docker — so "core.sshCommand is set" would fail here
|
||||
# for a correct image, which is precisely the v1.8.0 trap (an assertion whose
|
||||
# environment was never checked, unvalidated until the next tag). Both arms are
|
||||
# real: sidecar present => must route through it; sidecar absent => must be UNSET,
|
||||
# because -F pointing at a missing file breaks every git-over-ssh call and is
|
||||
# worse than the problem being fixed. This arm is the one CI actually exercises,
|
||||
# so CI validates the guard; the other is covered by the static greps above and
|
||||
# by scripts/recreate-sanity-check.sh on a real device.
|
||||
exec_test "git core.sshCommand matches sidecar presence" '
|
||||
cmd=$(git config --global --get core.sshCommand 2>/dev/null || true)
|
||||
if [ -r "$HOME/.ssh-local/config" ]; then
|
||||
case "$cmd" in *"-F $HOME/.ssh-local/config"*) echo "wired: $cmd" ;; *) exit 1 ;; esac
|
||||
else
|
||||
[ -z "$cmd" ] || exit 1; echo "no sidecar, correctly unset"
|
||||
fi'
|
||||
# ssh-lan.conf is Included whether or not it exists at container start. Until
|
||||
# v1.12.0 the Include was written only if the file was readable at start, so a
|
||||
# file created later was silently ignored (EMB-3HXGNXJ0, 2026-10-18). ssh skips
|
||||
# an Include whose file is missing, and picks the file up once it appears, with no
|
||||
# restart. The exception is a file that exists but cannot be read: ssh then
|
||||
# aborts every connection, so that case is still left out. 644 is set
|
||||
# explicitly because ssh rejects a world-writable config file.
|
||||
exec_test "ssh-lan.conf Included even if created after start" '
|
||||
T=$(mktemp -d) && mkdir -p $T/.config/devbox-shell &&
|
||||
HOME=$T DEVBOX_LAN_ACCESS=jump bash /usr/local/lib/pi-devbox/setup-lan-access.sh >/dev/null 2>&1 &&
|
||||
grep -qx "Include ~/.config/devbox-shell/ssh-lan.conf" $T/.ssh-local/config &&
|
||||
HOME=$T ssh -F $T/.ssh-local/config -G lanpeer 2>/dev/null | grep -qx "hostname lanpeer" &&
|
||||
printf "Host lanpeer\n HostName 10.9.9.9\n" > $T/.config/devbox-shell/ssh-lan.conf &&
|
||||
chmod 644 $T/.config/devbox-shell/ssh-lan.conf &&
|
||||
HOME=$T ssh -F $T/.ssh-local/config -G lanpeer 2>/dev/null | grep -qx "hostname 10.9.9.9"
|
||||
r=$?; rm -rf $T; exit $r'
|
||||
exec_test "unreadable ssh-lan.conf left out (ssh would abort)" '
|
||||
T=$(mktemp -d) && mkdir -p $T/.config/devbox-shell && : > $T/.config/devbox-shell/ssh-lan.conf &&
|
||||
chmod 000 $T/.config/devbox-shell/ssh-lan.conf &&
|
||||
HOME=$T DEVBOX_LAN_ACCESS=jump bash /usr/local/lib/pi-devbox/setup-lan-access.sh >/dev/null 2>&1 &&
|
||||
! grep -q "ssh-lan.conf$" $T/.ssh-local/config &&
|
||||
HOME=$T ssh -F $T/.ssh-local/config -G lanpeer >/dev/null 2>&1
|
||||
r=$?; rm -rf $T; exit $r'
|
||||
# ~/.ssh/config gets the same rule as ssh-lan.conf above: Included whether or
|
||||
# not it exists at container start, except when it exists but cannot be read.
|
||||
# Entries added to a file that already existed at start were always picked up
|
||||
# (ssh reads Includes on every connection, and the mount is live). This covers
|
||||
# only a file that appears after start, e.g. a host provisioned after its first
|
||||
# `docker compose up`. Through v1.12.1 that file was ignored until a restart.
|
||||
exec_test "~/.ssh/config Included even if created after start" '
|
||||
T=$(mktemp -d) &&
|
||||
HOME=$T DEVBOX_LAN_ACCESS=jump bash /usr/local/lib/pi-devbox/setup-lan-access.sh >/dev/null 2>&1 &&
|
||||
grep -qx "Include ~/.ssh/config" $T/.ssh-local/config &&
|
||||
HOME=$T ssh -F $T/.ssh-local/config -G ownpeer 2>/dev/null | grep -qx "hostname ownpeer" &&
|
||||
mkdir -p $T/.ssh && printf "Host ownpeer\n HostName 10.8.8.8\n" > $T/.ssh/config &&
|
||||
chmod 600 $T/.ssh/config &&
|
||||
HOME=$T ssh -F $T/.ssh-local/config -G ownpeer 2>/dev/null | grep -qx "hostname 10.8.8.8"
|
||||
r=$?; rm -rf $T; exit $r'
|
||||
exec_test "unreadable ~/.ssh/config left out (ssh would abort)" '
|
||||
T=$(mktemp -d) && mkdir -p $T/.ssh && printf "Host x\n" > $T/.ssh/config &&
|
||||
chmod 000 $T/.ssh/config &&
|
||||
HOME=$T DEVBOX_LAN_ACCESS=jump bash /usr/local/lib/pi-devbox/setup-lan-access.sh >/dev/null 2>&1 &&
|
||||
! grep -qx "Include ~/.ssh/config" $T/.ssh-local/config &&
|
||||
HOME=$T ssh -F $T/.ssh-local/config -G ownpeer >/dev/null 2>&1
|
||||
r=$?; chmod 600 $T/.ssh/config; rm -rf $T; exit $r'
|
||||
# The vendored mempalace snapshot is refreshed MANUALLY per release (see
|
||||
# rootfs/usr/local/share/pi-devbox/skills/VENDORED.md). Through v1.8.4 it also
|
||||
# silently SHADOWED the live skillset copy, so staleness was invisible — and the
|
||||
@@ -729,7 +829,7 @@ exec_test "mempalace skill snapshot is current" 'f=$HOME/.agents/skills/mempa
|
||||
# Link TARGETS, not just link existence: with no skillset mounted (as here) the
|
||||
# baked tree must be what resolves, for all four vendored skills.
|
||||
exec_test "vendored skills resolve to the baked tree (no skillset mounted)" \
|
||||
'for s in mempalace pi-extensions pi-devbox-environment credential-incident-response; do
|
||||
'for s in mempalace pi-extensions pi-devbox-environment credential-incident-response pi-devbox-firstboot; do
|
||||
case "$(readlink -f $HOME/.agents/skills/$s)" in
|
||||
/usr/local/share/pi-devbox/skills/$s) ;;
|
||||
*) echo "$s resolves to $(readlink -f $HOME/.agents/skills/$s)" >&2; exit 1 ;;
|
||||
@@ -743,7 +843,7 @@ exec_test "vendored skills resolve to the baked tree (no skillset mounted)" \
|
||||
exec_test "pi-devbox-version reports skill sources (all baked, no skillset here)" \
|
||||
'out=$(pi-devbox-version)
|
||||
echo "$out" | grep -q "skills:" || { echo "no skills section" >&2; exit 1; }
|
||||
for s in mempalace pi-extensions pi-devbox-environment credential-incident-response; do
|
||||
for s in mempalace pi-extensions pi-devbox-environment credential-incident-response pi-devbox-firstboot; do
|
||||
echo "$out" | grep -qE "^ $s +baked( \([^)]*\))?$" \
|
||||
|| { echo "$s not reported as baked" >&2; exit 1; }
|
||||
done; echo ok'
|
||||
@@ -950,14 +1050,62 @@ run "no build-time npm cache shipped (/root/.npm)" \
|
||||
# runbook as "if this fails, revert the release", and it duly failed for the
|
||||
# wrong reason on the first machine that ran it. A check must fail only for the
|
||||
# thing it is checking.
|
||||
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'
|
||||
#
|
||||
# PRESENT IFF DECLARED, like clipboard below. esbuild was never a direct pi
|
||||
# dependency: it arrived through @earendil-works/chord (pi 1.1.0 > chord >
|
||||
# esbuild 0.28.2, at both the pi and pi-fork sites). pi 1.2.0 dropped chord, so
|
||||
# a correct 1.2.0 image has NO esbuild, and the old "must exist" form failed the
|
||||
# v1.11.1 tag build (102/103 core, 105/106 studio, nothing published) against a
|
||||
# good image — the same mistake the clipboard check made at v1.10.0. Because it
|
||||
# is transitive, "declared" cannot be read from pi's package.json: it is
|
||||
# derived from every INSTALLED package root (node_modules/<pkg>/package.json,
|
||||
# node_modules/@scope/<pkg>/package.json) and every /opt repo root, counting
|
||||
# dependencies, optionalDependencies and peerDependencies (devDependencies are
|
||||
# not installed: --omit=dev). Anchoring on package roots keeps a test fixture's
|
||||
# package.json from counting. Both directions fail: declared but missing (the
|
||||
# prune removed function), and installed with nothing declaring it (residue).
|
||||
run "esbuild: present iff something declares it, and works at every install site (prune removed weight, not function)" \
|
||||
'declarers=$(find /usr/lib/node_modules /opt -regextype posix-extended -type f \( -regex ".*/node_modules/(@[^/]+/)?[^/]+/package\.json" -o -regex "/opt/[^/]+/package\.json" \) 2>/dev/null | node -e "
|
||||
const fs = require(\"fs\"); const hit = [];
|
||||
for (const f of fs.readFileSync(0, \"utf8\").split(String.fromCharCode(10)).filter(Boolean)) {
|
||||
let j; try { j = JSON.parse(fs.readFileSync(f, \"utf8\")); } catch (e) { continue; }
|
||||
if (!j || j.name === \"esbuild\") continue;
|
||||
for (const k of [\"dependencies\", \"optionalDependencies\", \"peerDependencies\"]) {
|
||||
if (j[k] && Object.prototype.hasOwnProperty.call(j[k], \"esbuild\")) { hit.push(f); break; }
|
||||
}
|
||||
}
|
||||
console.log(hit.join(\" \"));") || { echo "declarer scan itself failed" >&2; exit 1; };
|
||||
sites=$(find /usr/lib/node_modules /opt -type d -path "*/node_modules/esbuild" -prune 2>/dev/null);
|
||||
if [ -n "$declarers" ]; then
|
||||
[ -n "$sites" ] || { echo "declared by $declarers but NO install found - prune removed function" >&2; exit 1; };
|
||||
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: declared ($(echo $declarers | wc -w) package(s)) and compiles at $(echo $sites | wc -w) site(s)";
|
||||
else
|
||||
[ -z "$sites" ] || { echo "nothing declares esbuild yet installs remain: $sites" >&2; exit 1; };
|
||||
echo "ok: nothing declares esbuild (pi >= 1.2.0 dropped chord); no install expected";
|
||||
fi'
|
||||
|
||||
# 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 ""
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
#!/usr/bin/env bash
|
||||
# test-link-pi-extensions.sh — two-sided tests for
|
||||
# rootfs/usr/local/lib/pi-devbox/link-pi-extensions.sh.
|
||||
#
|
||||
# Runs the real script against a throwaway HOME / PI_CODING_AGENT_DIR, so it never
|
||||
# touches a live ~/.pi/agent. Every "must link" case has a "must NOT touch" twin:
|
||||
# the script's whole contract is that it only ever creates a link where nothing
|
||||
# exists, so the negative cases are the important half — and one of them (a real
|
||||
# extension of the same name wins) is what keeps a user's own file safe.
|
||||
#
|
||||
# bash scripts/test-link-pi-extensions.sh # CI (lint.yml) and locally
|
||||
# LINK_PI_EXTENSIONS=/path/to/variant.sh bash scripts/test-link-pi-extensions.sh
|
||||
set -u
|
||||
here="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
SCRIPT="${LINK_PI_EXTENSIONS:-$here/rootfs/usr/local/lib/pi-devbox/link-pi-extensions.sh}"
|
||||
T="$(mktemp -d)"; trap 'rm -rf "$T"' EXIT
|
||||
PASS=0; FAIL=0
|
||||
ok() { printf ' ✅ %s\n' "$1"; PASS=$((PASS+1)); }
|
||||
bad() { printf ' ❌ %s — %s\n' "$1" "$2"; FAIL=$((FAIL+1)); }
|
||||
|
||||
# One fresh world per case: a shared dir holding two extensions (plus files that
|
||||
# are NOT extensions), an empty HOME, agent dir unset.
|
||||
world() {
|
||||
rm -rf "$T/w"; mkdir -p "$T/w/shared" "$T/w/home"
|
||||
printf 'export default {}\n' > "$T/w/shared/alpha.ts"
|
||||
printf 'export default {}\n' > "$T/w/shared/beta.ts"
|
||||
printf '{"providers":{}}\n' > "$T/w/shared/models.json"
|
||||
printf 'not an extension\n' > "$T/w/shared/README.md"
|
||||
SRC="$T/w/shared"; AG="$T/w/home/.pi/agent"; DEST="$AG/extensions"
|
||||
}
|
||||
# run [env assignments...] — run the script with HOME and PI_CODING_AGENT_DIR pointed into the world.
|
||||
# "$@" goes FIRST so an option like `-u PI_EXTENSIONS_SRC` is still parsed as one by env.
|
||||
run() { OUT="$(env "$@" HOME="$T/w/home" PI_CODING_AGENT_DIR="$AG" bash "$SCRIPT" 2>&1)"; RC=$?; }
|
||||
|
||||
echo "── link-pi-extensions.sh ($SCRIPT)"
|
||||
|
||||
world; run PI_EXTENSIONS_SRC="$SRC"
|
||||
if [ "$RC" = 0 ] && [ "$(readlink "$DEST/alpha.ts")" = "$SRC/alpha.ts" ] \
|
||||
&& [ "$(readlink "$DEST/beta.ts")" = "$SRC/beta.ts" ] && [[ "$OUT" == *"linked alpha.ts"* ]]; then
|
||||
ok "links every top-level *.ts when the shared dir exists and nothing is there (creates the extensions dir)"
|
||||
else bad "link when absent" "rc=$RC link=$(readlink "$DEST/alpha.ts" 2>&1) out=[$OUT]"; fi
|
||||
|
||||
run PI_EXTENSIONS_SRC="$SRC"
|
||||
if [ "$RC" = 0 ] && [ -z "$OUT" ] && [ "$(readlink "$DEST/alpha.ts")" = "$SRC/alpha.ts" ]; then
|
||||
ok "second run is a silent no-op (every container start runs it)"
|
||||
else bad "idempotent" "rc=$RC out=[$OUT]"; fi
|
||||
|
||||
world; run PI_EXTENSIONS_SRC="$SRC"
|
||||
if [ ! -e "$DEST/models.json" ] && [ ! -L "$DEST/models.json" ] \
|
||||
&& [ ! -e "$DEST/README.md" ] && [ ! -L "$DEST/README.md" ]; then
|
||||
ok "non-.ts files in the shared dir are ignored (models.json, README.md)"
|
||||
else bad "non-.ts ignored" "dest holds: $(ls -A "$DEST" 2>&1 | tr '\n' ' ')"; fi
|
||||
|
||||
# A nested .ts must NOT be linked: the dir is fleet pi config, not an extension
|
||||
# tree, and loading something nobody intended to publish as an extension is worse
|
||||
# than missing one.
|
||||
world; mkdir -p "$SRC/nested"; printf 'export default {}\n' > "$SRC/nested/gamma.ts"; run PI_EXTENSIONS_SRC="$SRC"
|
||||
if [ "$RC" = 0 ] && [ ! -e "$DEST/gamma.ts" ] && [ ! -L "$DEST/gamma.ts" ]; then
|
||||
ok "does not recurse — a nested *.ts is left alone (only top level is the contract)"
|
||||
else bad "no recursion" "rc=$RC found=$(readlink "$DEST/gamma.ts" 2>&1)"; fi
|
||||
|
||||
world; mkdir -p "$SRC/dir.ts"; run PI_EXTENSIONS_SRC="$SRC"
|
||||
if [ "$RC" = 0 ] && [ ! -e "$DEST/dir.ts" ] && [ ! -L "$DEST/dir.ts" ]; then
|
||||
ok "a directory whose name ends in .ts is not linked"
|
||||
else bad "dir named *.ts" "rc=$RC out=[$OUT]"; fi
|
||||
|
||||
world; mkdir -p "$DEST"; printf 'my own\n' > "$DEST/alpha.ts"; before="$(sha256sum "$DEST/alpha.ts")"; run PI_EXTENSIONS_SRC="$SRC"
|
||||
if [ "$RC" = 0 ] && [ ! -L "$DEST/alpha.ts" ] && [ "$(sha256sum "$DEST/alpha.ts")" = "$before" ] \
|
||||
&& [[ "$OUT" == NOTE*"real file"* ]] && [ "$(readlink "$DEST/beta.ts")" = "$SRC/beta.ts" ]; then
|
||||
ok "never replaces the user's own extension of that name (content unchanged, one NOTE line) — and still links the others"
|
||||
else bad "real file kept" "rc=$RC islink=$([ -L "$DEST/alpha.ts" ] && echo y) out=[$OUT]"; fi
|
||||
|
||||
world; mkdir -p "$DEST"; ln -s /somewhere/else.ts "$DEST/alpha.ts"; run PI_EXTENSIONS_SRC="$SRC"
|
||||
if [ "$RC" = 0 ] && [ "$(readlink "$DEST/alpha.ts")" = /somewhere/else.ts ] && [[ "$OUT" == NOTE*"left alone"* ]]; then
|
||||
ok "never re-points a link the user made to another file"
|
||||
else bad "foreign link kept" "rc=$RC link=$(readlink "$DEST/alpha.ts") out=[$OUT]"; fi
|
||||
|
||||
world; rm -rf "$SRC"; run PI_EXTENSIONS_SRC="$SRC"
|
||||
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ] && [ -z "$OUT" ]; then
|
||||
ok "no shared dir → creates nothing and says nothing (the default for most users)"
|
||||
else bad "absent source" "rc=$RC out=[$OUT]"; fi
|
||||
|
||||
world; rm -f "$SRC"/*.ts; run PI_EXTENSIONS_SRC="$SRC"
|
||||
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ] && [ -z "$OUT" ]; then
|
||||
ok "a shared dir with no *.ts → creates nothing (the dir is not treated as an extension itself)"
|
||||
else bad "empty source dir" "rc=$RC out=[$OUT]"; fi
|
||||
|
||||
for v in off OFF ""; do
|
||||
world; run PI_EXTENSIONS_SRC="$v"
|
||||
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ] && [ -z "$OUT" ]; then
|
||||
ok "PI_EXTENSIONS_SRC='$v' disables it"
|
||||
else bad "disable '$v'" "rc=$RC out=[$OUT]"; fi
|
||||
done
|
||||
|
||||
world; run PI_EXTENSIONS_SRC="$SRC"; rm -f "$SRC"/alpha.ts; run PI_EXTENSIONS_SRC="$SRC"
|
||||
if [ "$RC" = 0 ] && [ -L "$DEST/alpha.ts" ] && [ "$(readlink "$DEST/alpha.ts")" = "$SRC/alpha.ts" ] && [ -z "$OUT" ]; then
|
||||
ok "our link whose target vanished (mount removed) is left as is, silently"
|
||||
else bad "dangling own link" "rc=$RC out=[$OUT]"; fi
|
||||
|
||||
# pi honours PI_CODING_AGENT_DIR, so the links must land THERE, not in $HOME/.pi/agent.
|
||||
world; AG="$T/w/elsewhere/agent"; DEST="$AG/extensions"; run PI_EXTENSIONS_SRC="$SRC"
|
||||
if [ "$RC" = 0 ] && [ "$(readlink "$DEST/alpha.ts" 2>/dev/null)" = "$SRC/alpha.ts" ] \
|
||||
&& [ ! -e "$T/w/home/.pi/agent/extensions" ]; then
|
||||
ok "links into PI_CODING_AGENT_DIR when set (and leaves \$HOME/.pi/agent alone)"
|
||||
else bad "PI_CODING_AGENT_DIR" "rc=$RC link=$(readlink "$DEST/alpha.ts" 2>&1) out=[$OUT]"; fi
|
||||
|
||||
# 'off' must be a switch, not merely a path that happens not to exist: with a
|
||||
# directory literally named "off" in the working directory it must still disable.
|
||||
world; mkdir -p "$T/w/off"; printf 'export default {}\n' > "$T/w/off/alpha.ts"
|
||||
OUT="$(cd "$T/w" && env PI_EXTENSIONS_SRC=off HOME="$T/w/home" PI_CODING_AGENT_DIR="$AG" bash "$SCRIPT" 2>&1)"; RC=$?
|
||||
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ]; then
|
||||
ok "PI_EXTENSIONS_SRC=off disables it even when a directory named 'off' exists in the cwd"
|
||||
else bad "off is a switch" "rc=$RC link=$(readlink "$DEST/alpha.ts" 2>&1) out=[$OUT]"; fi
|
||||
|
||||
# The default is /opt/fleet-pi — the same dir that holds models.json. Whether it
|
||||
# exists, and whether it holds any *.ts, depends on where this runs (a fleet devbox:
|
||||
# maybe both; CI: neither), so derive the expectation instead of assuming it.
|
||||
world; run -u PI_EXTENSIONS_SRC
|
||||
default_ts="$(ls -1 /opt/fleet-pi/*.ts 2>/dev/null | head -1)"
|
||||
if [ -n "$default_ts" ]; then
|
||||
_dn="$(basename "$default_ts")"
|
||||
if [ "$RC" = 0 ] && [ "$(readlink "$DEST/$_dn" 2>/dev/null)" = "$default_ts" ]; then
|
||||
ok "PI_EXTENSIONS_SRC unset → default /opt/fleet-pi (here: holds $_dn) is linked"
|
||||
else bad "default path (with .ts)" "rc=$RC link=$(readlink "$DEST/$_dn" 2>&1) out=[$OUT]"; fi
|
||||
unset _dn
|
||||
else
|
||||
if [ "$RC" = 0 ] && [ ! -e "$DEST" ] && [ ! -L "$DEST" ] && [ -z "$OUT" ]; then
|
||||
ok "PI_EXTENSIONS_SRC unset → default /opt/fleet-pi (absent or has no *.ts here) → nothing"
|
||||
else bad "default path (no .ts)" "rc=$RC out=[$OUT]"; fi
|
||||
fi
|
||||
|
||||
echo "── $PASS passed, $FAIL failed"
|
||||
[ "$FAIL" = 0 ]
|
||||
Executable
+100
@@ -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 ]
|
||||
Reference in New Issue
Block a user