f561acc89a
Folded into v1.8.13 at zero marginal cost: the snapshot is hashed into
base_tag, but Dockerfile.base already changed this release, so the ~67 min base
rebuild was already being paid. vendor-mempalace-skill.sh --check reported exit
0 (stale-but-truthful) beforehand, so skipping was sanctioned -- this is the
deliberate call the release checklist asks for. Upstream content: the bare
project-name wing convention and the <harness>@<device> added_by rule, both
downstream of the attribution defect measured on this device 2026-09-06.
The canary re-pin matters more than the refresh. Its old pair ("Provenance is
stamped for you" present / "Attribute what you file yourself" absent) still
PASSED against the new snapshot, so leaving it would have yielded a canary
green on both old and new bytes -- blind to exactly the refresh it exists to
witness, the same false-green family as the pre-v1.8.5 canary. New pair chosen
by measuring direction against both files rather than reading the diff
("Diaries self-heal; plain drawers do not" new=1/old=0; "Agent diaries live in"
new=0/old=1), then tested two-sided: PASS on refreshed bytes, FAIL on the old
bytes recovered from git.
Gates after the change: smoke-test.sh parses, vendor --check exit 0,
check-base-hash exit 0.
496 lines
30 KiB
Docker
496 lines
30 KiB
Docker
# pi-devbox — variant image
|
|
#
|
|
# FROMs a base-<hash> image produced by Dockerfile.base and adds only
|
|
# the variant-specific tools — currently just the pi install. Kept as a
|
|
# separate file (rather than collapsed into Dockerfile.base) so future
|
|
# variants (e.g. studio, studio-tex) can FROM the variant or extend
|
|
# this Dockerfile with additional build args without rebuilding the
|
|
# base on every pi version bump.
|
|
#
|
|
# Pass `--build-arg BASE_IMAGE=<repo>:base-<hash>` to select the base.
|
|
# CI computes the base hash from Dockerfile.base + rootfs/ +
|
|
# entrypoint*.sh and feeds it in.
|
|
#
|
|
# IMPORTANT: the base image sets NPM_CONFIG_PREFIX to
|
|
# /home/developer/.pi/npm-global so runtime `pi install npm:...` and
|
|
# `npm install -g` by the developer user lands on the named volume.
|
|
# At BUILD time we want the baked binaries on /usr so they survive the
|
|
# volume mount. Each `npm install -g` below therefore prefixes the
|
|
# command with `NPM_CONFIG_PREFIX=/usr`.
|
|
|
|
ARG BASE_IMAGE
|
|
FROM ${BASE_IMAGE}
|
|
|
|
ARG TARGETARCH
|
|
ARG USER_NAME=developer
|
|
|
|
# ── pi coding-agent + companions ─────────────────────────────────────
|
|
# pi-toolkit and pi-extensions are cloned into /opt/. entrypoint-user.sh
|
|
# runs each repo's install.sh on container start so symlinks land under
|
|
# ~/.pi/agent/ on the named volume.
|
|
#
|
|
# ── pi version pin: an AUDITED CHECKPOINT, not a freeze ──────────────
|
|
# PI_VERSION is pinned to a version whose upstream CHANGELOG has been read
|
|
# against this image's integration surface: the theme/TUI API that pi-atelier
|
|
# couples to, the session `.jsonl` format that `pi-session-repair` parses, the
|
|
# extension/package loader, and the Node engine floor. CI reads THIS LINE as
|
|
# the single source of truth (see the `resolve-versions` job) and no longer
|
|
# follows npm `latest` — following it meant every release silently adopted
|
|
# whatever pi shipped that morning, unaudited, in the very build that then got
|
|
# tagged and published.
|
|
#
|
|
# BUMPING IS ROUTINE AND EXPECTED — the pin exists to force a look, not to
|
|
# hold a version forever:
|
|
# 1. Read the upstream CHANGELOG for every version between old and new.
|
|
# 2. Re-check the companions that couple to pi's private TUI/renderer
|
|
# internals — pi-atelier above all (see PI_ATELIER_REF below for the
|
|
# 0.6.0-under-pi-0.84 startup-hang precedent).
|
|
# 3. Bump this line, record the audit in CHANGELOG.md, then tag.
|
|
# CI fails the build if this pin is not a published npm version, and warns —
|
|
# without adopting it — when npm `latest` has moved ahead. That warning is the
|
|
# prompt to do step 1; it is not something to silence.
|
|
#
|
|
# A concrete version here ALSO defeats the registry-buildcache cache-hit
|
|
# footgun that `latest` carried: a byte-identical build-arg string produced an
|
|
# identical layer hash, so the cache reused the layer from whatever pi was
|
|
# current when it was first populated (shipped the same bytes for pi-devbox
|
|
# v0.74.0..v0.75.5; discovered + fixed in v0.75.5b, 2026-05-23). The `latest`
|
|
# branch below is kept only for a deliberate local `docker build` override.
|
|
#
|
|
# AUDITED AT 0.84.4 (2026-08-31, was 0.84.3): NO "Breaking Changes" and no
|
|
# "Removed" heading in the 0.84.4 section (grepped, 0 matches) — unlike 0.84.3,
|
|
# whose heading is described in the paragraph below and stays audited. Adopted
|
|
# for three fixes that land on machinery this fleet actually runs:
|
|
# - #6879 large tool results crossing the auto-compaction threshold were sent
|
|
# to the provider BEFORE compacting; pi now compacts between tool execution
|
|
# and the next assistant response in the same run. This is the shape of
|
|
# nearly every session here (multi-hundred-KB logstream/palace tool output).
|
|
# - #8345 a resumed session corrupted its next appended entry when the JSONL
|
|
# lacked a trailing newline. That file is the memory feeder's own input.
|
|
# Measured on tor-ms22 before the bump: 49/49 transcripts end in a newline,
|
|
# 0 lines fail json.loads — the bug had not bitten this corpus.
|
|
# - #8537 extension messages sent with `triggerTurn: false` WHILE THE AGENT IS
|
|
# RUNNING were inserted between a tool call and its result, so
|
|
# order-validating providers rejected the replayed history. The mempalace
|
|
# mailbox is outside that precondition — it delivers at `agent_settled`
|
|
# (idle) with `{deliverAs:"steer"}` and deliberately no `triggerTurn` — and
|
|
# 0.84.4 leaves the documented steer semantics unchanged, so RFC 003 §7.11
|
|
# still holds. Recorded because the fix is what would make a future mid-run
|
|
# delivery safe, which is the only reason we would ever change that call.
|
|
# One doc consequence, fixed in this same release: pi's own docs/compaction.md
|
|
# gained exactly one paragraph — the autoCompact threshold is now ALSO checked
|
|
# mid-run, after a tool batch's results are appended. See
|
|
# docs/observational-memory.md §3, which had said compaction is only checked
|
|
# when pi goes idle.
|
|
#
|
|
# AUDITED AT 0.84.3 (2026-08-25, was 0.84.2): upstream's notes carry a
|
|
# "Breaking Changes" heading — `GoogleThinkingLevel` renamed to
|
|
# `GoogleApiThinkingLevel`. INERT FOR THIS IMAGE: all four vendored companions
|
|
# (/opt/pi-fork, /opt/pi-observational-memory, /opt/pi-atelier, /opt/pi-studio)
|
|
# were grepped for that symbol and reference it ZERO times, so nothing here
|
|
# couples to the renamed type. Recorded because the heading will look alarming
|
|
# to the next reader doing step 1 above — the audit is done, don't redo it.
|
|
# Adopted for two fixes that land squarely on this repo's own vendored-skill
|
|
# wiring (see devbox-skill-reconcile, v1.8.5): nested Markdown skills inside
|
|
# `.agents/skills/<group>/` directories were not discovered, and root Markdown
|
|
# files such as README.md / AGENTS.md inside a skill dir were reported as
|
|
# broken skills unless they declared valid skill frontmatter.
|
|
#
|
|
# v1.8.13: 0.84.4 -> 0.85.1. SKIP 0.85.0 deliberately — it accidentally
|
|
# published internal experimental code and extra subpaths, breaking SDK
|
|
# imports (upstream #9132); 0.85.1 exists specifically to undo that, with the
|
|
# supported SDK and stdio RPC API unchanged. Audited: no Breaking/Removed
|
|
# changelog headings in either release, engine floor unchanged (>=22.19.0,
|
|
# container runs 22.23.2), runtime deps 20 -> 19. User-visible changes are the
|
|
# streaming indicator moving into the editor border and faster fullscreen
|
|
# transcript search; no deprecation language anywhere.
|
|
#
|
|
# Verified EMPIRICALLY rather than from the changelog, because a pi bump has
|
|
# hung the TUI before (pi-atelier < 0.7.1 + pi >= 0.84): 0.85.1 was
|
|
# side-installed and driven under a pty against all four companion extensions,
|
|
# with atelier v0.10.0 AND v0.10.1 — five combinations, each rendering alive
|
|
# with a CPU delta of 0.00-0.01s over a 5s window, where the known hang
|
|
# signature is ~5s of sustained CPU. Two-sided check: the atelier sidebar
|
|
# painted ACTIVITY+WORKSPACE identically to the 0.84.4 control, so the test
|
|
# could distinguish "loaded" from "silently absent".
|
|
ARG PI_VERSION=0.85.1
|
|
ARG PI_TOOLKIT_REF=main
|
|
ARG PI_EXTENSIONS_REF=main
|
|
# Repo URLs default to the canonical gitea origin but are overridable so a
|
|
# relocated/forked build can clone from a mirror or a different host
|
|
# without editing this Dockerfile — same pattern as PI_FORK_REPO /
|
|
# PI_OBSMEM_REPO / PI_STUDIO_REPO below.
|
|
ARG PI_TOOLKIT_REPO=https://gitea.jordbo.se/joakimp/pi-toolkit.git
|
|
ARG PI_EXTENSIONS_REPO=https://gitea.jordbo.se/joakimp/pi-extensions.git
|
|
# pi-fork (fork tool) + pi-observational-memory (recall tool) live on GitHub
|
|
# under elpapi42. CI resolves these to commit SHAs to defeat the same
|
|
# cache-hit footgun that affects PI_VERSION.
|
|
ARG PI_FORK_REPO=https://github.com/elpapi42/pi-fork.git
|
|
ARG PI_FORK_REF=master
|
|
ARG PI_OBSMEM_REPO=https://github.com/elpapi42/pi-observational-memory.git
|
|
ARG PI_OBSMEM_REF=master
|
|
# 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.
|
|
#
|
|
# This floor is hard-earned. pi-atelier 0.6.0/0.7.0 wrapped pi's PRIVATE TUI
|
|
# renderer, and under pi 0.84 that wrapper recursed: pi hung at startup with
|
|
# sustained CPU. Upstream fixed the recursion in 0.7.1 and restored the
|
|
# non-overlapping split in 0.7.2 — "avoiding the recursive render path that
|
|
# caused startup hangs and sustained CPU usage". Its own peerDependencies
|
|
# still say `>=0.80.7`, which does NOT encode that floor, so nothing would
|
|
# have warned us: NEVER pair pi-atelier < 0.7.1 with pi >= 0.84. Bump this
|
|
# pin and PI_VERSION together, checking atelier's CHANGELOG for the pi
|
|
# version it claims to track.
|
|
#
|
|
# AUDITED AT v0.10.0 (2026-08-31, was v0.8.2 — two minor releases): no
|
|
# BREAKING notice in either release, and both are UI-only (Sidebar calm during
|
|
# an active Turn, composer frame + Status Rail, fullscreen-copy-safe Sidebar,
|
|
# Windows path normalisation, Workspace Pulse deferred until pi trusts the
|
|
# project). The one coupling that matters runs the OPPOSITE way to the floor
|
|
# above: v0.9.0 renders the Sidebar as a separate split-layout child and
|
|
# therefore "raises the minimum supported Pi version to 0.84.0", which its
|
|
# peerDependencies do encode this time (`>=0.84.0`, up from `>=0.80.7`).
|
|
# Satisfied with room to spare by PI_VERSION 0.84.4 above — and note that both
|
|
# executable floors (scripts/smoke-test.sh, scripts/recreate-sanity-check.sh)
|
|
# compare with `sort -V`, so 0.10.0 >= 0.7.1 is evaluated correctly rather than
|
|
# as the string comparison that would read 0.10.0 as older than 0.7.1.
|
|
# Pairs deliberately with pi 0.84.4's own fullscreen selection-copy controls:
|
|
# atelier keeps Sidebar content out of the transcript selection, pi adds
|
|
# `fullscreenCopyOnSelect` + Ctrl+X for the selection itself.
|
|
#
|
|
# No `npm install` step, unlike pi-fork/pi-observational-memory/pi-studio:
|
|
# pi-atelier declares ZERO runtime dependencies (only peerDeps, satisfied by
|
|
# the baked pi) and has no build step — pi loads its TypeScript directly from
|
|
# the /opt checkout. Adding an install here would be a no-op that only costs
|
|
# build time.
|
|
ARG PI_ATELIER_REPO=https://github.com/michaelmjhhhh/pi-atelier.git
|
|
# v1.8.13: v0.10.0 -> v0.10.1. Refactor-only upstream (formatters, tests,
|
|
# panel identity); peerDependencies declare pi >=0.84.0, so it spans both the
|
|
# old and new pin. Included because it was already exercised: the pty matrix
|
|
# for PI_VERSION above ran atelier v0.10.1 against pi 0.85.1 and painted the
|
|
# sidebar identically to v0.10.0.
|
|
ARG PI_ATELIER_REF=v0.10.1
|
|
# Human-readable tag PI_ATELIER_REF was resolved from; recorded as a label.
|
|
ARG PI_ATELIER_VERSION=v0.10.1
|
|
|
|
RUN set -e && \
|
|
# git_fetch_ref: clone-equivalent helper that accepts EITHER a branch name
|
|
# OR a commit SHA as $ref. Uses `git fetch <ref> + checkout FETCH_HEAD`
|
|
# which (a) works with both name and SHA forms uniformly, and (b) defeats
|
|
# the registry-buildcache footgun when CI passes a resolved SHA. The
|
|
# earlier helper `git_clone_retry` (using `git clone --branch`) only
|
|
# worked with branch names — a SHA-resolved build-arg made `git clone
|
|
# --branch <40-char-SHA>` fail with "Remote branch not found". Surfaced
|
|
# in pi-devbox v1.0.0-rerun (run 374) 2026-06-10 and fixed by switching
|
|
# all four clones to git_fetch_ref. Both Gitea and GitHub allow fetching
|
|
# arbitrary commits by default (uploadpack.allowReachableSHA1InWant).
|
|
git_fetch_ref() { \
|
|
url="$1"; ref="$2"; dest="$3"; \
|
|
rm -rf "$dest"; mkdir -p "$dest"; \
|
|
git -C "$dest" init -q && git -C "$dest" remote add origin "$url" && \
|
|
for i in 1 2 3 4 5; do \
|
|
if git -C "$dest" fetch --depth 1 origin "$ref" && git -C "$dest" checkout -q FETCH_HEAD; then return 0; fi; \
|
|
echo "git fetch $url@$ref failed (attempt $i/5), retrying in $((i*5))s..."; \
|
|
sleep $((i*5)); \
|
|
done; \
|
|
return 1; \
|
|
} && \
|
|
if [ "${PI_VERSION}" = "latest" ]; then \
|
|
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \
|
|
else \
|
|
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
|
|
fi && \
|
|
pi --version && \
|
|
git_fetch_ref "${PI_TOOLKIT_REPO}" "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \
|
|
git_fetch_ref "${PI_EXTENSIONS_REPO}" "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
|
|
git_fetch_ref "${PI_FORK_REPO}" "${PI_FORK_REF}" /opt/pi-fork && \
|
|
git_fetch_ref "${PI_OBSMEM_REPO}" "${PI_OBSMEM_REF}" /opt/pi-observational-memory && \
|
|
git_fetch_ref "${PI_ATELIER_REPO}" "${PI_ATELIER_REF}" /opt/pi-atelier && \
|
|
(cd /opt/pi-fork && npm install --omit=dev --no-audit --no-fund) && \
|
|
(cd /opt/pi-observational-memory && npm install --omit=dev --no-audit --no-fund) && \
|
|
echo "pi-toolkit at $(cd /opt/pi-toolkit && git rev-parse --short HEAD)" && \
|
|
echo "pi-extensions at $(cd /opt/pi-extensions && git rev-parse --short HEAD)" && \
|
|
echo "pi-fork at $(cd /opt/pi-fork && git rev-parse --short HEAD)" && \
|
|
echo "pi-observational-memory at $(cd /opt/pi-observational-memory && git rev-parse --short HEAD)" && \
|
|
echo "pi-atelier at $(cd /opt/pi-atelier && git rev-parse --short HEAD) (${PI_ATELIER_VERSION})"
|
|
|
|
# ── Image-baked skill refresh: pi-extensions (Option 1 over Option 2) ──
|
|
# rootfs ships a VENDORED snapshot of the pi-extensions skill at
|
|
# /usr/local/share/pi-devbox/skills/pi-extensions/ (the "floor" — guarantees the
|
|
# skill is always in the image). The pi-extensions PACKAGE repo now co-locates
|
|
# the canonical skill under skill/, so here — after the pinned clone — we copy
|
|
# that over the snapshot. Result: a normal build ships the fresh, package-owned
|
|
# copy (pinned + recorded in the manifest via PI_EXTENSIONS_REF); a build whose
|
|
# ref predates the skill, or a fork pointing at a mirror without it, still ships
|
|
# the committed snapshot. The skill calls ./evaluate-extension-usage.py, so it
|
|
# is copied alongside. Idempotent and cache-safe (depends only on the clone).
|
|
RUN if [ -f /opt/pi-extensions/skill/SKILL.md ]; then \
|
|
cp /opt/pi-extensions/skill/SKILL.md \
|
|
/usr/local/share/pi-devbox/skills/pi-extensions/SKILL.md && \
|
|
if [ -f /opt/pi-extensions/skill/evaluate-extension-usage.py ]; then \
|
|
cp /opt/pi-extensions/skill/evaluate-extension-usage.py \
|
|
/usr/local/share/pi-devbox/skills/pi-extensions/evaluate-extension-usage.py ; \
|
|
fi && \
|
|
echo "refreshed pi-extensions skill from package @ $(cd /opt/pi-extensions && git rev-parse --short HEAD)" ; \
|
|
else \
|
|
echo "pi-extensions package has no skill/ at this ref — keeping vendored snapshot" ; \
|
|
fi
|
|
|
|
# ── pi-devbox awareness: append our pointer to the global AGENTS.md ──
|
|
# pi loads a SINGLE global instruction file (~/.pi/agent/AGENTS.md), which
|
|
# pi-toolkit's install.sh re-symlinks to /opt/pi-toolkit/pi-global-AGENTS.md on
|
|
# every container start. There is no second global slot, and that file is
|
|
# root-owned (not writable by the runtime user), so we compose at BUILD time:
|
|
# append the pi-devbox managed block to pi-toolkit's file here, after the clone.
|
|
# Idempotent via a marker grep so a rebuilt layer never double-appends. This
|
|
# makes every container proactively aware of the pi-devbox-environment skill;
|
|
# the snippet itself is gated (only fires when /usr/local/lib/pi-devbox exists).
|
|
RUN if [ -f /opt/pi-toolkit/pi-global-AGENTS.md ] && \
|
|
! grep -q 'pi-devbox:managed-block' /opt/pi-toolkit/pi-global-AGENTS.md; then \
|
|
printf '\n' >> /opt/pi-toolkit/pi-global-AGENTS.md && \
|
|
cat /usr/local/share/pi-devbox/pi-global-AGENTS.append.md >> /opt/pi-toolkit/pi-global-AGENTS.md && \
|
|
echo "appended pi-devbox block to pi-global-AGENTS.md" ; \
|
|
else \
|
|
echo "pi-devbox block already present or pi-global-AGENTS.md missing (skipped)" ; \
|
|
fi
|
|
|
|
# ── Optional: pi-studio (:latest-studio variant) ─────────────────────
|
|
# pi-studio (omaclaren/pi-studio) is a pi-package + theme providing a
|
|
# two-pane browser workspace: prompt/response editor, KaTeX/Mermaid live
|
|
# preview, and tmux-backed literate REPLs. Off by default; the studio
|
|
# variant sets INSTALL_STUDIO=true.
|
|
#
|
|
# Vendored to /opt/pi-studio and registered at container start by
|
|
# entrypoint-user.sh via `pi install /opt/pi-studio` — the SAME pattern
|
|
# as pi-fork / pi-observational-memory above. We deliberately do NOT run
|
|
# `pi install <git-url>` at build time: that writes into ~/.pi/agent,
|
|
# which is a named volume, so a build-time install collides with / is
|
|
# shadowed by the volume on first run. Vendoring to /opt (an image layer)
|
|
# + a runtime local-path install keeps it on the image and idempotent.
|
|
#
|
|
# No build step is needed: pi-studio ships its browser bundle prebuilt in
|
|
# git (client/studio-client.js) and pi loads index.ts directly; its
|
|
# package.json scripts are only test/typecheck. So we just fetch + install
|
|
# the 3 prod deps (@earendil-works/pi-ai, @sinclair/typebox, ws).
|
|
#
|
|
# PI_STUDIO_REF is CI-resolved to a commit SHA to defeat the registry-
|
|
# buildcache cache-hit footgun (see the PI_VERSION note above).
|
|
ARG INSTALL_STUDIO=false
|
|
ARG PI_STUDIO_REPO=https://github.com/omaclaren/pi-studio.git
|
|
ARG PI_STUDIO_REF=main
|
|
# PI_STUDIO_VERSION is the human-readable tag (e.g. v0.9.36) that PI_STUDIO_REF
|
|
# was resolved from; recorded as a label below for at-a-glance identification.
|
|
# Only meaningful for the studio variant (default `none` otherwise).
|
|
#
|
|
# v1.8.13: PI_STUDIO_REF stays floating on `main`, which resolves to
|
|
# v0.9.60-rc.0 — a RELEASE CANDIDATE, not the latest stable (v0.9.59).
|
|
# Adopted deliberately (JOA, 2026-09-06); recording the label is what makes
|
|
# that choice visible via `docker inspect` instead of someone discovering an
|
|
# RC in production later. Of the 15 commits since 0.9.55, one adds an OPT-IN
|
|
# network binding for the Studio server: that is network-facing and deserves
|
|
# its own audit before anyone enables it. The container default is unchanged —
|
|
# pi-studio still hard-binds 127.0.0.1 (see the STUDIO_EXPOSE bridge below),
|
|
# so adopting the RC does not by itself expose Studio to the LAN.
|
|
ARG PI_STUDIO_VERSION=v0.9.60-rc.0
|
|
RUN if [ "${INSTALL_STUDIO}" = "true" ]; then \
|
|
set -e; \
|
|
rm -rf /opt/pi-studio && mkdir -p /opt/pi-studio && \
|
|
git -C /opt/pi-studio init -q && \
|
|
git -C /opt/pi-studio remote add origin "${PI_STUDIO_REPO}" && \
|
|
ok=0; for i in 1 2 3 4 5; do \
|
|
if git -C /opt/pi-studio fetch --depth 1 origin "${PI_STUDIO_REF}" && \
|
|
git -C /opt/pi-studio checkout -q FETCH_HEAD; then ok=1; break; fi; \
|
|
echo "git fetch pi-studio@${PI_STUDIO_REF} failed (attempt $i/5), retrying in $((i*5))s..."; \
|
|
sleep $((i*5)); \
|
|
done; \
|
|
[ "$ok" = "1" ] && \
|
|
(cd /opt/pi-studio && npm install --omit=dev --no-audit --no-fund) && \
|
|
echo "pi-studio at $(cd /opt/pi-studio && git rev-parse --short HEAD)"; \
|
|
fi
|
|
|
|
# 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.
|
|
ENV STUDIO_PORT=8765
|
|
|
|
# ── Optional: Go toolchain ───────────────────────────────────────────
|
|
# Off by default; opt in for users who run Go tools inside the devbox.
|
|
ARG INSTALL_GO=false
|
|
ARG GO_VERSION=latest
|
|
RUN if [ "${INSTALL_GO}" = "true" ]; then \
|
|
GOARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
|
V="${GO_VERSION}" && \
|
|
if [ "$V" = "latest" ]; then \
|
|
V=$(curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/?mode=json" | \
|
|
awk -F'"' '/"version":/ { sub(/^go/,"",$4); print $4; exit }'); \
|
|
fi && \
|
|
[ -n "$V" ] && \
|
|
echo "Installing Go ${V}" && \
|
|
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/go${V}.linux-${GOARCH}.tar.gz" | tar -C /usr/local -xz && \
|
|
ln -s /usr/local/go/bin/go /usr/local/bin/go && \
|
|
ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt; \
|
|
fi
|
|
|
|
# ── Build provenance: OCI labels + on-disk build manifest ────────────
|
|
# Records exactly which pi version and companion-repo commits were baked
|
|
# into THIS image, so a published tag is self-describing and reproducible
|
|
# after the fact (CI logs rotate; a released image must not depend on
|
|
# them). Previously the resolved SHAs only ever reached the CI build log.
|
|
#
|
|
# These ARGs are declared LAST, immediately before the layer that uses
|
|
# them, so a changing BUILD_DATE / RELEASE_TAG / SOURCE_REVISION never
|
|
# invalidates the expensive pi-install / clone layers above.
|
|
ARG RELEASE_TAG=dev
|
|
ARG BUILD_DATE=
|
|
ARG SOURCE_REVISION=
|
|
# MEMPALACE_TOOLKIT_REF is consumed in Dockerfile.base; re-declared here
|
|
# only so its intended ref lands in the label set alongside the others.
|
|
ARG MEMPALACE_TOOLKIT_REF=main
|
|
# ── Vendored skill provenance ─────────────────────────────────────────
|
|
# The vendored mempalace SKILL.md is the ONLY baked artefact with no /opt
|
|
# clone behind it: its upstream (the skillset repo) is PRIVATE, so the
|
|
# image cannot clone it and CI cannot resolve its HEAD (see VENDORED.md).
|
|
# Consequence through v1.8.7: the snapshot was ANONYMOUS — nothing in the
|
|
# image or the repo recorded which skillset commit it was taken from, so
|
|
# the only staleness check available was a hand-maintained phrase canary in
|
|
# scripts/smoke-test.sh, which by construction can only detect "older than
|
|
# what I remembered to pin", never "older than skillset main".
|
|
#
|
|
# Recording the ref costs nothing and makes the question answerable. It is
|
|
# deliberately a plain ARG DEFAULT rather than a CI-resolved output:
|
|
# * the value is a fact about the committed snapshot, so it belongs in
|
|
# the tree next to it — not in a workflow that a local `docker build`
|
|
# never runs (same reasoning as MEMPALACE_VERSION living in
|
|
# Dockerfile.base rather than being duplicated in docker-publish.yml);
|
|
# * CI therefore needs NO new build-arg at any of its four
|
|
# Dockerfile.variant call sites (smoke, smoke-studio, build-variant,
|
|
# build-variant-studio) — a plumbing change that is easy to
|
|
# under-apply to only two of them;
|
|
# * and it needs no credential for a private repo.
|
|
# Bump it with scripts/vendor-mempalace-skill.sh, which refreshes the file
|
|
# and rewrites this line together, so the pair cannot drift apart by hand.
|
|
# This ARG lives in Dockerfile.variant ON PURPOSE: Dockerfile.base and
|
|
# rootfs/ are both hashed into base_tag, so recording provenance here costs
|
|
# 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=e9e09d95f92670536a199fc986dfa24d787f18d1
|
|
|
|
# Dockerfile.base sets description="pi-devbox — base image (variant-independent)"
|
|
# and every variant INHERITS it, so both published images used to advertise
|
|
# themselves on Docker Hub as the base image. A LABEL cannot branch on
|
|
# INSTALL_STUDIO, so the description arrives as a build-arg: CI passes the
|
|
# variant-specific string (see docker-publish.yml), and the default below keeps
|
|
# a plain `docker build -f Dockerfile.variant` honest rather than misleading.
|
|
ARG IMAGE_TITLE="pi-devbox"
|
|
ARG IMAGE_DESCRIPTION="pi-devbox — development container for the pi coding agent"
|
|
|
|
LABEL org.opencontainers.image.version="${RELEASE_TAG}" \
|
|
org.opencontainers.image.revision="${SOURCE_REVISION}" \
|
|
org.opencontainers.image.created="${BUILD_DATE}" \
|
|
org.opencontainers.image.title="${IMAGE_TITLE}" \
|
|
org.opencontainers.image.description="${IMAGE_DESCRIPTION}" \
|
|
description="${IMAGE_DESCRIPTION}" \
|
|
se.jordbo.pi-devbox.pi-version="${PI_VERSION}" \
|
|
se.jordbo.pi-devbox.pi-toolkit-ref="${PI_TOOLKIT_REF}" \
|
|
se.jordbo.pi-devbox.pi-extensions-ref="${PI_EXTENSIONS_REF}" \
|
|
se.jordbo.pi-devbox.pi-fork-ref="${PI_FORK_REF}" \
|
|
se.jordbo.pi-devbox.pi-obsmem-ref="${PI_OBSMEM_REF}" \
|
|
se.jordbo.pi-devbox.pi-atelier-ref="${PI_ATELIER_REF}" \
|
|
se.jordbo.pi-devbox.pi-atelier-version="${PI_ATELIER_VERSION}" \
|
|
se.jordbo.pi-devbox.mempalace-toolkit-ref="${MEMPALACE_TOOLKIT_REF}" \
|
|
se.jordbo.pi-devbox.pi-studio-ref="${PI_STUDIO_REF}" \
|
|
se.jordbo.pi-devbox.pi-studio-version="${PI_STUDIO_VERSION}" \
|
|
se.jordbo.pi-devbox.skillset-snapshot-ref="${SKILLSET_SNAPSHOT_REF}"
|
|
|
|
# The manifest is written from GROUND TRUTH — the actual checked-out HEAD
|
|
# of each /opt clone and the live `pi --version` — not merely the intended
|
|
# build-args. That way it also exposes a clone that silently resolved to
|
|
# something other than the requested ref. pi-studio is present only in the
|
|
# studio variant (JSON null otherwise).
|
|
RUN set -e; \
|
|
mkdir -p /etc/pi-devbox; \
|
|
rev() { git -C "$1" rev-parse HEAD 2>/dev/null || echo "unknown"; }; \
|
|
PI_V="$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')"; \
|
|
# mempalace CORE (the PyPI package behind the MCP tools) is installed in
|
|
# Dockerfile.base via `uv tool install`, so no /opt clone reveals it and
|
|
# until v1.8.6 the manifest could not answer "which palace shipped here?" —
|
|
# a palace bug could not be correlated to an image, which is precisely the
|
|
# correlation this file exists to provide. Read from the INSTALLED BINARY,
|
|
# not from ARG MEMPALACE_VERSION, per the ground-truth rule above: that is
|
|
# what catches an install which resolved to something other than the pin.
|
|
# `mempalace --version` prints "MemPalace 3.7.1" — NAME-PREFIXED, unlike
|
|
# pi's bare "0.84.2" — hence the $NF pick rather than a straight read. The
|
|
# leading-digit test then rejects usage/error text (a renamed flag prints a
|
|
# usage block) and degrades to JSON null, so this can never fail the build.
|
|
MP_V="$(mempalace --version 2>/dev/null | head -n1 | tr -d '\r' | awk '{print $NF}')"; \
|
|
case "$MP_V" in [0-9]*) MP_CORE="\"${MP_V}\"" ;; *) MP_CORE='null' ;; esac; \
|
|
STUDIO_REV='null'; \
|
|
if [ -d /opt/pi-studio/.git ]; then STUDIO_REV="\"$(rev /opt/pi-studio)\""; fi; \
|
|
# The vendored skill snapshot's fingerprint is MEASURED here, not passed
|
|
# in as a build-arg, per the ground-truth rule above: SKILLSET_SNAPSHOT_REF
|
|
# is a CLAIM about which skillset commit the file came from, while this
|
|
# hash is what the image actually ships. Recorded together they let any
|
|
# reader with the skillset checked out — which on this fleet is every
|
|
# host, since all four compose stacks mount it — verify the claim at
|
|
# RUNTIME, without CI ever needing access to the private repo. Degrades
|
|
# to JSON null rather than failing the build if the directory is absent;
|
|
# the smoke assertion is what turns that into a loud failure.
|
|
#
|
|
# Hashes the whole DIRECTORY, not just SKILL.md: a single-file hash
|
|
# answers "did this one file change", not "is the live copy the same
|
|
# skill" — a live checkout that added or edited a SIBLING file (a
|
|
# reference/ doc, a helper script) would still report "identical to
|
|
# baked snapshot" against a file-only hash. pi-extensions already ships
|
|
# two files for exactly this reason (SKILL.md + evaluate-extension-usage.py),
|
|
# so this is not a hypothetical. Deterministic over `find | sort`, never
|
|
# readdir order: relative paths + per-file sha256, folded into one hash.
|
|
# pi-devbox-version mirrors this exact pipeline over the live directory so
|
|
# the two sides are comparable — if you change this, change that too.
|
|
tree_sha256() { \
|
|
( cd "$1" && find . -type f -print | LC_ALL=C sort | xargs -r sha256sum ) 2>/dev/null | sha256sum | cut -d' ' -f1; \
|
|
}; \
|
|
SKILL_SNAP='null'; \
|
|
_snap_dir=/usr/local/share/pi-devbox/skills/mempalace; \
|
|
if [ -d "$_snap_dir" ] && [ -n "$(find "$_snap_dir" -type f -print -quit)" ]; then \
|
|
SKILL_SNAP="\"$(tree_sha256 "$_snap_dir")\""; \
|
|
fi; \
|
|
{ \
|
|
echo '{'; \
|
|
echo " \"release_tag\": \"${RELEASE_TAG}\","; \
|
|
echo " \"build_date\": \"${BUILD_DATE}\","; \
|
|
echo " \"source_revision\": \"${SOURCE_REVISION}\","; \
|
|
echo " \"pi_version\": \"${PI_V}\","; \
|
|
# Sibling of pi_version, NOT a member of components{}: that map holds git
|
|
# SHAs and `pi-devbox-version` renders it with .value[0:12], which would
|
|
# silently truncate a longer version string.
|
|
echo " \"mempalace_version\": ${MP_CORE},"; \
|
|
# Siblings, NOT members of components{}, for two independent reasons:
|
|
# that map means "HEAD of a clone present in this image" and the
|
|
# skillset is not cloned here (calling it a component would be a
|
|
# lie a future reader would act on), and `pi-devbox-version` renders
|
|
# every components{} value with .value[0:12] — which would truncate
|
|
# a 64-hex sha256 into something that looks like a short commit.
|
|
# Named `_tree_sha256`, not `_sha256`: it measures every file under the
|
|
# vendored skill directory, not one file — see tree_sha256() above.
|
|
echo " \"skillset_snapshot_ref\": \"${SKILLSET_SNAPSHOT_REF}\","; \
|
|
echo " \"skillset_snapshot_tree_sha256\": ${SKILL_SNAP},"; \
|
|
echo " \"components\": {"; \
|
|
echo " \"pi-toolkit\": \"$(rev /opt/pi-toolkit)\","; \
|
|
echo " \"pi-extensions\": \"$(rev /opt/pi-extensions)\","; \
|
|
echo " \"pi-fork\": \"$(rev /opt/pi-fork)\","; \
|
|
echo " \"pi-observational-memory\": \"$(rev /opt/pi-observational-memory)\","; \
|
|
echo " \"pi-atelier\": \"$(rev /opt/pi-atelier)\","; \
|
|
echo " \"mempalace-toolkit\": \"$(rev /opt/mempalace-toolkit)\","; \
|
|
echo " \"pi-studio\": ${STUDIO_REV}"; \
|
|
echo " }"; \
|
|
echo '}'; \
|
|
} > /etc/pi-devbox/build-manifest.json; \
|
|
echo "── build manifest ──"; cat /etc/pi-devbox/build-manifest.json
|
|
|
|
# WORKDIR / ENTRYPOINT / CMD inherited from base.
|