Compare commits
34 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
@@ -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 ──────────────────
|
||||
@@ -186,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 ->
|
||||
@@ -386,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
|
||||
@@ -483,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}"
|
||||
@@ -512,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
|
||||
@@ -571,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:
|
||||
@@ -584,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
|
||||
@@ -600,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 }}
|
||||
@@ -622,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
|
||||
@@ -636,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:
|
||||
@@ -649,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
|
||||
@@ -665,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 }}
|
||||
@@ -690,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:
|
||||
@@ -715,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
|
||||
@@ -808,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,27 @@ 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
|
||||
@@ -228,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`).
|
||||
|
||||
+1088
File diff suppressed because it is too large
Load Diff
+3
-2
@@ -49,7 +49,8 @@ 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
|
||||
@@ -60,7 +61,7 @@ The entrypoint deploys/registers all of these on first container start. Re-runni
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
+37
-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).
|
||||
@@ -639,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
|
||||
@@ -822,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)
|
||||
@@ -839,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}" && \
|
||||
|
||||
+170
-11
@@ -212,9 +212,11 @@ ARG USER_NAME=developer
|
||||
# `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 and mcp-loader.ts run their OWN MCP client and surface tools via
|
||||
# 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`. Confirmed on a second route — settings.json has
|
||||
# `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.
|
||||
@@ -231,7 +233,126 @@ ARG USER_NAME=developer
|
||||
# is documented (README -> "Terminal UI mode"): `"tuiMode": "regular"` in
|
||||
# ~/.pi/agent/settings.json, `pi --tui-mode regular` for one session, or the
|
||||
# same key in a project's .pi/settings.json.
|
||||
ARG PI_VERSION=1.0.0
|
||||
#
|
||||
# v1.11.0: 1.0.0 -> 1.1.0. MEASURED 2026-10-12 against the published npm
|
||||
# tarballs for 1.0.0 and 1.1.0, unpacked side by side, by the same method as
|
||||
# the v1.10.2 audit above:
|
||||
# 1. ONE `### Breaking Changes` section in 1.0.1..1.1.0: 1.0.3 renamed the
|
||||
# provider `azure-openai-responses` -> `azure`. 0 uses here: grepped
|
||||
# rootfs/, ~/.pi/agent/settings.json, pi-toolkit's settings.example.json
|
||||
# and the fleet's shared models.json. Everything else is additive or a fix
|
||||
# (OSC 7501 program status, `+name`/`-name` in --tools, `durationMs` and
|
||||
# `outputPad` in the tool render context, `aborted` on agent_settled).
|
||||
# Two user-visible changes: Home/End always move the editor cursor
|
||||
# (fullscreen transcript top/bottom moved to Ctrl+Home/Ctrl+End, 1.0.3),
|
||||
# and OSC 7501 status reporting (PI_PROGRAM_STATUS=0 turns it off, 1.1.0).
|
||||
# 2. pi-agent-core 1.0.0 -> 1.1.0: the finishTurn context in dist is
|
||||
# BYTE-IDENTICAL; the only agent-loop diff adds `durationMs`. The obsmem
|
||||
# turn-cap probe drove the real 1.1.0 agentLoop: uncapped ran its chosen
|
||||
# 11 turns, maxTurns=3 stopped at exactly 3 + agent_end.
|
||||
# 3. The 6 named pi.* APIs (registerTool, registerCommand, registerFlag,
|
||||
# getFlag, sendMessage, sendUserMessage) appear in the same number of dist
|
||||
# files in 1.0.0 and 1.1.0. atelier's internals TuiMainScreen and
|
||||
# renderLayoutFrame are both still in 1.1.0's dist.
|
||||
# 4. engines.node is still `>=22.19.0`; the image ships 24.x.
|
||||
# Exercised before tagging, from a temp prefix with pi 1.1.0 and a throwaway
|
||||
# PI_CODING_AGENT_DIR, in a real pty, with -ne controls: atelier v0.15.0 paints
|
||||
# ACTIVITY/READY/TOOLS in BOTH renderers (controls 0/3, every arm render-proofed);
|
||||
# obsmem 022da5e registers /om:status and /om:view (control: none); and all 11
|
||||
# pi-extensions a88fa99 extensions load with no error (control: no /aws-sso).
|
||||
#
|
||||
# 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
|
||||
@@ -253,7 +374,22 @@ ARG PI_OBSMEM_REPO=https://github.com/elpapi42/pi-observational-memory.git
|
||||
# 40-char match (SHA40), so a short pin would fall through to its
|
||||
# branch-or-tag lookup, fail, and downgrade that component's drift check to a
|
||||
# silent SKIP — a pin that reads fine and is no longer verified.
|
||||
ARG PI_OBSMEM_REF=731c3d49288580f4d79cabdbfbc0d16b34db0f41
|
||||
# v1.11.0: 731c3d4 -> 022da5e (master, 2026-10-05; still no newer tag than
|
||||
# 3.1.4). Two commits: #81 never drops unobserved context at compaction
|
||||
# (new settings compactionMaxRetainedTokens, consolidateWhenIdle,
|
||||
# piAutoCompactionEnabled, all defaulted), and #93 makes pi load a prebuilt
|
||||
# dist/index.ts instead of 32 TS sources. The bundle is committed, so
|
||||
# `npm install --omit=dev` below needs no build step. src/agents/ is
|
||||
# UNCHANGED between the two SHAs, so the finishTurn migration is intact.
|
||||
# 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.
|
||||
@@ -332,9 +468,31 @@ ARG PI_ATELIER_REPO=https://github.com/michaelmjhhhh/pi-atelier.git
|
||||
# - Residual: v0.13.0 is SAME-DAY upstream (2026-10-02). Acceptance proves
|
||||
# the sidebar PAINTS with the two-sided check from 0.84.4/0.85.1 that can
|
||||
# tell "loaded" from "silently absent" — "no crash" is not the test.
|
||||
ARG PI_ATELIER_REF=v0.13.0
|
||||
#
|
||||
# v1.11.0: v0.13.0 -> v0.15.0 (v0.14.0 and v0.15.0 both 2026-10-03). The
|
||||
# peerDependencies floor is unchanged (pi >=0.84.0), atelier still has zero
|
||||
# runtime dependencies, and it still imports only the public package exports.
|
||||
# Behaviour changes: v0.14.0 auto-collapses the Sidebar when the terminal is
|
||||
# narrow (Auto/Manual modes). v0.15.0 starts in Manual mode, recolours the
|
||||
# panels and simplifies the Activity rows. The startup preference is still
|
||||
# `showSidebarOnStartup` (default true, src/config.ts:38). Painted in both
|
||||
# renderers on pi 1.1.0 before tagging (see the PI_VERSION note).
|
||||
# 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.13.0
|
||||
ARG PI_ATELIER_VERSION=v0.16.0
|
||||
|
||||
RUN set -e && \
|
||||
# git_fetch_ref: clone-equivalent helper that accepts EITHER a branch name
|
||||
@@ -592,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 ───────────────────────────────────────────
|
||||
@@ -659,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
|
||||
|
||||
@@ -464,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
|
||||
|
||||
@@ -731,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
|
||||
@@ -879,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:
|
||||
@@ -996,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
|
||||
```
|
||||
|
||||
@@ -1046,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
|
||||
|
||||
@@ -1161,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 1.0.0 # 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:
|
||||
@@ -1195,15 +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 | `1.0.0` | `ARG PI_VERSION` — `Dockerfile.variant` |
|
||||
| pi-obsmem | `731c3d49288580f4d79cabdbfbc0d16b34db0f41` | `ARG PI_OBSMEM_REF` — `Dockerfile.variant` |
|
||||
| pi-atelier | `v0.13.0` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
|
||||
| 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
|
||||
|
||||
@@ -371,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.
|
||||
|
||||
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
|
||||
@@ -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"
|
||||
|
||||
+104
-5
@@ -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 everywhere; clipboard present iff pi declares it
|
||||
# - esbuild present iff something declares it, and compiles everywhere;
|
||||
# clipboard present iff pi declares it
|
||||
# - image size within threshold
|
||||
|
||||
set -euo pipefail
|
||||
@@ -290,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" \
|
||||
@@ -672,6 +691,9 @@ 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'
|
||||
@@ -692,6 +714,51 @@ exec_test "git core.sshCommand matches sidecar presence" '
|
||||
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
|
||||
@@ -762,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 ;;
|
||||
@@ -776,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'
|
||||
@@ -983,8 +1050,40 @@ 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. Upstream pi 0.86.0 (#9163) REPLACED the
|
||||
# external `@mariozechner/clipboard` dependency with bundled macOS/Windows/X11
|
||||
|
||||
@@ -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