# pi-devbox — variant image # # FROMs a base- 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=:base-` 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//` 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". # # v1.9.4: HELD at 0.85.1 while 0.86.1 and 0.87.0 exist upstream. 0.87.0's # changelog: "Removed the inherited `shouldStopAfterTurn` agent option. Use # `finishTurn` and return `{ action: "end" }` instead" (no issue number on # that line); 0.86.0 moved provider stream inputs to `TranscriptContext`, with # system prompts read from `context.messages`. pi-observational-memory 3.1.4 # (the `master` ref baked below) still uses both the old option and # `AgentContext.systemPrompt` in its observer, reflector and dropper workers — # measured 2026-09-22 in src/agents/*/agent.ts, both the baked 3.1.3 tree and # upstream master 3.1.4: `shouldStopAfterTurn` 1 per worker (3), `finishTurn` # 0, `systemPrompt` 1 per worker. Its peerDependencies are `*`, # so nothing at install time would refuse; it would break at runtime (turn # caps ignored, workers losing their specialised prompts). Upstream tracks it # as pi-observational-memory #82 with fix PR #83 (opened 2026-09-21, mergeable, # not merged at this writing). Bump pi and pi-obsmem TOGETHER once #83 has # shipped in a release. The other extensions were checked against the 0.86.0 # and 0.87.0 breaking lists and are clean: ssh-controlmaster's `user_bash` # handler already returns `undefined | { operations }` (0.86.0 fail-closed # contract) and its registerTool calls spread the built-in tools so they # carry parameter schemas (#9300). 0.86.1 as an intermediate is untested and # not worth the pty matrix for a stop that #83 will make moot. # # v1.9.5: 0.85.1 -> 0.87.1, and pi-obsmem moves off `master` to a pinned SHA in # the SAME commit, because neither is safe alone. 0.87.0 REMOVED # `shouldStopAfterTurn`, which 3.1.4 still used; the pinned tip uses # `finishTurn`, which does not exist before 0.87.0. So 3.1.4 + 0.87.1 silently # ignores turn caps, and the new tip + 0.85.1 breaks the workers outright — the # pair only works together, exactly as the v1.9.4 note predicted. # MEASURED 2026-10-01 across src/agents/{observer,reflector,dropper}/agent.ts, # deliberately the same counting method as the v1.9.4 audit above so the two # numbers are comparable: # 3.1.4 (e7d77dc): shouldStopAfterTurn 3, finishTurn 0, systemPrompt 3 # 731c3d4 (pinned) : shouldStopAfterTurn 0, finishTurn 3, systemPrompt 0 # i.e. all three workers migrated, and the 0.86.0 `AgentContext.systemPrompt` # reads are gone too. #82/#83 merged 2026-09-23. # WHY A SHA AND NOT A TAG, departing from the v1.9.4 instruction to wait for a # release: #83 is merged, but the newest obsmem tag is STILL 3.1.4, cut # 2026-09-20 — before the merge. Upstream tags slowly while moving master often # (e7d77dc -> 1529e14 -> 731c3d4 in the nine days to 2026-10-01), so waiting for # a tag means holding pi indefinitely. A full SHA keeps the one property # `master` does not have: rebuilding this tag later produces the SAME image. # v1.9.5 SKIPPED 0.99.x and 1.0.0 for lack of evidence. v1.10.1 went and got the # evidence instead of waiting for obsmem to mention a version, because "no issue # names 1.0" is an absence of a statement, not a measurement. # # v1.10.1: 0.87.1 -> 1.0.0. MEASURED 2026-10-02 against the published npm # tarballs for 0.87.1 and 1.0.0, unpacked side by side: # 1. NO `### Breaking Changes` SECTION IN 1.0.0 AT ALL. The changelog's # breaking sections belong to 0.87.0, 0.86.0, 0.84.3, 0.84.0, 0.83.0, # 0.80.8, 0.80.7 and 0.75.0 — none to 0.88+..1.0.0. The major is a # milestone (fullscreen default, leaner codemode), not an API break. The # last break that touched us was 0.87.0's `shouldStopAfterTurn` removal, # which v1.9.5 already absorbed. # 2. `finishTurn` IS STILL IN 1.0.0's dist, so the obsmem SHA pinned below # keeps the API it migrated to. (`shouldStopAfterTurn`: absent from both # 0.87.1 and 1.0.0, as expected after its 0.87.0 removal.) # 3. EVERY pi.* API our extensions call exists in 1.0.0's dist — all 8 of # registerTool, registerCommand, registerFlag, getFlag, on, exec, # sendMessage, sendUserMessage, extracted from mempalace.ts, the # pi-extensions tree and obsmem's src/. # 4. engines.node is `>=22.19.0` on both; the image ships 24.x. # THE 0.99.2 CHANGE THAT LOOKED FATAL AND IS NOT: from 0.99.2 the DEFAULT MCP # `exposure` is `codemode`, i.e. such tools are "neither declared to the model # nor listed" and must be found with `searchTools()`. That would gut the # MemPalace protocol if MemPalace were a builtin-MCP server. It is not: both # mempalace.ts and mcp-loader.ts run their OWN MCP client and surface 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 # neither an `mcpServers` block (builtin, exposure-governed) nor an `mcp` block. # Extension-registered tools are declared like built-ins, so `exposure` cannot # reach them. # WHAT IS NOT PROVEN, stated so acceptance does not mistake this for cleared: # grepping dist shows the SYMBOLS survive, not that their SIGNATURES are # unchanged — necessary, not sufficient. 1.0.0 is also four days of upstream old # (published 2026-10-01T19:15Z) and neither obsmem nor atelier has a commit # naming it. Acceptance must prove the obsmem workers CAP TURNS (peerDeps are # `*`, so a mismatch is silent) and that the atelier sidebar PAINTS. # USER-VISIBLE BEHAVIOUR CHANGE, decided rather than inherited: 1.0.0 makes the # TUI fullscreen by default, which replaces the terminal's normal scrollback. # Upstream's default is ADOPTED on purpose and no `tuiMode` is baked here, so # the image follows pi instead of pinning the fleet to either mode. The revert # is documented (README -> "Terminal UI mode"): `"tuiMode": "regular"` in # ~/.pi/agent/settings.json, `pi --tui-mode regular` for one session, or the # same key in a project's .pi/settings.json. ARG PI_VERSION=1.0.0 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 # PINNED to a full 40-char SHA as of v1.9.5, not `master` — see the PI_VERSION # note above for the measurement and the reasoning. Moves together with # PI_VERSION by necessity, not by convention. The width matters: 731c3d4 is the # same commit, but check-doc-drift.sh recognises a literal SHA only via a # 40-char match (SHA40), so a short pin would fall through to its # branch-or-tag lookup, fail, and downgrade that component's drift check to a # silent SKIP — a pin that reads fine and is no longer verified. ARG PI_OBSMEM_REF=731c3d49288580f4d79cabdbfbc0d16b34db0f41 # 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. # # v1.9.4: v0.10.1 -> v0.10.3 (both v0.10.2 and v0.10.3 released 2026-09-22). # Fixes only per the release notes: sidebar text/borders preserved beside # inline images (#53), transcript images hidden while capturing overlays are # open, Workspace Pulse skips redundant HEAD/diff when nothing tracked changed # (#61), sidebar height from row counts (#59), git/usage scans suspended while # disabled, Display Revert + Undo ordering. Checked before bumping: package.json # at v0.10.3 still declares zero runtime dependencies and no build script (so # the no-`npm install` reasoning above holds) and peerDependencies are still # pi >=0.84.0, so it still spans the pinned 0.85.1. 13 commits v0.10.1..v0.10.3, # all under src/ tests/ docs/ scripts/ plus metadata; no entry-point move. # # v1.10.1: v0.10.3 -> v0.13.0, closing the two-minor gap v1.9.5 flagged as the # residual risk of the pi bump. peerDependencies are UNCHANGED at pi >=0.84.0 # across v0.10.3, v0.12.1 and v0.13.0 — still a FLOOR, so still not evidence of # anything; the floor is satisfied by 1.0.0 either way. What was actually # checked, 2026-10-02: # - v0.13.0 CARRIES ITS OWN BREAKING CHANGE, unrelated to pi: "The package # entry point now exports only the sidebar contribution protocol # (`registerSidebarPanel`, guards, size limits, event types). The internal # registry and layout helpers are no longer exported." Harmless HERE only # because nothing of ours imports them: a grep for `pi-atelier` and # `registerSidebarPanel` across mempalace-toolkit, pi-devbox, # pi-extensions, pi-fork, pi-observational-memory and pi-studio returns # ZERO matches in all six. atelier is a leaf here — it registers its own # sidebar and no one consumes its API. # - Settings churn in the gap, checked against our own tree: v0.12.0 REMOVED # the `showSessionActions` setting (0 references here) and migrated the # Control Center shortcut Alt+A -> F6 (0 references here, so no doc goes # stale). `showSidebarAgent` / `showSidebarTodos`, the only atelier keys # README names, are both still documented in v0.13.0's README. # - The TUI internals atelier patches (`TuiMainScreen`, `renderLayoutFrame`) # are both still present in pi 1.0.0's dist, and atelier's changelog shows # it has handled fullscreen vs regular renderers explicitly since 0.84. # - Residual: v0.13.0 is SAME-DAY upstream (2026-10-02). Acceptance proves # the sidebar PAINTS with the two-sided check from 0.84.4/0.85.1 that can # tell "loaded" from "silently absent" — "no crash" is not the test. ARG PI_ATELIER_REF=v0.13.0 # Human-readable tag PI_ATELIER_REF was resolved from; recorded as a label. ARG PI_ATELIER_VERSION=v0.13.0 RUN set -e && \ # git_fetch_ref: clone-equivalent helper that accepts EITHER a branch name # OR a commit SHA as $ref. Uses `git fetch + 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; \ } && \ # prune_foreign_natives: npm 11 (shipped with Node 24) installs EVERY optional # platform package of a native dependency, not just the one matching the host. # TWO families are affected in this image, and BOTH have been measured — add a # family here only after measuring it, never by widening the pattern on a hunch: # # @esbuild/ 26 dirs, 284 MB (found first, v1.9.1) # @mariozechner/clipboard- 11 dirs, 12 MB per site, 10 MB foreign # # esbuild declares those with os/cpu constraints, but npm 11 ignores them and # ALSO ignores --os/--cpu and an npmrc carrying os=/cpu= (all three measured). # So prune explicitly, keeping only the host platform, computed from # `node -p process.arch` so one line stays correct on amd64 and arm64. # Measured on pi-fork's tree: npm 10.9.8 -> 165 MB, npm 11.19.0 -> 449 MB, # and the 165 MB figure reproduces what v1.9.0's predecessor actually shipped. # # WHY THE CLIPBOARD FAMILY WAS ADDED (2026-09-11): v1.9.1 pruned @esbuild only # and still shipped +131 MB compressed over v1.8.14. That residual was # attributed by listing the PUBLISHED arm64 layer tarballs straight from the # registry (there is no docker CLI inside the container, so `docker history` # was not available): +110 MB /root/.npm/_cacache (purged below) and +21 MB of # clipboard platform packages across the two install sites — 131 MB total, so # the delta is now fully accounted for with no unexplained remainder. # # Keeping linux-$arch-{gnu,musl} is deliberate: clipboard's napi-rs loader # tries ./.node then the platform package, per platform in try/catch, and # chooses gnu vs musl at runtime from its own isMusl() probe — so both host-arch # branches must survive. The musl package is a 420-byte stub, i.e. free. The # bare wrapper `@mariozechner/clipboard` has no hyphen suffix and therefore # cannot match the regex below. Verified on arm64 against a copy of the real # tree before this was written: after pruning to those two, # require('@mariozechner/clipboard') still loads and exports all 18 functions. # esbuild likewise still compiles TS via transformSync at both install sites. # This removes dead weight, not function. # # MUST run in the SAME layer as the npm installs above: deleting in a later RUN # leaves the bytes in this layer and shrinks the image by nothing. # NOTE the single backslash in -printf '%f\n': Docker passes '\\n' through # verbatim, so v1.9.1's doubled version printed a mangled "li ux-arm64" # (find emitted a literal backslash, then `tr` translated the n out of the # name). Confirmed from the published image's own recorded created_by. prune_foreign_natives() { \ arch="$(node -p process.arch)"; \ find /usr/lib/node_modules /opt -type d -regex '.*/@esbuild/[^/]+' \ ! -name "linux-$arch" -prune -exec rm -rf {} + ; \ find /usr/lib/node_modules /opt -type d -regex '.*/@mariozechner/clipboard-[^/]+' \ ! -name "clipboard-linux-$arch-gnu" ! -name "clipboard-linux-$arch-musl" \ -prune -exec rm -rf {} + ; \ echo "native platform dirs kept: $(find /usr/lib/node_modules /opt -type d \( -regex '.*/@esbuild/[^/]+' -o -regex '.*/@mariozechner/clipboard-[^/]+' \) -printf '%f\n' 2>/dev/null | sort | uniq -c | tr '\n' ' ')"; \ } && \ # purge_build_caches: the build's own download caches are NOT free — they land # in whichever layer created them. Measured on the published v1.9.1 arm64 # variant layer: root/.npm/_cacache was 145.2 MB of a 401.9 MB layer (35.2 MB # in v1.8.14), the single biggest item in the +131 MB residual, because npm 11 # caches every platform tarball it fetched — including the ones just pruned. # Nothing at runtime reads it: the build runs as root, the container runs as # `developer` with its own cache under $HOME (and $HOME/.pi is a volume). # DELIBERATELY NOT purged here: /tmp/node-compile-cache (1.3 MB, written by # `pi --version` below). The manifest RUN at the end of this file calls # `pi --version` again, so deleting it here only relocates those bytes into # that layer instead of removing them from the image — measured, not assumed: # today the manifest layer is 128 kB precisely because it finds the cache warm. purge_build_caches() { \ npm cache clean --force >/dev/null 2>&1 || true; \ rm -rf /root/.npm; \ } && \ 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) && \ prune_foreign_natives && \ purge_build_caches && \ 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})" # ── git: let the unprivileged user read the root-owned /opt clones ────────── # The clones above (and /opt/mempalace-toolkit from the base, /opt/pi-studio in # the studio variant) are root-owned; the container runs as `developer`. Since # git 2.35.2 (CVE-2022-24765) any git command in a repo owned by another user # fails with "dubious ownership" — so `git -C /opt/pi-extensions rev-parse` # returns nothing, `install.sh` used to abort on it, and acceptance checks that # read a baked ref via git silently measured "" (v1.9.4 first-boot run: one # false FAIL from exactly this). Listing the paths (not `*`) keeps the check # meaningful for everything else, e.g. the virtiofs-mounted /workspace. # Entries for paths absent in a variant (pi-studio) are inert. RUN for d in pi-toolkit pi-extensions pi-fork pi-observational-memory pi-atelier \ mempalace-toolkit pi-studio; do \ git config --system --add safe.directory "/opt/${d}"; \ done && \ git config --system --get-all safe.directory # ── Image-baked skill refresh: pi-extensions (Option 1 over Option 2) ── # rootfs ships a VENDORED snapshot of the pi-extensions skill at # /usr/local/share/pi-devbox/skills/pi-extensions/ (the "floor" — guarantees the # 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 ` 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 — READ THIS BEFORE REASONING ABOUT WHICH pi-studio SHIPS. Neither # default below survives a CI build. `resolve-versions` in # .gitea/workflows/docker-publish.yml passes BOTH as build-args (studio_ref and # studio_tag), and it deliberately selects the newest STABLE semver tag: its # filter is `^v?[0-9]+\.[0-9]+\.[0-9]+$`, which excludes pre-releases. So a # PUBLISHED v1.8.13 studio image contains pi-studio v0.9.59 (commit 9eed84f, # = refs/tags/v0.9.59^{}), NOT the v0.9.60-rc.0 that `main` currently points at # (658536f). The `main` default here only applies to a local `docker build` # that passes no studio args. # # That upstream-tag-over-main choice is intentional and documented at the # resolve step: pi-studio keeps tagging every version but stopped publishing # GitHub Releases at v0.5.55 and pushes freely to main, so pinning main risked # baking half-finished commits that land after a tag. # # Corrected here on 2026-09-06 after reading the run-639 resolve-versions # output: the v1.8.13 audit had recorded "RC adopted deliberately" and set this # ARG to v0.9.60-rc.0, which was measured at the wrong layer — a Dockerfile # default cannot answer "what will CI publish?" when CI overrides it. Left at # `none` rather than pinned to a tag, because a hardcoded pre-release here goes # stale the moment main moves and would re-tell the same lie to the next reader. # Consequence worth keeping: the RC's opt-in Studio network binding is NOT in # any published v1.8.13 image, so it needs no audit for this release. ARG PI_STUDIO_VERSION=none RUN if [ "${INSTALL_STUDIO}" = "true" ]; then \ set -e; \ # Same prune + cache purge as the main install RUN — see the comments there. # They have to be redefined because shell functions do not survive across # layers, and they have to run in THIS layer because pi-studio's npm install # happens here: deleting in a later RUN would leave the bytes in this layer # and shrink nothing. pi-studio pulls its own pi-coding-agent copy, so it is # a third ~274 MB site on top of the two in the non-studio variant — and its # npm install refills /root/.npm, which the main RUN emptied in ITS layer. prune_foreign_natives() { \ arch="$(node -p process.arch)"; \ find /usr/lib/node_modules /opt -type d -regex '.*/@esbuild/[^/]+' \ ! -name "linux-$arch" -prune -exec rm -rf {} + ; \ find /usr/lib/node_modules /opt -type d -regex '.*/@mariozechner/clipboard-[^/]+' \ ! -name "clipboard-linux-$arch-gnu" ! -name "clipboard-linux-$arch-musl" \ -prune -exec rm -rf {} + ; \ echo "native platform dirs kept: $(find /usr/lib/node_modules /opt -type d \( -regex '.*/@esbuild/[^/]+' -o -regex '.*/@mariozechner/clipboard-[^/]+' \) -printf '%f\n' 2>/dev/null | sort | uniq -c | tr '\n' ' ')"; \ }; \ purge_build_caches() { \ npm cache clean --force >/dev/null 2>&1 || true; \ rm -rf /root/.npm; \ }; \ 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) && \ prune_foreign_natives && \ purge_build_caches && \ 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=e9e45f7acdde490c3b5d24ce5f508bff8785c2c7 # 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; \ # ── WHICH pi-extensions skill copy actually shipped ── # Closes the silent-fallback hole. The refresh step above is guarded by # `[ -f /opt/pi-extensions/skill/SKILL.md ]`, so a build whose clone predates # the co-located skill (or a fork pointing at a mirror without it) keeps the # vendored floor and still succeeds — GREEN, with nothing anywhere recording # that a snapshot shipped instead of the package copy. Measured 2026-09-10: # the floor had been stale since 2026-07-30, so that fallback would have # shipped a six-week-old skill silently. The floor is fresh now and gated by # the skill-floor CI job, but "the fallback is currently harmless" is not the # same as "you can tell which copy you got", and only the second survives. # # MEASURED, never claimed, per the ground-truth rule above: the branch # condition is re-derived from the same test the refresh step used, and the # served bytes are then compared against the clone. A build-arg could not # express this at all, since the outcome depends on the clone's contents. # package served bytes == the clone's skill/ (the normal path) # vendored-floor the clone has no skill/ at this ref (fallback shipped) # divergent both exist but differ — e.g. the clone ships SKILL.md but # not evaluate-extension-usage.py, so the served directory is # a MIX of package and floor. Worth its own value: it is the # one state neither of the other two names honestly. # No OCI label mirrors this, deliberately: LABEL cannot take a value computed # in a RUN, and a label fed from an ARG would be exactly the claim-not- # measurement this block exists to avoid. _px_dir=/usr/local/share/pi-devbox/skills/pi-extensions; \ PIEXT_SRC='null'; PIEXT_HASH='null'; \ if [ -d "$_px_dir" ] && [ -n "$(find "$_px_dir" -type f -print -quit)" ]; then \ PIEXT_HASH="\"$(tree_sha256 "$_px_dir")\""; \ if [ -f /opt/pi-extensions/skill/SKILL.md ]; then \ if [ "$(tree_sha256 "$_px_dir")" = "$(tree_sha256 /opt/pi-extensions/skill)" ]; then \ PIEXT_SRC='"package"'; \ else \ PIEXT_SRC='"divergent"'; \ fi; \ else \ PIEXT_SRC='"vendored-floor"'; \ fi; \ 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 " \"pi_extensions_skill_source\": ${PIEXT_SRC},"; \ echo " \"pi_extensions_skill_tree_sha256\": ${PIEXT_HASH},"; \ 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.