Compare commits

..

13 Commits

Author SHA1 Message Date
Joakim Persson 53b41cd76b smoke: assert the pi stage $HOME-relative, and add a smoke_only dispatch
Lint / actionlint (push) Successful in 16s
Lint / hadolint (push) Successful in 13s
Publish Docker Image / resolve-versions (push) Successful in 10s
Publish Docker Image / base-decide (push) Successful in 16s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke-studio (push) Successful in 5m20s
Publish Docker Image / smoke (push) Successful in 14m40s
Publish Docker Image / build-variant-studio (push) Successful in 21m51s
Publish Docker Image / build-variant (push) Successful in 15m59s
Publish Docker Image / update-description (push) Successful in 6s
Publish Docker Image / promote-base-latest (push) Successful in 17s
v1.8.0 never shipped: smoke (67/68) and smoke-studio (70/71) each failed the
same single assertion, so build-variant and everything downstream skipped and
latest stayed on v1.7.0.

The assertion was wrong, not the product. It grepped for a literal
stage=/home/developer/.mempalace/pi-stage/, but run() invokes

  docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd"

and neither Dockerfile sets USER or ENV HOME — the published base image config
has no HOME at all; it is normally set by entrypoint-user.sh, which
--entrypoint="" skips on purpose. So the assertion executed as root with
HOME=/root, mempalace-pi-session correctly resolved
stage=/root/.mempalace/pi-stage/... (the stage is $HOME-relative by design), and
the literal grep could never match under any circumstances.

The tell was one line below in the log: the sibling assertion "pi stage follows
MEMPALACE_PALACE_PATH" PASSED, because it sets the variable explicitly and never
consults HOME. Default fails + explicit passes = wrong HOME, not broken staging.

Now asserts the invariant actually intended — the stage sits beside the resolved
palace, sharing its lifetime — which is user-independent:

  case "$stage" in "stage=$HOME/.mempalace/pi-stage/"*) exit 0 ;; *) exit 1 ;; esac

$HOME is expanded by the container's own shell, so it holds as root, as
developer, or under any future user. Verified all four cases against the real
bin/mempalace-pi-session by extracting the committed assertion bodies and
running them under sh -c: virgin HOME -> exit 0; HOME=/home/developer -> exit 0;
MEMPALACE_PI_STAGE pinned to a .cache path -> exit 1 (the regression this
assertion exists to catch still fails it); developer-identity companion -> 0.

Added that companion assertion, "pi stage is palace-adjacent for the developer
user", which covers the deployment-specific path properly by SUPPLYING
HOME=/home/developer rather than assuming it.

Why this took a release to surface: docker-publish.yml triggers on push tags v*
only. The assertion was added on a push to main (7c00dd6), where only lint.yml
runs, so v1.8.0 was its first execution ever. Any smoke assertion written
outside a release was unvalidated until a release consumed it.

New workflow_dispatch input smoke_only probes/builds the base, runs both smoke
jobs against HEAD, and stops before publishing. Implemented as
`if: inputs.smoke_only != 'true'` on build-variant and build-variant-studio,
deliberately WITHOUT always() so the implicit needs-succeeded gate survives and
a red smoke still blocks a release. promote-base-latest and update-description
already require build-variant success, so they skip on their own. On a tag push
inputs is unset and null != 'true' is true, so releases are unaffected.

Finally, run() no longer discards output. A red  carried zero diagnostic
weight: explaining this one-line failure needed a CI-log dig plus a registry
image-config inspection, when the container had already printed the answer.
Failures now show the last lines of output (guarded with `if`, not a trailing
`&&`, which would abort under set -e), and the stage assertions echo the
resolved stage and the HOME they saw to stderr — invisible while they pass.
2026-08-15 12:29:32 +02:00
Joakim Persson 29b62093f0 v1.8.0: bump pi 0.84.1 → 0.84.2 and pi-atelier v0.8.0 → v0.8.1, audited
Publish Docker Image / resolve-versions (push) Successful in 12s
Lint / actionlint (push) Successful in 23s
Publish Docker Image / base-decide (push) Successful in 8s
Lint / hadolint (push) Successful in 1m20s
Publish Docker Image / build-base (push) Successful in 41m34s
Publish Docker Image / smoke (push) Failing after 4m41s
Publish Docker Image / build-variant (push) Has been skipped
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
Publish Docker Image / smoke-studio (push) Failing after 8m22s
Publish Docker Image / build-variant-studio (push) Has been skipped
pi 0.84.2 closes the Amazon Bedrock tool-argument poison pill that v1.6.4
recorded as "Not fixed upstream". pi-ai 0.84.2 adds a recursive
sanitizeBedrockDocument() and applies it at exactly the site that entry named
(dist/api/bedrock-converse-stream.js, line 692 -> 704; upstream PR #7882):

  - toolUse: { ..., input: c.arguments },
  + toolUse: { ..., input: sanitizeBedrockDocument(c.arguments) },

It strips object members whose key is the empty string, recursing through arrays
and nested objects. It runs at request-build time, so it covers the live turn and
a resume alike: a session already bricked by an empty-key tool argument now
replays instead of dying on a Bedrock ValidationException. pi-session-repair is
therefore no longer the recovery path on this image -- it stays useful for older
images and for inspection, since the fix sanitises what is sent, not what was
recorded.

Bumping PI_VERSION is the ONLY way to get that fix: pi publishes an
npm-shrinkwrap.json, so pi 0.84.1 pins pi-ai to exactly 0.84.1 even though its
package.json range (^0.84.1) would admit 0.84.2. Transitive upstream fixes never
leak into this image.

pi-atelier v0.8.1 is the matching companion -- both sides changed fullscreen
input handling within three days. Its only code change (src/split-pane.ts) stops
atelier writing its own 1002h/1006h pair around a sidebar resize under pi's
fullscreen renderer, which had been tearing down the mouse reporting pi itself
enabled and leaving the wheel dead.

Audited against the surfaces the pin policies name:
  - session .jsonl: identical migrateV1ToV2/migrateV2ToV3 ladder
  - node engine floor: unchanged >=22.19.0 (image ships 22.23.2)
  - atelier's three private couplings all intact in pi-tui 0.84.2 --
    class TuiAltScreen extends TuiBase (detected by constructor NAME, so a
    rename would fail silently), inputListeners still `new Set()` at the same
    line 103, own render(width) descriptor still present
  - pi's mouse sequences byte-identical between 0.84.1 and 0.84.2
  - atelier metadata unchanged: engines >=22.19.0, peerDeps >=0.80.7, zero
    runtime deps, so the "no npm install step" note holds

Not proven by execution: CI smoke does not drive the TUI, and 0.84.2 adds a
focused fullscreen search overlay that also participates in input handling. The
pairing is reasoned from the diffs. Worth an alt+a plus a sidebar resize and
wheel scroll in fullscreen on first use.
2026-08-15 00:22:08 +02:00
Joakim Persson cbd7cf5c67 entrypoint: announce the "remote palace, no inbox" skip instead of vanishing
Lint / hadolint (push) Successful in 8s
Lint / actionlint (push) Successful in 1m8s
When MEMPALACE_REMOTE_URL is set but MEMPALACE_PI_SSH_TARGET is not, the feeder
has nowhere to ship staged transcripts, so skipping is correct. The problem was
that the branch was a bare `:` AND the skip happens before the subshell that
writes ~/.pi/agent/mempalace-catchup.log -- so a container in that state
contributed nothing to the palace and left no artifact at all, not even an empty
log, to explain why. It is indistinguishable from a healthy run that had nothing
to file, which is the worst property a memory system can have: the failure looks
exactly like success.

Surfaced while flipping the first client onto the shared palace, where this is
the single most likely way to end up quietly memory-less -- the palace is the
only thing that survives a container recreate.

The notice goes to both the container start output (docker logs) and the log
path anyone debugging looks at first. It names both variables, says what still
works (MCP tools read/write the shared palace; only this container's own
conversations go nowhere), and points at MEMPALACE_FEED=0 for anyone who meant
it -- "HTTPS first, mining later" is a documented interim state, so the notice
has to be silenceable without being ignorable.

Guarded against becoming a startup failure. mkdir -p in a branch that previously
touched no filesystem is a new risk: an unwritable ~/.pi (root-owned volume, a
classic Docker accident) fails under set -e and would abort the entire
entrypoint. It now degrades to stdout-only. Verified all five paths by executing
the extracted block under `set -euo pipefail`: the trap prints and writes the
log; unwritable ~/.pi still exits 0 and still prints; MEMPALACE_FEED=0 stays
completely silent (no message, no file); and both normal-remote and local mode
still background the feeder with no notice.

Two smoke assertions guard against a regression to the silent no-op. They test
the entrypoint as shipped in the image rather than behaviour, because this branch
only runs at container start and a `docker run` one-shot cannot reach it.

entrypoint-user.sh is COPY'd in Dockerfile.base, so this rides the base rebuild
the Unreleased feeder work already needs.
2026-08-13 16:30:55 +02:00
Joakim Persson 7c00dd6001 mempalace: drop the stage ENV pin, fix the shared-server compose
Lint / actionlint (push) Successful in 21s
Lint / hadolint (push) Successful in 29s
The feeder now defaults to <palace-root>/pi-stage upstream, so pinning
MEMPALACE_PI_STAGE into ~/.pi here is unnecessary -- and was actively wrong. It
created a second convention that could still diverge from the palace: keep the
devbox-palace volume, drop devbox-pi-config, and a scoped `mempalace sync`
prunes every conversation drawer, because dedup keys on the staged path. Both
the ENV and the entrypoint export are gone; a comment explains why adding one
back re-introduces the split it was meant to fix.

docker-compose.mempalace.yml was broken on mempalace 3.6.0 in both directions:
  - `--host 0.0.0.0` with no token in the environment makes the server refuse
    to start, crash-looping under `restart: unless-stopped`.
  - Supply a token and the healthcheck's unauthenticated `tools/list` POST 401s,
    marking a perfectly healthy server unhealthy forever.
Now the token is required via ${MEMPALACE_REMOTE_TOKEN:?...} so it fails fast at
`docker compose up` with a readable message, and the healthcheck probes the
deliberately token-free /healthz. The "no authentication of its own" security
note has been stale since 3.6.0 and is replaced with the actual posture
(bearer token + Host pin + Origin allowlist), including why browser-shaped auth
must not be put in front of it.

Dockerfile.base: mempalace-pi-session symlinked onto PATH, with a build-time
`--help` check so a broken feeder fails the image build rather than the first
session.

smoke-test: assert the stage resolves beside the palace (default, and following
$MEMPALACE_PALACE_PATH) instead of asserting the removed ENV pin. The two
behavioural guards -- a synthetic session that must be captured, an abandoned
one that must not be -- are unchanged.

.env.example: recommend `mempalace serve` on the docker0 gateway rather than
`mempalace-mcp --transport http --host 0.0.0.0`, with the two binds to avoid.
2026-08-12 17:04:14 +02:00
Joakim Persson 7649d53f3b .env.example: say why GIT_USER_* must be set
Lint / hadolint (push) Successful in 30s
Lint / actionlint (push) Successful in 59s
Both keys shipped empty with no explanation, so they read as optional. They
are not: ~/.gitconfig is not on a persistent mount, so with these unset every
repo in the container fails "Author identity unknown" on first commit after
every recreate — and an agent asked to commit then infers an identity from
git log and picks the wrong one. Observed today across three repos on one
machine (two wrong-address commits, plus older `pi <pi@devbox>` fossils from
earlier sessions guessing the same way).

Also records that the address is per-machine (corporate vs private), so it
belongs in the per-machine .env rather than a skill or a repo-local override.
2026-08-09 15:42:29 +02:00
joakimp ade58131d6 docs(readme): use mkdir -p instead of install -d for the peer's ~/.ssh
Lint / hadolint (push) Successful in 13s
Lint / actionlint (push) Successful in 23s
`install -d -m 700 ~/.ssh` was correct but wrong for the audience. This snippet
gets pasted onto an arbitrary peer — a NAS, a router, a BSD box — and `install`
is not in POSIX, so it is not guaranteed to be there. `mkdir -p` + `chmod` is
POSIX, present everywhere, and self-evidently idempotent to a reader deciding
whether it is safe to run on a machine that already has keys.

Behaviour was checked rather than assumed, on both coreutils and BSD/macOS
`install`: on an existing ~/.ssh it exits 0 and leaves authorized_keys intact
in content and mode, but it also silently chmods the directory (755 -> 700).
Desirable here, yet invisible in a doc — which is the second reason to prefer
the explicit two-step form, and why the surrounding text now states outright
that re-running is safe on an already-configured peer: mkdir -p is a no-op, the
chmods only tighten, and appending never touches keys already listed.

Verified the whole block end-to-end against a fresh HOME and one with a
pre-existing 755 ~/.ssh and an older key: 700/600 in both cases, older key
preserved, new line appended.
2026-08-08 01:24:51 +02:00
joakimp ffd44ad9cf docs(readme): say where the authorized_keys line actually goes
Lint / actionlint (push) Successful in 13s
Lint / hadolint (push) Successful in 13s
Step 2 of "Giving the container its own key for a peer" showed a correctly
narrowed authorized_keys line but never named the file it belongs in, and never
said which account's — the only mention of authorized_keys was an aside 35
lines further down about revoking one key per machine. A reader following the
steps had a public key, a line to construct, and nowhere to put it.

Now explicit: append to ~/.ssh/authorized_keys of the account named as `User`
in step 3, via a heredoc that shows `>>` rather than `>` (the truncation that
revokes every other key on that account), with `install -d -m 700 ~/.ssh` and
`chmod 600` so the file is created correctly the first time.

Adds the three failure modes that make a good key look broken, none of which
announce themselves on the client: the options prefix must be on the same
physical line as the key (a wrapped paste is the usual culprit), `ssh-copy-id`
cannot add that prefix at all so the line must be appended by hand, and sshd's
StrictModes silently ignores authorized_keys when the home directory, ~/.ssh or
the file is group- or world-writable — reporting it only in the peer's own log.
2026-08-08 01:17:48 +02:00
joakimp 43cd6e22f2 v1.7.0: bundle pi-atelier at a pinned tag; pin pi to an audited 0.84.1
Publish Docker Image / resolve-versions (push) Successful in 9s
Lint / actionlint (push) Successful in 15s
Lint / hadolint (push) Successful in 13s
Publish Docker Image / base-decide (push) Successful in 8s
Publish Docker Image / build-base (push) Successful in 41m22s
Publish Docker Image / smoke-studio (push) Successful in 5m16s
Publish Docker Image / smoke (push) Successful in 7m31s
Publish Docker Image / build-variant-studio (push) Successful in 18m31s
Publish Docker Image / build-variant (push) Successful in 27m18s
Publish Docker Image / promote-base-latest (push) Successful in 11s
Publish Docker Image / update-description (push) Successful in 12s
Two changes that belong together, because the first is what makes the second
dangerous to get wrong.

pi-atelier (TUI sidebar + status rail) is now vendored to /opt/pi-atelier at
PI_ATELIER_REF=v0.8.0 and registered by entrypoint-user.sh — the pi-fork /
pi-observational-memory / pi-studio pattern, deliberately NOT
`pi install npm:pi-atelier`, which writes into ~/.pi/npm-global on the config
volume where it shadows the image and pins nothing. Unlike its siblings it gets
no `npm install`: atelier declares zero runtime deps (peerDeps only, satisfied by
the baked pi) and has no build step, so pi loads its TypeScript straight from the
checkout via package.json `pi.extensions`.

pi is no longer resolved to npm `latest` at build time. The pin lives in
Dockerfile.variant and CI reads it from there, so a local `docker build` and a CI
release ship the same versions by construction. The pin is a CHECKPOINT, NOT A
FREEZE: bumping stays a one-line change; what stops is *unreviewed* adoption of
whatever shipped that morning, in the same build that then gets tagged and
published. CI fails when a pin is not concrete or not actually published on npm,
and warns — never adopts — when npm latest moves ahead, naming what to re-check.

Why this pairing needed care: pi-atelier 0.6.0/0.7.0 wrap pi's PRIVATE TUI
renderer, and under pi 0.84 that wrapper recurses — pi hangs at startup burning
CPU with no error. Upstream fixed the recursion in 0.7.1 and restored the
non-overlapping split in 0.7.2; 0.8.0 is additive on top. atelier's own
peerDependencies still say >=0.80.7, which does not express that floor, so
nothing in npm metadata could have warned us. The floor is therefore encoded as
an executable rule — pi >= 0.84 => pi-atelier >= 0.7.1 — asserted in both
smoke-test.sh (build time) and recreate-sanity-check.sh (after a real recreate),
verified against a 4x4 version matrix.

Existing volumes needed migration, not just vendoring: a hand-installed
`npm:pi-atelier` entry is counted as already-registered by the entrypoint guard,
so every existing volume would have kept its unpinned npm copy — and a 0.6.x copy
next to pi 0.84 is exactly the startup hang. The entrypoint now drops that one
exact string (settings.json.bak.atelier.<ts> backup, distinct prefix so it cannot
clobber the template merge's backup in the same second) and lets the pinned /opt
copy register. Tested against a real settings.json: only that entry removed,
other packages and all keys intact, idempotent, and unparseable JSON leaves the
file untouched. DEVBOX_ATELIER=0 opts out entirely — in the entrypoint rather
than via `pi uninstall`, because this component's failure mode is "pi will not
start", which cannot be repaired from inside pi.

0.84.1 was audited for this release, not merely adopted: theme/TUI changes are
additive, the session format is unchanged (CURRENT_SESSION_VERSION = 3 in both
0.83.0 and 0.84.1 with an identical migrateV1ToV2/migrateV2ToV3 ladder, so
existing transcripts are neither migrated nor at risk and pi-session-repair stays
valid), and the Node engine floor is unmoved at >=22.19.0. CI resolves the
atelier tag to its PEELED commit SHA — atelier uses annotated tags, so the
unpeeled ref is a tag object, not a commit; pi-studio's lightweight tags never
exposed that distinction.

Also: docs for overriding the read-only ~/.ssh/config from the container —
container-only keys in ~/.ssh-local, hardened authorized_keys, the fact that
`from=` must allow the HOST's addresses because container egress is NAT'd through
it, and the macOS-only-keyword trap (`UseKeychain` is fatal to Linux OpenSSH and
takes out dssh/pi --ssh while the host keeps working). Corrects two claims in
"Naming LAN peers": ssh-lan.conf is not ProxyJump-only, and first-time creation
does need one restart because the Include is emitted only when the file already
exists at start.
2026-08-07 21:34:41 +02:00
pi 62a2a79b1c ci(lint): correct the rationale comment — runner contention was overstated
Lint / hadolint (push) Successful in 15s
Lint / actionlint (push) Successful in 4m29s
The previous commit justified excluding tag pushes partly on runner contention:
that the duplicate lint run stole one of two self-hosted runners from the release
build. Measured, that is false for THIS repo — pi-devbox lint runs take 0.3-0.9
min (ids 529/531/532/533) against a 77.6 min release build (id=530). I imported
the claim from opencode-devbox, where actionlint apt-installs shellcheck inside
the container and takes 6-15 min, so contention there is real.

The change stands on its actual merits: duplicate lint of an identical tree, and
release-run discovery ambiguity (the substantive one — it is what made the naive
"first run matching refs/tags/<tag>" rule pick lint over the publish run).

No functional change; comment only.
2026-08-04 18:08:28 +02:00
pi f20b2a7926 ci(lint): don't re-lint on tag pushes
Lint / hadolint (push) Successful in 9s
Lint / actionlint (push) Has been cancelled
`on: push:` with no filter also fires on refs/tags/v*, which is duplicate work:
the tagged tree was already linted when that same commit was pushed to main
(v1.6.4 sha e86e5df linted as id=529 on main, then again as id=531 on the tag).

Two costs beyond the wasted run. It consumed one of the two self-hosted runners
while the release pipeline wanted both for its parallel multi-arch variant
builds; and it made release-run discovery ambiguous, since the newest-first runs
listing puts the tag-ref lint run above the publish run.

`branches: ['**']` keeps the documented intent exactly — lint fires early on
every branch push and PR, rather than only at tag time — while excluding tag
refs. docker-publish.yml is untouched and still tag-scoped.
2026-08-04 18:06:34 +02:00
pi 66a19aa394 docs(agents): how to find the release run (tag push fires two workflows)
The release-day checklist said "Watch CI" without saying which run, and the
Gitea API example used limit=5. Both are traps, because a tag push produces
TWO runs here: lint.yml has a bare `push:` trigger so it fires on the tag ref
as well, and docker-publish.yml fires on v*. The runs listing is newest-first
and the lint run sorts ABOVE the publish run, so "first run matching
refs/tags/<tag>" picks lint reliably. Verified against the real API for v1.6.4:

  id=531  #104  lint.yml@refs/tags/v1.6.4           <- picked by the naive rule
  id=530  #103  docker-publish.yml@refs/tags/v1.6.4 <- the actual release build
  id=529  #102  lint.yml@refs/heads/main            <- same sha, already linted

Lint goes green in minutes while the image is still building, so watching it
makes a release look finished before anything is published. limit=5 compounds
it: the publish run is already at position 4 of 5 in the current listing.

Documents: head_sha-filtered discovery with limit=20; the jobs endpoint takes
the internal id, never the run_number (silently returns another run's jobs);
and the correct ci-release-watcher config for this repo — EXPECT_WORKFLOW,
the studio tag pair, base-latest as existence-only, and CRITICAL_JOBS with
build-variant-studio spelled out (job names are matched exactly, and the
skill's default omits it) while excluding promote-base-latest, which
legitimately skips on a base cache hit.

Smoke-gate detail in step 5 is retained.
2026-08-04 18:06:34 +02:00
pi 572430237f Bump mempalace pin 3.5.0 → 3.6.0 (lockstep with opencode-devbox v2.9.0)
Lint / actionlint (push) Successful in 38s
Lint / hadolint (push) Successful in 50s
3.6.0 (2026-07-17, PyPI latest) is additive/reliability only: secure
`mempalace serve` remote mode, optional Milvus backend, atomic KG
supersede(), conversation chronology, mining exclusions, plus recovery and
locking fixes.

Reviewed for MCP tool-schema changes before bumping — that being the exact
regression class this pin exists to catch, after an unpinned install once
swept in the broken 3.3.x/3.4.0 diary_write schema. There are none, and
nothing touches diary_write, so the perl workaround removed in v1.2.2 stays
removed.

Two fixes matter for how this image uses mempalace: read-only mode now covers
checkpoint + delete_by_source in _MUTATING_TOOLS (#1930), and agent
attribution is preserved in mempalace_checkpoint (#2023/#2034) — the latter
because the diary protocol relies on per-agent attribution.

Also adds a CHANGELOG Unreleased block that backfills the per-variant image
description labels (1fd524e), pushed after the v1.6.4 tag without an entry.

Not tagged: more changes are queued for the next release. Pushing to main
triggers only lint.yml (actionlint + hadolint) — the image build/publish
workflow is tag-only. Verified clean against the CI-pinned hadolint 2.14.0.
2026-08-04 16:01:39 +02:00
joakimp 1fd524e7fb Give each variant its own image description label
Lint / actionlint (push) Successful in 14s
Lint / hadolint (push) Successful in 12s
Both published variants inherited Dockerfile.base's
description="pi-devbox — base image (variant-independent)", so v1.6.4 and
v1.6.4-studio both advertised themselves on Docker Hub as the base image —
misleading, and useless for telling the two apart.

A LABEL cannot branch on INSTALL_STUDIO, so the text arrives as a build-arg:
CI passes a variant-specific string (interpolating RELEASE_TAG, PI_VERSION and,
for studio, STUDIO_TAG), and the Dockerfile default keeps a bare local
`docker build -f Dockerfile.variant` honest instead of misleading.

Also sets org.opencontainers.image.title/description alongside the legacy bare
`description` key, so Hub and OCI-aware tooling both see it. ARGs stay in the
last-declared block, so the label layer is still the only thing invalidated.

Verified: hadolint 2.14.0 (the CI-pinned version) clean on both Dockerfiles;
workflow YAML parses; check-workflow-shell.sh passes. Lands on the next release.
2026-07-30 07:59:41 +02:00
13 changed files with 1423 additions and 56 deletions
+61 -3
View File
@@ -12,16 +12,59 @@ SSH_KEY_PATH=~/.ssh
# ── MemPalace memory (local by default) ─────────────────────────── # ── MemPalace memory (local by default) ───────────────────────────
# By default the mempalace.ts extension spawns a LOCAL mempalace-mcp stdio # By default the mempalace.ts extension spawns a LOCAL mempalace-mcp stdio
# server (palace at ~/.mempalace). Uncomment the devbox-palace volume in # server (palace at ~/.mempalace). Uncomment the devbox-palace volume in
# docker-compose.yml to persist it across container recreation. # docker-compose.yml to persist it across container recreation — that one
# volume now covers the mined conversation transcripts too, since the pi and
# opencode feeders stage inside the palace root (<palace-root>/pi-stage), so
# the staged files and the palace dedup keys pointing at them cannot be
# separated.
# #
# To instead share ONE MemPalace across containers/harnesses (pi + opencode # To instead share ONE MemPalace across containers/harnesses (pi + opencode
# + native), set the URL below. When set, the extension connects over HTTP # + native), set the URL below. When set, the extension connects over HTTP
# and NO local mempalace-mcp is spawned; the devbox-palace volume is then # and NO local mempalace-mcp is spawned; the devbox-palace volume is then
# irrelevant. MEMPALACE_REMOTE_TOKEN, if set, is sent as a bearer token. # irrelevant. MEMPALACE_REMOTE_TOKEN, if set, is sent as a bearer token.
# Serve it with: mempalace-mcp --transport http --host 0.0.0.0 --port 8765 #
# MEMPALACE_REMOTE_URL=http://mempalace.lan:8765/mcp # Serve it with: mempalace serve --host 172.17.0.1 --port 8765
#
# NOT `mempalace-mcp --transport http --host 0.0.0.0`: `serve` is the turnkey
# wrapper that mints/keeps a bearer token (0600, passed via env so it stays out
# of `ps`) and can terminate TLS. Two binds to avoid:
# 0.0.0.0 - exposes the palace to the whole LAN.
# 127.0.0.1 - behind a tunnel this 403s every proxied request (the Host pin
# is only enforced on loopback binds) AND silently starts with
# no token at all, since auto-minting is gated on the bind being
# non-loopback. Bind the docker0 gateway: reachable from the host
# and its containers (so a newt/proxy container works), not from
# the LAN. Set MEMPALACE_MCP_HTTP_TOKEN explicitly server-side.
# MEMPALACE_REMOTE_URL=https://mempalace.example.com/mcp
# MEMPALACE_REMOTE_TOKEN= # MEMPALACE_REMOTE_TOKEN=
# ── MemPalace: automatic capture of pi sessions ───────────────────────
# The mempalace.ts extension feeds this container's pi transcripts into the
# palace by itself: on session_shutdown, and on a debounced agent_settled so a
# crash loses at most one window rather than the whole session. The entrypoint
# also runs a catch-up at container start, which is the only thing that can
# recover transcripts after a hard kill (no handler runs on SIGKILL).
# Nothing below is required for the local-palace case; the defaults work.
#
# MEMPALACE_FEED=0 # disable automatic capture entirely
# MEMPALACE_FEED_DEBOUNCE_MS=600000 # min gap between mid-session feeds (10 min)
# MEMPALACE_FEED_WING=wing_conversations
#
# REMOTE PALACE ONLY (MEMPALACE_REMOTE_URL set above): the palace is on another
# host, and `mempalace_mine` resolves its source path in the SERVER process, so
# the server cannot see this container's transcripts. The feeder therefore
# rsyncs its staged exports into a per-device inbox on the palace host and asks
# the server to mine its own local copy. Without MEMPALACE_PI_SSH_TARGET the
# feeder is skipped (a remote palace with no inbox has nothing to mine).
# MEMPALACE_PI_SSH_TARGET where to rsync to, as user@host:path
# MEMPALACE_PI_REMOTE_PATH what that inbox is called ON THE SERVER — must be
# the container path if the server runs in Docker
# (see docker-compose.mempalace.yml)
# MEMPALACE_PI_DEVICE inbox subdirectory for this machine (default: hostname)
# MEMPALACE_PI_SSH_TARGET=user@palace-host:/srv/mempalace-feed
# MEMPALACE_PI_REMOTE_PATH=/data/feed
# MEMPALACE_PI_DEVICE=
# ── LAN access from the container (host-OS-agnostic) ───────────────── # ── LAN access from the container (host-OS-agnostic) ─────────────────
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't # On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
# reach the host's directly-attached LAN peers by default. The entrypoint # reach the host's directly-attached LAN peers by default. The entrypoint
@@ -43,7 +86,22 @@ SSH_KEY_PATH=~/.ssh
# the host, so bare `dssh user@<ip>` works on whatever LAN you're roaming on. # the host, so bare `dssh user@<ip>` works on whatever LAN you're roaming on.
# DEVBOX_LAN_AUTOJUMP_PRIVATE=0 # DEVBOX_LAN_AUTOJUMP_PRIVATE=0
# ── pi-atelier (TUI sidebar) ─────────────────────────────────────────
# The image vendors pi-atelier at a pinned, audited tag and registers it on
# container start. Set to 0 to opt out: the entrypoint then removes it from
# pi's `packages[]` instead of registering it. This lives here rather than
# being a `pi uninstall` because a broken TUI extension's failure mode is
# "pi will not start", which you cannot fix from inside pi.
# DEVBOX_ATELIER=1
# ── Git Configuration ──────────────────────────────────────────────── # ── Git Configuration ────────────────────────────────────────────────
# Set BOTH. If unset, every repo inside the container fails with
# "Author identity unknown" on first commit, and an agent asked to commit
# will guess an identity from git log — often the wrong one. The e-mail is
# per-machine (work machines use the corporate address, personal machines the
# private one), so it belongs in this per-machine .env, never in a skill or a
# repo-local override. Consumed by entrypoint-user.sh -> ~/.gitconfig, which is
# NOT persistent across container recreate — this file is the source of truth.
GIT_USER_NAME= GIT_USER_NAME=
GIT_USER_EMAIL= GIT_USER_EMAIL=
+94 -6
View File
@@ -18,6 +18,14 @@ name: Publish Docker Image
# 5. build-variant multi-arch push of latest + vX.Y.Z tags. # 5. build-variant multi-arch push of latest + vX.Y.Z tags.
# 6. promote-base-latest re-tag base-<hash> → base-latest with `crane copy`. # 6. promote-base-latest re-tag base-<hash> → base-latest with `crane copy`.
# 7. update-description patch Docker Hub description. # 7. update-description patch Docker Hub description.
#
# Note the trigger: `push: tags: v*` (plus workflow_dispatch). Nothing here runs
# on a push to main, so a smoke assertion added outside a release is UNVALIDATED
# until the next tag — which is exactly how v1.8.0 shipped a broken assertion
# written three days earlier (it asserted a literal /home/developer stage path,
# while `run` executes `docker run --entrypoint=""` as root with HOME=/root).
# The `smoke_only` dispatch input exists to close that gap: it runs steps 1-4
# against HEAD and stops before anything is published.
on: on:
push: push:
@@ -33,6 +41,10 @@ on:
description: 'Update latest aliases (default true for tag-push, false for manual test runs)' description: 'Update latest aliases (default true for tag-push, false for manual test runs)'
required: false required: false
default: 'false' default: 'false'
smoke_only:
description: 'Build base + run both smoke jobs against HEAD, then stop. Publishes nothing. Use to validate smoke assertions without cutting a tag.'
required: false
default: 'false'
concurrency: concurrency:
group: ${{ github.workflow }}-${{ github.ref }} group: ${{ github.workflow }}-${{ github.ref }}
@@ -136,8 +148,16 @@ jobs:
extensions_ref: ${{ steps.resolve.outputs.extensions_ref }} extensions_ref: ${{ steps.resolve.outputs.extensions_ref }}
studio_ref: ${{ steps.resolve.outputs.studio_ref }} studio_ref: ${{ steps.resolve.outputs.studio_ref }}
studio_tag: ${{ steps.resolve.outputs.studio_tag }} studio_tag: ${{ steps.resolve.outputs.studio_tag }}
atelier_ref: ${{ steps.resolve.outputs.atelier_ref }}
atelier_tag: ${{ steps.resolve.outputs.atelier_tag }}
mempalace_toolkit_ref: ${{ steps.resolve.outputs.mempalace_toolkit_ref }} mempalace_toolkit_ref: ${{ steps.resolve.outputs.mempalace_toolkit_ref }}
steps: steps:
# Needed since v1.7.0: the pi version and the pi-atelier tag are now
# PINNED IN Dockerfile.variant and read from it here, so this job has to
# see the repo. Keeping the pins in the Dockerfile (rather than duplicated
# in this workflow) means a local `docker build` and CI ship the same
# versions by construction, and a bump is one reviewable line.
- uses: actions/checkout@v4
- name: Resolve pi version + companion refs - name: Resolve pi version + companion refs
id: resolve id: resolve
shell: bash shell: bash
@@ -157,13 +177,35 @@ jobs:
fi fi
} }
# pi version from npm (catthehacker/ubuntu:act-latest's npm is not # ── pi version: from the PIN, not from npm `latest` ───────────
# reliably on PATH in act_runner job containers, so query directly). # Until v1.7.0 this followed npm `latest`, which meant every release
PI_VERSION=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version' 2>/dev/null || true) # silently adopted whatever pi had shipped that morning — unaudited —
if ! printf '%s' "${PI_VERSION:-}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then # in the same build that then got tagged and published. A pi minor
echo "::error::Could not resolve pi version from npm (got '${PI_VERSION:-<empty>}')." # can move the TUI/renderer internals that pi-atelier wraps (0.84 vs
# atelier 0.6.0: startup hang, sustained CPU) or the session `.jsonl`
# format that pi-session-repair parses. The pin makes adoption an
# explicit, reviewable act; the drift warning below makes it a
# prompt rather than a surprise.
PI_VERSION=$(sed -n 's/^ARG PI_VERSION=\([^[:space:]]*\).*/\1/p' Dockerfile.variant | head -n1)
if ! printf '%s' "${PI_VERSION:-}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::ARG PI_VERSION in Dockerfile.variant is not a concrete version (got '${PI_VERSION:-<empty>}'). CI refuses to build from a floating pi version — see the pin policy comment above that ARG."
exit 1 exit 1
fi fi
# The pin must actually exist on npm: catches a typo, an unpublished
# version, or one yanked after we audited it — at resolve time, with
# a clear message, instead of as an `npm install` failure mid-build.
PI_PUBLISHED=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/${PI_VERSION}" | jq -r '.version // empty' 2>/dev/null || true)
if [ "${PI_PUBLISHED:-}" != "${PI_VERSION}" ]; then
echo "::error::Pinned pi version ${PI_VERSION} is not published on npm (registry returned '${PI_PUBLISHED:-<empty>}'). Fix ARG PI_VERSION in Dockerfile.variant."
exit 1
fi
# Informational only — a newer pi must never be adopted implicitly.
# `|| true`: a transient registry failure must not fail a release
# whose version is already pinned and verified above.
PI_NPM_LATEST=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version // empty' 2>/dev/null || true)
if [ -n "${PI_NPM_LATEST:-}" ] && [ "${PI_NPM_LATEST}" != "${PI_VERSION}" ]; then
echo "::warning::pi ${PI_NPM_LATEST} is published; this build ships the audited pin ${PI_VERSION}. To adopt it: read the upstream CHANGELOG for every version in between (TUI/theme API, session .jsonl format, extension loader, Node engine), re-check pi-atelier's floor, then bump ARG PI_VERSION in Dockerfile.variant and note the audit in CHANGELOG.md."
fi
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT" echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
# pi-fork / pi-observational-memory (GitHub) → commit SHAs. # pi-fork / pi-observational-memory (GitHub) → commit SHAs.
@@ -176,6 +218,26 @@ jobs:
echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT" echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT"
echo "obsmem_ref=${OBSMEM_REF}" >> "$GITHUB_OUTPUT" echo "obsmem_ref=${OBSMEM_REF}" >> "$GITHUB_OUTPUT"
# pi-atelier → the PINNED TAG's commit SHA. Unlike fork/obsmem
# (which track a branch head) atelier wraps pi's private TUI
# renderer, so its version is pinned in Dockerfile.variant and read
# from there; we only resolve tag → SHA, for reproducibility and to
# defeat the cache-hit footgun. Never floats to a branch.
ATELIER_TAG=$(sed -n 's/^ARG PI_ATELIER_REF=\([^[:space:]]*\).*/\1/p' Dockerfile.variant | head -n1)
if ! printf '%s' "${ATELIER_TAG:-}" | grep -qE '^v?[0-9]+\.[0-9]+\.[0-9]+$'; then
echo "::error::ARG PI_ATELIER_REF in Dockerfile.variant is not a semver tag (got '${ATELIER_TAG:-<empty>}'). pi-atelier must stay pinned to a tag — see the floor note above that ARG."
exit 1
fi
ATELIER_LS=$(git ls-remote --tags "https://github.com/michaelmjhhhh/pi-atelier.git" || true)
# Peeled ^{} line first (annotated tags), then the direct ref.
ATELIER_REF=$(printf '%s\n' "$ATELIER_LS" | awk -v t="refs/tags/${ATELIER_TAG}^{}" '$2==t{print $1}')
if [ -z "$ATELIER_REF" ]; then
ATELIER_REF=$(printf '%s\n' "$ATELIER_LS" | awk -v t="refs/tags/${ATELIER_TAG}" '$2==t{print $1}')
fi
require_sha PI_ATELIER_REF "$ATELIER_REF"
echo "atelier_ref=${ATELIER_REF}" >> "$GITHUB_OUTPUT"
echo "atelier_tag=${ATELIER_TAG}" >> "$GITHUB_OUTPUT"
# pi-toolkit / pi-extensions (Gitea) → commit SHAs. Gitea API # pi-toolkit / pi-extensions (Gitea) → commit SHAs. Gitea API
# requires auth even for public-repo commit listing. # requires auth even for public-repo commit listing.
TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \ TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \
@@ -230,7 +292,8 @@ jobs:
echo "studio_ref=${STUDIO_REF}" >> "$GITHUB_OUTPUT" echo "studio_ref=${STUDIO_REF}" >> "$GITHUB_OUTPUT"
echo "studio_tag=${STUDIO_TAG}" >> "$GITHUB_OUTPUT" echo "studio_tag=${STUDIO_TAG}" >> "$GITHUB_OUTPUT"
echo "Resolved PI_VERSION=${PI_VERSION}" echo "Resolved PI_VERSION=${PI_VERSION} (pinned in Dockerfile.variant; npm latest is ${PI_NPM_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}"
echo "Resolved PI_TOOLKIT_REF=${TOOLKIT_REF}, PI_EXTENSIONS_REF=${EXTENSIONS_REF}" 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 PI_STUDIO_REF=${STUDIO_REF} (pi-studio ${STUDIO_TAG})"
@@ -357,6 +420,8 @@ jobs:
PI_TOOLKIT_REF=${{ needs.resolve-versions.outputs.toolkit_ref }} PI_TOOLKIT_REF=${{ needs.resolve-versions.outputs.toolkit_ref }}
PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }} PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }}
MEMPALACE_TOOLKIT_REF=${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }} MEMPALACE_TOOLKIT_REF=${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
PI_ATELIER_REF=${{ needs.resolve-versions.outputs.atelier_ref }}
PI_ATELIER_VERSION=${{ needs.resolve-versions.outputs.atelier_tag }}
RELEASE_TAG=smoke RELEASE_TAG=smoke
SOURCE_REVISION=${{ github.sha }} SOURCE_REVISION=${{ github.sha }}
- name: Smoke test (amd64) - name: Smoke test (amd64)
@@ -417,6 +482,8 @@ jobs:
PI_STUDIO_REF=${{ needs.resolve-versions.outputs.studio_ref }} PI_STUDIO_REF=${{ needs.resolve-versions.outputs.studio_ref }}
PI_STUDIO_VERSION=${{ needs.resolve-versions.outputs.studio_tag }} PI_STUDIO_VERSION=${{ needs.resolve-versions.outputs.studio_tag }}
MEMPALACE_TOOLKIT_REF=${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }} MEMPALACE_TOOLKIT_REF=${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
PI_ATELIER_REF=${{ needs.resolve-versions.outputs.atelier_ref }}
PI_ATELIER_VERSION=${{ needs.resolve-versions.outputs.atelier_tag }}
RELEASE_TAG=smoke-studio RELEASE_TAG=smoke-studio
SOURCE_REVISION=${{ github.sha }} SOURCE_REVISION=${{ github.sha }}
- name: Smoke test studio (amd64) - name: Smoke test studio (amd64)
@@ -427,6 +494,14 @@ jobs:
# ── Phase 4: multi-arch publish ───────────────────────────────────── # ── Phase 4: multi-arch publish ─────────────────────────────────────
build-variant: build-variant:
needs: [base-decide, smoke, resolve-versions] needs: [base-decide, smoke, resolve-versions]
# A `smoke_only` dispatch stops the pipeline here: base is probed/built and
# both smoke jobs run, but nothing is published. Deliberately NOT wrapped in
# always() — specifying `if:` keeps the implicit "all needs succeeded" gate,
# so a failing smoke still blocks the release. On a tag push `inputs` is
# unset, and `null != 'true'` is true, so releases are unaffected.
# promote-base-latest and update-description need build-variant to have
# succeeded, so they skip on their own — no extra guard required.
if: inputs.smoke_only != 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
@@ -471,6 +546,8 @@ jobs:
TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }} TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }}
EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }} EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }}
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }} MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
ATELIER_REF: ${{ needs.resolve-versions.outputs.atelier_ref }}
ATELIER_TAG: ${{ needs.resolve-versions.outputs.atelier_tag }}
run: | run: |
set -euo pipefail set -euo pipefail
TAG_FLAGS=() TAG_FLAGS=()
@@ -490,6 +567,10 @@ jobs:
--build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \ --build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \ --build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \ --build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
--build-arg "PI_ATELIER_REF=${ATELIER_REF}" \
--build-arg "PI_ATELIER_VERSION=${ATELIER_TAG}" \
--build-arg "IMAGE_TITLE=pi-devbox" \
--build-arg "IMAGE_DESCRIPTION=pi-devbox ${RELEASE_TAG} — core variant: pi coding agent CLI ${PI_VERSION}, pi-toolkit, extensions (fork + observational-memory + atelier ${ATELIER_TAG} TUI sidebar), MemPalace. No browser UI — see the -studio tags for that." \
--build-arg "RELEASE_TAG=${RELEASE_TAG}" \ --build-arg "RELEASE_TAG=${RELEASE_TAG}" \
--build-arg "BUILD_DATE=${BUILD_DATE}" \ --build-arg "BUILD_DATE=${BUILD_DATE}" \
--build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \ --build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \
@@ -513,6 +594,7 @@ jobs:
# or fail independently of the core release. # or fail independently of the core release.
build-variant-studio: build-variant-studio:
needs: [base-decide, smoke-studio, resolve-versions] needs: [base-decide, smoke-studio, resolve-versions]
if: inputs.smoke_only != 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
container: container:
image: catthehacker/ubuntu:act-latest image: catthehacker/ubuntu:act-latest
@@ -559,6 +641,8 @@ jobs:
STUDIO_REF: ${{ needs.resolve-versions.outputs.studio_ref }} STUDIO_REF: ${{ needs.resolve-versions.outputs.studio_ref }}
STUDIO_TAG: ${{ needs.resolve-versions.outputs.studio_tag }} STUDIO_TAG: ${{ needs.resolve-versions.outputs.studio_tag }}
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }} MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
ATELIER_REF: ${{ needs.resolve-versions.outputs.atelier_ref }}
ATELIER_TAG: ${{ needs.resolve-versions.outputs.atelier_tag }}
run: | run: |
set -euo pipefail set -euo pipefail
TAG_FLAGS=() TAG_FLAGS=()
@@ -579,6 +663,10 @@ jobs:
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \ --build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \ --build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
--build-arg "INSTALL_STUDIO=true" \ --build-arg "INSTALL_STUDIO=true" \
--build-arg "IMAGE_TITLE=pi-devbox (studio)" \
--build-arg "PI_ATELIER_REF=${ATELIER_REF}" \
--build-arg "PI_ATELIER_VERSION=${ATELIER_TAG}" \
--build-arg "IMAGE_DESCRIPTION=pi-devbox ${RELEASE_TAG} — studio variant: everything in the core variant (pi ${PI_VERSION}, pi-toolkit, fork + observational-memory + atelier ${ATELIER_TAG}, MemPalace) plus the pi-studio browser UI ${STUDIO_TAG}." \
--build-arg "PI_STUDIO_REF=${STUDIO_REF}" \ --build-arg "PI_STUDIO_REF=${STUDIO_REF}" \
--build-arg "PI_STUDIO_VERSION=${STUDIO_TAG}" \ --build-arg "PI_STUDIO_VERSION=${STUDIO_TAG}" \
--build-arg "RELEASE_TAG=${RELEASE_TAG}" \ --build-arg "RELEASE_TAG=${RELEASE_TAG}" \
+19 -2
View File
@@ -6,11 +6,28 @@ name: Lint
# actionlint runs shellcheck against each `run:` step using its *effective* # actionlint runs shellcheck against each `run:` step using its *effective*
# shell, so `set -o pipefail` under dash is flagged as SC3040 before any # shell, so `set -o pipefail` under dash is flagged as SC3040 before any
# expensive build runs. This is cheap (~10s) and independent of the build # expensive build runs. This is cheap (~10s) and independent of the build
# pipeline, so it fires on every push/PR — not just on release tags, which # pipeline, so it fires on every branch push/PR — not just on release tags,
# is where the build workflow (docker-publish.yml) is otherwise only # which is where the build workflow (docker-publish.yml) is otherwise only
# triggered. # triggered.
#
# `branches: ['**']` (rather than a bare `push:`) deliberately EXCLUDES tag
# pushes. A bare `push:` also fires on `refs/tags/v*`, which was duplicate work —
# the tagged tree was already linted when the same commit was pushed to main
# (v1.6.4: lint id=529 on refs/heads/main, then id=531 again on
# refs/tags/v1.6.4, same sha e86e5df). The wasted compute is small (measured:
# lint here runs 0.3-0.9 min, against a 77.6 min release build for v1.6.4 — so
# runner contention is NOT a real argument in this repo, unlike opencode-devbox
# where actionlint installs shellcheck and takes 6-15 min). The substantive
# reason is discovery ambiguity: the runs listing is newest-first, so the
# tag-ref lint run sorts ABOVE the publish run, and "first run matching
# refs/tags/<tag>" picks lint — which goes green in under a minute while the
# image is still building, making a release look finished before anything is
# published. See AGENTS.md "Gitea API access" for the head_sha-filtered
# discovery pattern.
on: on:
push: push:
branches:
- '**'
pull_request: pull_request:
workflow_dispatch: workflow_dispatch:
+53 -2
View File
@@ -76,7 +76,10 @@ re-brand of opencode-devbox's `pi-only` variant.
4. Push tag: `git tag vX.Y.Z && git push origin vX.Y.Z`. 4. Push tag: `git tag vX.Y.Z && git push origin vX.Y.Z`.
5. Watch CI: smoke job builds amd64 only and asserts size + extensions + 5. Watch CI: smoke job builds amd64 only and asserts size + extensions +
pi version + new-base-tooling presence. Variant build is multi-arch pi version + new-base-tooling presence. Variant build is multi-arch
(amd64 + arm64) only after smoke passes. (amd64 + arm64) only after smoke passes. **A tag push produces two runs, not
one** — `lint.yml` fires on every push (including tag refs) and
`docker-publish.yml` fires on `v*` tags. Watch the **publish** run; see
*Gitea API access* below for how to find it without picking lint by mistake.
6. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus 6. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus
base-latest if the base was rebuilt this run). base-latest if the base was rebuilt this run).
7. **Revoke any short-lived Gitea PAT** used during the release at 7. **Revoke any short-lived Gitea PAT** used during the release at
@@ -92,13 +95,61 @@ host `.env` via `docker-compose.yml` (`${GITEA_ACCESS_TOKEN:-}` /
**not** baked into the image. When configured, they are also available for **not** baked into the image. When configured, they are also available for
**any** direct Gitea API interaction from inside the container — inspecting **any** direct Gitea API interaction from inside the container — inspecting
CI runs, checking published tags, listing commits — e.g. CI runs, checking published tags, listing commits — e.g.
`curl -H "Authorization: token $GITEA_ACCESS_TOKEN" "$GITEA_HOST/api/v1/repos/joakimp/pi-devbox/actions/runs?limit=5"`. `curl -H "Authorization: token $GITEA_ACCESS_TOKEN" "$GITEA_HOST/api/v1/repos/joakimp/pi-devbox/actions/runs?limit=20"`.
Prefer this over a short-lived PAT file when the env token is present (the Prefer this over a short-lived PAT file when the env token is present (the
`ci-release-watcher` skill auto-detects it). Public-repo GET listings work `ci-release-watcher` skill auto-detects it). Public-repo GET listings work
unauthenticated too, so the token matters mainly for private repos or unauthenticated too, so the token matters mainly for private repos or
rate-limit headroom; its lifecycle is host-managed, so there is nothing to rate-limit headroom; its lifecycle is host-managed, so there is nothing to
revoke after use. Never echo the token value (including into logs). revoke after use. Never echo the token value (including into logs).
**Gotcha — a tag push fires EVERY workflow whose triggers match the tag ref.**
`lint.yml` uses a bare `push:` trigger, so a release tag yields *both* a lint run
and the publish run. The listing is newest-first and lint sorts **above** the
publish run, so "take the first run whose `path` contains `refs/tags/<tag>`"
picks the wrong one **reliably, not occasionally**. Real listing for v1.6.4:
```
id=531 #104 lint.yml@refs/tags/v1.6.4 <- wrong; sorts first
id=530 #103 docker-publish.yml@refs/tags/v1.6.4 <- the release build
id=529 #102 lint.yml@refs/heads/main <- same commit, linted on push
```
Lint goes green in minutes while the image is still building, so watching it
makes a release look finished when nothing has been published yet.
**Gotcha — the jobs endpoint takes the internal `id`, NOT the `run_number` the
UI shows as `#104`.** The two diverge widely, and `GET
.../actions/runs/<run_number>/jobs` does **not** error — it silently returns a
*different* run's jobs. Always read `id` from the run listing:
```bash
# Which runs did this tag/commit trigger? Filter on head_sha; never trust
# ordering or run numbering. limit=20, not 5 — with two runs per push the
# publish run falls off a 5-item window fast.
curl -sS -H "Authorization: token $GITEA_ACCESS_TOKEN" \
"$GITEA_HOST/api/v1/repos/joakimp/pi-devbox/actions/runs?limit=20" \
| jq --arg sha "$(git rev-list -n1 vX.Y.Z)" \
'.workflow_runs[] | select(.head_sha==$sha) | {id, run_number, path, status, conclusion}'
# pick the id whose .path starts with docker-publish.yml, then:
curl -sS -H "Authorization: token $GITEA_ACCESS_TOKEN" \
"$GITEA_HOST/api/v1/repos/joakimp/pi-devbox/actions/runs/<id>/jobs" \
| jq '.jobs[] | {name, status, conclusion}'
```
**Watcher config for this repo** (`ci-release-watcher` skill, hub-only shape —
pi-devbox has no downstream host to deploy to):
- `EXPECT_WORKFLOW=docker-publish.yml` — the skill's `preflight_run()` aborts at
startup if the run id belongs to lint instead.
- `EXPECTED_FRESH_TAGS='vX.Y.Z latest vX.Y.Z-studio latest-studio'`
- `EXPECTED_EXISTS_TAGS='base-latest'` — existence only: it is content-addressed
and legitimately keeps its old timestamp when the base is a cache hit.
- `CRITICAL_JOBS='build-variant build-variant-studio'` — job names are matched
**exactly** (`critical.issubset(succeeded)`), so the studio variant must be
listed explicitly; the skill's default omits it. Leave `promote-base-latest`
out: it legitimately skips on a base cache hit, which would misclassify a good
run. `update-description` is the cosmetic post-publish job.
## Cache-hit footgun (must-know) ## Cache-hit footgun (must-know)
`PI_VERSION` defaults to `latest` in `Dockerfile.variant` but **CI must `PI_VERSION` defaults to `latest` in `Dockerfile.variant` but **CI must
+484
View File
@@ -11,6 +11,490 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
--- ---
## v1.8.1 — 2026-08-15
Patch release. **Unblocks v1.8.0, which never shipped.** Its `smoke` and
`smoke-studio` jobs each failed exactly one assertion (67/68 and 70/71 passed),
so `build-variant` and everything downstream skipped: no `v1.8.0` tag reached
Docker Hub and `latest` stayed on v1.7.0 from 2026-08-07. Image content is
unchanged from what v1.8.0 intended — the pins here are identical (pi `0.84.2`,
pi-atelier `v0.8.1`).
The failing assertion was `pi stage defaults next to the palace (not a cache
dir)`, added three days earlier in 7c00dd6. **It was a test bug, not a product
regression.** It asserted a literal path:
```sh
echo "$out" | grep -q "stage=/home/developer/.mempalace/pi-stage/"
```
but the `run` helper invokes `docker run --rm --entrypoint="" $IMAGE sh -c …`,
and neither `Dockerfile.base` nor `Dockerfile.variant` sets `USER` or `ENV HOME`
(the published base image config carries no `HOME` at all — `HOME` is normally
set by `entrypoint-user.sh`, which `--entrypoint=""` deliberately skips). So the
assertion ran as **root with `HOME=/root`**, `mempalace-pi-session` correctly
resolved `stage=/root/.mempalace/pi-stage/…` (it is `$HOME`-relative by design:
`$MEMPALACE_PALACE_PATH``$MEMPAL_PALACE_PATH``~/.mempalace/config.json`
`~/.mempalace/palace`), and the literal grep could never match under any
circumstances. The tell was one line below it in the log: the sibling assertion
`pi stage follows MEMPALACE_PALACE_PATH` **passed**, because it sets the variable
explicitly and so never consults `HOME`. Default fails while explicit passes is
the signature of a wrong `HOME`, not of broken staging.
Fixed by asserting the invariant that was actually meant — the stage sits beside
the resolved palace, sharing its lifetime — which is user-independent:
```sh
case "$stage" in
"stage=$HOME/.mempalace/pi-stage/"*) exit 0 ;;
*) exit 1 ;;
esac
```
`$HOME` is expanded by the container's own shell, so this holds as root, as
`developer`, or under any future user, while a cache-dir default — the
regression the assertion exists to catch — still fails it (verified against all
three cases plus a simulated `MEMPALACE_PI_STAGE` cache pin). A second
assertion, `pi stage is palace-adjacent for the developer user`, now covers the
deployment-specific path properly, by *supplying* `HOME=/home/developer` instead
of assuming it.
### Why it took a release to notice — and the `smoke_only` input
`docker-publish.yml` triggers on `push: tags: v*` only. 7c00dd6 was a push to
**main**, so only `lint.yml` ran; v1.8.0 was the first tag afterwards and
therefore the assertion's **first execution ever**. Any smoke assertion written
outside a release was unvalidated until the next release consumed it — the
worst possible moment to discover it.
New `workflow_dispatch` input **`smoke_only`** closes that: it probes/builds the
base and runs both smoke jobs against HEAD, then stops before publishing
anything. Implemented as `if: inputs.smoke_only != 'true'` on `build-variant`
and `build-variant-studio`, deliberately *without* `always()` so the implicit
"needs succeeded" gate survives and a red smoke still blocks a release;
`promote-base-latest` and `update-description` already require `build-variant`
success and so skip on their own. On a tag push `inputs` is unset and
`null != 'true'` is true, so releases behave exactly as before. This release was
validated with a `smoke_only` dispatch before the tag was cut.
### Smoke failures now explain themselves
`run` discarded all output (`>/dev/null 2>&1`), so a red ❌ carried zero
diagnostic weight — explaining this one-line failure took a CI-log dig plus a
registry image-config inspection, when the container had already printed the
answer and thrown it away. It now captures output and prints the last few lines
under a failed assertion only. Assertions that want a diagnostic echo it to
stderr (the stage checks now report the resolved stage and the `HOME` they saw),
which stays invisible while they pass.
---
## v1.8.0 — 2026-08-15
Minor release. Headline: **pi sessions now feed MemPalace by themselves.** The
image already shipped `mempalace-toolkit`, but its pi feeder
(`mempalace-pi-session`) was never symlinked onto `PATH`, so nothing ever mined
pi's transcripts — the palace only ever contained what an agent remembered to
file by hand. A container that gets recreated regularly has no other memory, so
a missed wind-down was a permanently lost session.
Also here: **pi 0.84.1 → 0.84.2 and pi-atelier v0.8.0 → v0.8.1**, bumped
together. The pi bump closes the Amazon Bedrock tool-argument poison pill that
v1.6.4 recorded as unfixed upstream; the atelier bump is the matching companion,
since both sides changed fullscreen input handling in the same fortnight. Audits
for both are below.
*Why event-driven and not a timer:* there is nothing schedulable inside the
container — PID 1 is `bash -l`, with no systemd and no cron — and anything
installed would not survive recreate anyway. The triggers therefore live where
the events already are: pi's own lifecycle, plus container start.
### Added
- **`mempalace-pi-session` symlinked onto `PATH`** (`Dockerfile.base`,
alongside its `mempalace-session` / `mempalace-docs` siblings, with the same
`--help` build-time check). `entrypoint-user.sh` also self-heals the symlink
into `~/.local/bin` (already ahead of `/usr/local/bin` on `PATH`, and
writable by `developer`) so the feature works on images whose base predates
this change.
- **Container-start catch-up feed** (`entrypoint-user.sh`, backgrounded). pi's
mempalace extension feeds the palace on `session_shutdown` and on a debounced
`agent_settled`, but a hard kill (`docker kill`, OOM, host reboot) runs no
handler at all; this is the only trigger that can recover the previous life's
transcripts. Skipped when a remote palace is configured without an inbox to
ship to — **and the skip now says so** (see Changed) — and skippable entirely
with `MEMPALACE_FEED=0`.
- **`MEMPALACE_PI_STAGE` no longer needs pinning here — the feeder's default
was fixed upstream instead.** It used to stage under `~/.cache`, which is
disposable in a container; the first cut of this change pinned the env var
into the persisted `~/.pi` volume. That was the wrong fix: it created a second
convention that could still diverge from the palace (keep the palace volume,
drop `devbox-pi-config`, and a scoped `mempalace sync` prunes every
conversation drawer, because dedup keys on the *staged* path). The feeder now
defaults to `<palace-root>/pi-stage`, resolved with mempalace's own
precedence (`$MEMPALACE_PALACE_PATH``$MEMPAL_PALACE_PATH`
`~/.mempalace/config.json``~/.mempalace/palace`), so the stage inherits
whatever persistence the palace has and the two cannot be separated by
accident. No `ENV` and no entrypoint export: adding one back would
re-introduce exactly the split it removes.
- **Transcript inbox mount in `docker-compose.mempalace.yml`**
(`${MEMPALACE_FEED_DIR:-./feed}:/data/feed:ro`). A client cannot mine into a
remote palace directly: `mempalace_mine` expands its source path in the
*server* process, so the server can only see paths inside its own container.
Clients rsync their staged exports to a per-device subdirectory and then ask
the server to mine `/data/feed/<device>`. Read-only because mining only reads
sources — all locks live palace-side.
- **Smoke tests** for the above: `mempalace-pi-session` on `PATH`, two
assertions that the stage resolves next to the palace (default, and following
`$MEMPALACE_PALACE_PATH`), and two behavioural guards that feed the exporter a
synthetic pi session — one that must be captured, one abandoned session that
must not be. The second matters because pi expands skills/context into the
user prompt, so an abandoned session can look substantial by byte count while
containing no assistant output; and if pi's JSONL shape ever changes, the
exporter would silently capture nothing.
- **`.env.example`**: documents `MEMPALACE_FEED`,
`MEMPALACE_FEED_DEBOUNCE_MS`, `MEMPALACE_FEED_WING`, and the remote-palace
shipping vars `MEMPALACE_PI_SSH_TARGET`, `MEMPALACE_PI_REMOTE_PATH`,
`MEMPALACE_PI_DEVICE`.
### Changed
- **The "remote palace, no inbox" skip announces itself instead of vanishing**
(`entrypoint-user.sh`). When `MEMPALACE_REMOTE_URL` is set but
`MEMPALACE_PI_SSH_TARGET` is not, there is genuinely nothing the feeder can
ship to, so skipping is correct — but the branch was a bare `:`, and the skip
happens *before* the subshell that writes `~/.pi/agent/mempalace-catchup.log`.
A container in that state therefore contributed nothing to the palace and left
**no artifact at all**, not even an empty log, to explain why — indistinguish-
able from a healthy run that had nothing to file. Found while flipping the
first client onto the shared palace (2026-08-12), where it is the single most
likely way to end up quietly memory-less. The notice now goes to both the
container start output and that log path, names the two variables that fix it,
states that MCP tools still work (only *this* container's transcripts go
nowhere), and points at `MEMPALACE_FEED=0` for anyone who meant it.
Deliberately incapable of breaking startup: an unwritable `~/.pi` — root-owned
volume, a classic Docker accident — would make `mkdir -p` fail under `set -e`
and abort the whole entrypoint, so it degrades to stdout-only. That was a real
new risk, since this branch previously touched no filesystem whatsoever.
Covered by two smoke assertions against the entrypoint as shipped in the image
(the branch only runs at container start, so a `docker run` one-shot cannot
reach it).
### Bumped: pi 0.84.1 → 0.84.2
- **`ARG PI_VERSION=0.84.2`** (`Dockerfile.variant`), with the audit the pin
policy in that file requires.
**Headline for this image: the Bedrock tool-argument poison pill is FIXED
upstream.** The v1.6.4 entry below recorded it as *"Not fixed upstream … still
replayed unsanitised"* — that note is now superseded. pi-ai 0.84.2 adds a
recursive `sanitizeBedrockDocument()` and applies it at exactly the site that
entry named ([#7882](https://github.com/earendil-works/pi/pull/7882)):
```diff
- toolUse: { toolUseId: c.id, name: c.name, input: c.arguments },
+ toolUse: { toolUseId: c.id, name: c.name, input: sanitizeBedrockDocument(c.arguments) },
```
(`dist/api/bedrock-converse-stream.js` — line 692 in pi-ai 0.84.1, 704 in
0.84.2; it was 644 in 0.83.0 and 634 in 0.82.1.) The sanitiser drops object
members whose key is the empty string, recursing through arrays and nested
objects and preserving every valid value. It runs while the request is built,
so it covers the live turn *and* a resume: a session already bricked by an
empty-key tool argument now replays instead of dying on a Bedrock
`ValidationException`. **`pi-session-repair` (in `cli_utils`) is therefore no
longer the recovery path on this image.** It stays useful for older images and
for inspecting a transcript, because the stored `.jsonl` is still malformed —
the fix sanitises what is *sent*, not what was *recorded*.
**Why bumping `PI_VERSION` is the only way to get it:** pi publishes an
`npm-shrinkwrap.json`, which pins transitive dependencies *exactly*. pi
0.84.1's shrinkwrap pins `@earendil-works/pi-ai` to **0.84.1**, so although
0.84.1's `package.json` range is `^0.84.1` — which would otherwise admit
0.84.2 — rebuilding the old pin can never pick the fix up. Transitive upstream
fixes do not leak into this image; `PI_VERSION` is the whole gate.
Rest of the audit, against the integration surface the pin policy names:
- **Session `.jsonl` format — unchanged.** Identical
`migrateV1ToV2`/`migrateV2ToV3` ladder in both versions, so existing sessions
on the named volume load as-is and `pi-session-repair`'s parse target is
untouched.
- **Node engine floor — unchanged** at `>=22.19.0` (image ships 22.23.2).
- **pi-atelier — no change needed.** The pin stays `v0.8.0`: the hard floor is
"never pair < 0.7.1 with pi >= 0.84", this bump does not leave 0.84.x, and
atelier's `peerDependencies` (`>=0.80.7`) are satisfied. pi-atelier **0.8.1**
is published but deliberately NOT adopted here — one variable at a time, and
atelier is the component that has drawn blood at startup.
- **Directly relevant to `pi --ssh` use of this image:** 0.84.2 fixes split
`Alt+Enter` over SSH being misread as Escape, and adds `PI_TUI_ESC_TIMEOUT`
for high-latency terminals.
- **Keybindings — one surface worth knowing.** `pi-toolkit` ships exactly one
override, `tui.input.newLine: [shift+enter, ctrl+j, alt+j]`. 0.84.2's new
fullscreen transcript search (`Ctrl+Shift+F`) binds `Shift+Enter` to
*previous match* while its overlay is focused. Different context, so no
conflict is expected — but it is the one place the override meets a new
default, and the first place to look if "shift+enter stopped inserting a
newline" is ever reported.
- **New `defaultTools` setting** (choose startup built-in tools globally or per
project) is additive; `pi-toolkit`'s `settings.example.json` does not set it,
so the bootstrap template needs no change.
### Bumped: pi-atelier v0.8.0 → v0.8.1
- **`ARG PI_ATELIER_REF` / `ARG PI_ATELIER_VERSION` = `v0.8.1`**
(`Dockerfile.variant`), bumped *together* with `PI_VERSION` as that pin's
comment requires — and this pairing is a good advert for the rule, because both
sides touched fullscreen input handling within three days of each other.
atelier 0.8.1 (2026-08-12) is two changes, only one of them code: *"Preserve
fullscreen transcript mouse-wheel scrolling after Sidebar resize and visibility
changes by leaving Pi's persistent mouse reporting enabled"*, plus a README
simplification. The single source file that differs from 0.8.0 is
`src/split-pane.ts`. It extracts an `isPiFullscreenRenderer()` predicate and,
under pi's fullscreen renderer, stops writing its own
`\e[?1002h\e[?1006h` / `\e[?1006l\e[?1002l` pair around a sidebar resize —
previously it enabled mouse reporting on grab and disabled it on release, which
tore down the reporting **pi itself** had switched on and left the wheel dead
afterwards. Outside fullscreen it manages mouse mode exactly as before. It also
now captures the terminal it enabled mouse on and writes the disable sequence to
*that* terminal instead of to whatever `tui` currently points at.
**The audit that matters is the private-internals coupling**, since that is what
hung startup at 0.6.0/0.7.0. atelier reaches into three pi internals; all three
are unchanged in pi 0.84.2:
- **`TuiAltScreen`** — detected *by constructor name*, so a rename would
silently disable both the resize-input prioritisation and the new mouse
behaviour, with no error. Still
`class TuiAltScreen extends TuiBase implements ViewportTUI`.
- **`tui.inputListeners`** — a private `Set` that atelier deletes from and
re-adds to, to get its resize handler ahead of pi's viewport listener (which
"consumes every mouse event for text selection"). Still `inputListeners = new
Set()`, at the identical line 103 of `pi-tui/dist/tui.js` in both versions,
and still a `Set` — atelier guards with `instanceof Set`.
- **the prototype `render` descriptor** it wraps via `findPrototypeRender`.
Still an own `render(width)` on `TuiAltScreen`.
pi's mouse sequences are byte-identical between 0.84.1 and 0.84.2 (same
`1002h`/`1006h`/`1002l`/`1006l`/`1003h` occurrence counts), so atelier's
assumption about what pi leaves enabled still holds. `pi-tui`'s base class
changed additively only (one new `isOverlayFocused()`), and `TuiAltScreen`'s own
changes are the new search feature (`activeSearch`, `openSearch`/`closeSearch`,
the two search match styles, `copySelection`).
**Caveat, stated plainly:** pi 0.84.2 adds a focused fullscreen *search overlay*
that participates in input handling, while atelier reorders input listeners
around pi's viewport listener. The two look convergent — 0.84.2 separately fixes
*"focused fullscreen overlays not receiving mouse wheel or viewport scroll
keys"* — but this pairing is reasoned from the diffs, **not proven by
execution**: the CI smoke test does not drive the TUI, so a fullscreen
interaction regression would not be caught before pull. Worth an `alt+a` plus a
sidebar resize and a wheel scroll in fullscreen on first use of this image.
Version metadata is unchanged: `engines.node >=22.19.0`, `peerDependencies`
still the uninformative `>=0.80.7` on both pi packages (so still nothing in npm
metadata encodes the real floor), and still zero runtime dependencies — so the
"no `npm install` step" note above stays true. The GitHub tag `v0.8.1` exists
(commit `c31d7439`), which is what CI resolves to a SHA.
### Notes
- The `Dockerfile.base` change moves the base hash, so this needs a base
rebuild; the `~/.local/bin` self-heal exists so the feature does not have to
wait for one. The skip-notice change is in `entrypoint-user.sh`, which is
`COPY`d in `Dockerfile.base` too, so it rides the same rebuild — until then,
older images keep skipping silently and the two commands in the toolkit's
`phase-1-exposure-runbook.md` §3.7 are the way to tell.
- Requires the matching `mempalace-toolkit` change (`--prepare` two-phase
split, remote transport, and the auto-feed triggers in
`extensions/pi/mempalace.ts`). The split exists because the palace is
single-writer: a live pi session holds it through the extension's own
`mempalace-mcp`, so a CLI `mempalace mine` during a session fails with
"palace ... is held by PID". Staging is therefore done by the CLI and the
mine itself by whichever process already holds the palace.
---
## v1.7.0 — 2026-08-07
Minor release. Headline: **pi-atelier is now part of the image** — the TUI
sidebar/status rail every container previously had to hand-install — and **pi is
pinned to an audited version instead of tracking npm `latest`**.
*Why minor and not patch:* the policy above reserves patch for "pi version bumps,
smaller fixes" and minor for "new variants, significant base additions". Bundling
a new companion package into every image is the same shape as v1.1.0, which went
minor for bundling pi-studio; v1.4.0 likewise went minor for adding typst. This
release also adds a new build-arg pair, a new opt-out env var, and a settings
migration, so patch would understate it.
### Added
- **pi-atelier vendored at `/opt/pi-atelier`, pinned to `v0.8.0`** — the TUI
sidebar (ordered panels, split-pane, themes) is now part of the image instead
of something each user hand-installs. Vendored + registered at container start
by `entrypoint-user.sh`, the same pattern as pi-fork/pi-observational-memory/
pi-studio, and deliberately **not** `pi install npm:pi-atelier`: an npm
install writes into `~/.pi/npm-global` on the config volume, which shadows the
image and pins nothing — the footgun that once hid a missing `fork` tool for
six weeks. Unlike its siblings it gets **no `npm install`**: pi-atelier
declares zero runtime dependencies (only peerDeps, satisfied by the baked pi)
and has no build step, so pi loads its TypeScript straight from the checkout
(`pi.extensions` → `extensions/index.ts`).
- **A version FLOOR, encoded as an executable test.** pi-atelier 0.6.0/0.7.0
wrap pi's private TUI renderer in a way that recurses under pi 0.84: pi hangs
at startup with sustained CPU and no error message. 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"); 0.8.0 is additive on top of that. atelier's own
`peerDependencies` still say `>=0.80.7`, which does **not** express the floor,
so nothing in npm metadata could have warned us. `smoke-test.sh` and
`recreate-sanity-check.sh` now assert the pairing rule **pi ≥ 0.84 ⇒
pi-atelier ≥ 0.7.1** — verified against a 4×4 version matrix — so a bad
combination fails the build instead of publishing an image whose TUI never
starts. CI resolves the pinned tag to its **peeled commit SHA**; atelier uses
annotated tags, so the unpeeled ref is a tag object, not a commit (pi-studio's
lightweight tags never exposed that distinction).
- **`DEVBOX_ATELIER=0`** opts out: the entrypoint removes pi-atelier from pi's
`packages[]` instead of registering it. The switch lives in the entrypoint
rather than being "just run `pi uninstall`" because this component's failure
mode is *pi will not start*, which cannot be repaired from inside pi.
- **Migration for hand-installed copies.** A pre-existing `npm:pi-atelier` entry
is dropped from `packages[]` (with a `settings.json.bak.atelier.<ts>` backup)
so the pinned `/opt` copy takes over. This is not cosmetic: the registration
guard counts `npm:<name>` as already-registered, so without this step every
existing volume would have kept its unpinned npm copy — and a 0.6.x copy
alongside pi 0.84 is exactly the startup hang above. Only that one exact
string is removed; jq-parse failures or a missing file leave settings
untouched, and the backup prefix is distinct from the template merge's so two
rewrites in the same second cannot overwrite each other's backup.
### Changed
- **pi-toolkit's `pi-atelier.json` modernised to atelier's current schema**
(pi-toolkit `0e1369e`, cross-repo — it reaches the image through the pinned
`PI_TOOLKIT_REF` clone). The seeded config had been written against the pre-0.7
vocabulary: `segments` → `segmentLayout` with explicit per-segment visibility,
`ornament: "none"` → `{"id":"brand","visible":false}`, `showExtensionStatuses`
→ `{"id":"statuses","visible":true}`, plus the sidebar toggles that did not
exist when it was written (`showSidebarAgent`, `showSidebarTodos`, and
`showSidebarOnStartup`, new in atelier 0.8.0). Upstream still reads the old
keys, but only as non-authoritative legacy inputs, so the file worked while
silently missing every sidebar control added since. Verified by loading the old
and new file through pi-atelier 0.8.0's own `loadConfig()`: zero warnings from
each and an identical *effective* config, so it is a pure schema
modernisation — every deliberate choice (compact density, 60/85 context
thresholds, notifications off) is preserved. `sidebarPanelLayout` is left unset
on purpose so the panel set tracks upstream as atelier adds panels.
- **pi is now PINNED, not `latest`: `PI_VERSION=0.84.1`** (`Dockerfile.variant`).
CI's `resolve-versions` job used to resolve `@earendil-works/pi-coding-agent`
to npm `latest`, which meant every release silently adopted whatever pi had
shipped that morning — unaudited — in the same build that then got tagged and
published. A pi minor can move the private TUI/renderer internals pi-atelier
wraps (0.84 vs atelier 0.6.0: startup hang) or the session `.jsonl` format
`pi-session-repair` parses. **The pin is a checkpoint, not a freeze** —
bumping stays a routine one-line change; what stops is *unreviewed* adoption.
0.84.1 was audited for this release: theme/TUI additions are additive, the
session format is unchanged (`CURRENT_SESSION_VERSION = 3` in both 0.83.0 and
0.84.1, identical `migrateV1ToV2`/`migrateV2ToV3` ladder, so existing
transcripts are neither migrated nor at risk), and the Node engine floor is
unmoved at `>=22.19.0`.
- The pins live in the **Dockerfiles** and CI reads them from there (a
`checkout` was added to `resolve-versions`), so a local `docker build` and a
CI release ship the same versions by construction instead of by convention.
- CI **fails** the build when the pin is not a concrete version, and when the
pinned version is not actually published on npm — catching a typo, an
unpublished version, or one yanked after we audited it, at resolve time with
a clear message rather than as an `npm install` error mid-build.
- CI **warns** (`::warning::`, never adopts) when npm `latest` is ahead of the
pin, naming the newer version and what to re-check. That warning is the
prompt to audit and bump — not something to silence.
- **mempalace pin `3.5.0` → `3.6.0`** (`Dockerfile.base` `MEMPALACE_VERSION`),
in lockstep with opencode-devbox v2.9.0 as the pin's own comment requires.
3.6.0 (2026-07-17) is PyPI latest and is additive/reliability only — secure
`mempalace serve` remote mode, optional Milvus backend, atomic KG
`supersede()`, conversation chronology, mining exclusions, plus recovery and
locking fixes. **Reviewed for MCP tool-schema changes before bumping** — that
being the exact regression class this pin exists to catch, after an unpinned
install once swept in the broken 3.3.x/3.4.0 `diary_write` schema: there are
**none**, and nothing touches `diary_write`, so the perl workaround removed in
v1.2.2 stays removed. Two fixes are directly relevant to how this image uses
mempalace: read-only mode now covers `checkpoint` + `delete_by_source` in
`_MUTATING_TOOLS` (#1930), and agent attribution is preserved in
`mempalace_checkpoint` (#2023/#2034) — the latter matters because the diary
protocol relies on per-agent attribution. Rebuilds the base image.
### Documentation
- **New README section: "Using pi-atelier (TUI sidebar)"** — what the status rail
and sidebar give you, the `alt+a` / `/atelier` entry points, session-scoped
`/atelier sidebar on|off` versus persistent Save, and `DEVBOX_ATELIER=0` to opt
out. Plus the config story: why `~/.pi/agent/pi-atelier.json` is **copied, not
symlinked** (atelier saves via write-temp-then-`rename(2)`, and `rename`
replaces a symlink rather than following it, so a symlink would silently detach
on the first save), why `install.sh` therefore only seeds it when absent, which
keys are current versus legacy-compatibility, and the 92-column auto-hide /
64-column main-pane floor so a narrow terminal degrades gracefully.
- **Documents how to authenticate the container to a LAN peer with its own key**
(README: *Giving the container its own key for a peer*) — the gap the existing
*Naming LAN peers* section left open. That section explained `ProxyJump`
*routing* while asserting `HostName`/`User`/`IdentityFile` are "inherited from
the matching block in your real `~/.ssh/config`", which is precisely what fails
in a container: host keys are normally passphrase-protected and unlocked by the
macOS Keychain or an `ssh-agent`, neither of which exists here, so the key can
never be decrypted — `Permission denied (publickey)` while the identical
`ssh peer` works fine in a host terminal — and `~/.ssh` is read-only, so no
usable key can be added there either. The new walkthrough (throwaway example
keys) covers a passphraseless keypair in the `devbox-ssh-local` volume so it
survives `--force-recreate`; a hardened `authorized_keys` line (`restrict`,
`from=`, optional `permitopen`); the non-obvious detail that `from=` must allow
the **host's** addresses, plural, because container egress is NAT'd through the
host and a roaming laptop presents a different one per network (a `from=`
mismatch is indistinguishable from a wrong key in the error message); the
`IdentityFile` override in the host-owned `ssh-lan.conf`; and verification with
`-o ControlPath=none` so a warm ControlMaster cannot fake a pass. States
explicitly that no private key is in the published image — the volume is
created at runtime on the operator's own machine.
- **Corrects two claims in *Naming LAN peers***: (1) `ssh-lan.conf` is not
`ProxyJump`-only — it is `Include`d before `~/.ssh/config`, so by
first-value-wins *any* option set there wins, which is what makes the
`IdentityFile` override above possible; (2) "newly added peers work
immediately, no container or session restart needed" holds only for *edits* to
an existing file. Creating it for the first time **does** need one restart,
because `setup-lan-access.sh` emits the
`Include ~/.config/devbox-shell/ssh-lan.conf` line only
`if [ -r "$SSH_LAN_CONF" ]` at container start — until then ssh never reads it,
which presents exactly as "my override is being ignored".
- **Adds *macOS-only keywords in a shared `~/.ssh/config`***. The same file is
read by macOS ssh and by the container's Linux OpenSSH, where macOS-only
keywords are fatal rather than ignored: one `UseKeychain yes` in a `Host *`
block yields `Bad configuration option: usekeychain` /
`terminating, 1 bad configuration options` and takes down `dssh`/`dscp`,
`pi --ssh`, `scp` and every helper that shells out to ssh — while the host
keeps working, so it presents as a container regression rather than a host
config error. Fix is `IgnoreUnknown UseKeychain` *ahead of* the keyword (macOS
still honours it, Linux skips it), plus keeping such a `Host *` block below
OrbStack's `Include ~/.orbstack/ssh/config`, which documents in its own comment
that it must come first.
- Documents **per-variant image description labels** (committed and pushed after
the v1.6.4 tag without a changelog entry). Both published variants used to
inherit `Dockerfile.base`'s `description="pi-devbox — base image
(variant-independent)"`, so `v1.6.4` and `v1.6.4-studio` both advertised
themselves on Docker Hub as *the base image* — misleading, and useless for
telling the two apart. Since a `LABEL` cannot branch on `INSTALL_STUDIO`, the
text now arrives as a build arg: CI passes a variant-specific string
(interpolating `RELEASE_TAG`, `PI_VERSION`, and `STUDIO_TAG` for studio), while
the `Dockerfile.variant` default keeps a bare local `docker build` honest
rather than misleading. Sets `org.opencontainers.image.title`/`.description`
alongside the legacy bare `description` key so both Hub and OCI-aware tooling
see it. The ARGs stay in the last-declared block, so the label layer remains
the only thing invalidated.
---
## v1.6.4 — 2026-07-30 ## v1.6.4 — 2026-07-30
Patch release. Headline: **the `fork` tool has never once loaded since v1.0.0** Patch release. Headline: **the `fork` tool has never once loaded since v1.0.0**
+2 -1
View File
@@ -46,7 +46,8 @@ Full setup guide — authentication for each provider (Anthropic, OpenAI, Gemini
### pi and companions ### pi and companions
- **pi `{{PI_VERSION}}`** ([`@earendil-works/pi-coding-agent`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)) — installed at `/usr/bin/pi` - **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-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)** — 7 user-facing extensions: `ext-toggle`, `mcp-loader`, `todo`, `ssh-controlmaster`, `notify`, `git-checkpoint`, `confirm-destructive`
- **`fork`** ([pi-fork](https://github.com/elpapi42/pi-fork)) and **`recall`** ([pi-observational-memory](https://github.com/elpapi42/pi-observational-memory)) tools - **`fork`** ([pi-fork](https://github.com/elpapi42/pi-fork)) and **`recall`** ([pi-observational-memory](https://github.com/elpapi42/pi-observational-memory)) tools
+19 -5
View File
@@ -367,13 +367,24 @@ ARG INSTALL_MEMPALACE=true
# diary_write schema. Pinning makes mempalace upgrades a reviewable diff # diary_write schema. Pinning makes mempalace upgrades a reviewable diff
# rather than a surprise. # rather than a surprise.
# #
# 3.5.0 (2026-06) ships the upstream fix for the top-level-anyOf diary_write # 3.5.0 (2026-06) shipped the upstream fix for the top-level-anyOf diary_write
# schema (issue #1728 / PR #1717, merged 2026-06-14): the advertised schema # schema (issue #1728 / PR #1717, merged 2026-06-14): the advertised schema
# is now `"required": ["agent_name"]` with entry/content enforced at dispatch, # is now `"required": ["agent_name"]` with entry/content enforced at dispatch,
# which Anthropic's tools API accepts — so the old mcp_server.py perl # which Anthropic's tools API accepts — so the old mcp_server.py perl
# workaround that used to live below is gone. Keep in lockstep with # workaround that used to live below is gone.
# opencode-devbox when bumping. #
ARG MEMPALACE_VERSION=3.5.0 # 3.6.0 (2026-07-17, PyPI latest) is additive/reliability only — secure
# `mempalace serve` remote mode, optional Milvus backend, atomic KG
# supersede(), conversation chronology, mining exclusions, plus recovery and
# locking fixes. Reviewed for MCP tool-schema changes before bumping (that
# being the exact regression class this pin exists to catch): there are NONE,
# and nothing touches diary_write. Two fixes matter for how this image uses
# mempalace: read-only mode now covers checkpoint + delete_by_source in
# _MUTATING_TOOLS (#1930), and agent attribution is preserved in
# mempalace_checkpoint (#2023/#2034).
#
# Keep in lockstep with opencode-devbox when bumping.
ARG MEMPALACE_VERSION=3.6.0
ENV UV_TOOL_DIR=/opt/uv-tools ENV UV_TOOL_DIR=/opt/uv-tools
ENV UV_TOOL_BIN_DIR=/usr/local/bin ENV UV_TOOL_BIN_DIR=/usr/local/bin
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \ RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
@@ -413,9 +424,12 @@ RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" =
[ "$ok" = "1" ] && \ [ "$ok" = "1" ] && \
ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \ ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \
ln -sf /opt/mempalace-toolkit/bin/mempalace-docs /usr/local/bin/mempalace-docs && \ ln -sf /opt/mempalace-toolkit/bin/mempalace-docs /usr/local/bin/mempalace-docs && \
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs && \ ln -sf /opt/mempalace-toolkit/bin/mempalace-pi-session /usr/local/bin/mempalace-pi-session && \
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs \
/opt/mempalace-toolkit/bin/mempalace-pi-session && \
mempalace-session --help >/dev/null && \ mempalace-session --help >/dev/null && \
mempalace-docs --help >/dev/null && \ mempalace-docs --help >/dev/null && \
mempalace-pi-session --help >/dev/null && \
echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \ echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \
fi fi
+68 -11
View File
@@ -29,16 +29,34 @@ ARG USER_NAME=developer
# runs each repo's install.sh on container start so symlinks land under # runs each repo's install.sh on container start so symlinks land under
# ~/.pi/agent/ on the named volume. # ~/.pi/agent/ on the named volume.
# #
# PI_VERSION should be passed explicitly by CI as a concrete version # ── pi version pin: an AUDITED CHECKPOINT, not a freeze ──────────────
# (resolved from `npm view @earendil-works/pi-coding-agent version`). # PI_VERSION is pinned to a version whose upstream CHANGELOG has been read
# The default `latest` is for local dev convenience only — it has a # against this image's integration surface: the theme/TUI API that pi-atelier
# known cache-hit footgun in registry-cached CI builds: the resulting # couples to, the session `.jsonl` format that `pi-session-repair` parses, the
# build-arg string is byte-identical across builds, the layer-hash is # extension/package loader, and the Node engine floor. CI reads THIS LINE as
# identical, and the registry buildcache silently reuses the layer # the single source of truth (see the `resolve-versions` job) and no longer
# from whatever pi version was current when the cache was first # follows npm `latest` — following it meant every release silently adopted
# populated. CI MUST pass a resolved concrete version. See pi-devbox # whatever pi shipped that morning, unaudited, in the very build that then got
# v0.75.5b 2026-05-23 for the discovery + canonical fix. # tagged and published.
ARG PI_VERSION=latest #
# 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.
ARG PI_VERSION=0.84.2
ARG PI_TOOLKIT_REF=main ARG PI_TOOLKIT_REF=main
ARG PI_EXTENSIONS_REF=main ARG PI_EXTENSIONS_REF=main
# Repo URLs default to the canonical gitea origin but are overridable so a # Repo URLs default to the canonical gitea origin but are overridable so a
@@ -54,6 +72,29 @@ ARG PI_FORK_REPO=https://github.com/elpapi42/pi-fork.git
ARG PI_FORK_REF=master ARG PI_FORK_REF=master
ARG PI_OBSMEM_REPO=https://github.com/elpapi42/pi-observational-memory.git ARG PI_OBSMEM_REPO=https://github.com/elpapi42/pi-observational-memory.git
ARG PI_OBSMEM_REF=master 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.
#
# 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
ARG PI_ATELIER_REF=v0.8.1
# Human-readable tag PI_ATELIER_REF was resolved from; recorded as a label.
ARG PI_ATELIER_VERSION=v0.8.1
RUN set -e && \ RUN set -e && \
# git_fetch_ref: clone-equivalent helper that accepts EITHER a branch name # git_fetch_ref: clone-equivalent helper that accepts EITHER a branch name
@@ -87,12 +128,14 @@ RUN set -e && \
git_fetch_ref "${PI_EXTENSIONS_REPO}" "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \ 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_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_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-fork && npm install --omit=dev --no-audit --no-fund) && \
(cd /opt/pi-observational-memory && 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-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-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-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-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) ── # ── Image-baked skill refresh: pi-extensions (Option 1 over Option 2) ──
# rootfs ships a VENDORED snapshot of the pi-extensions skill at # rootfs ships a VENDORED snapshot of the pi-extensions skill at
@@ -219,15 +262,28 @@ ARG SOURCE_REVISION=
# MEMPALACE_TOOLKIT_REF is consumed in Dockerfile.base; re-declared here # MEMPALACE_TOOLKIT_REF is consumed in Dockerfile.base; re-declared here
# only so its intended ref lands in the label set alongside the others. # only so its intended ref lands in the label set alongside the others.
ARG MEMPALACE_TOOLKIT_REF=main ARG MEMPALACE_TOOLKIT_REF=main
# 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}" \ LABEL org.opencontainers.image.version="${RELEASE_TAG}" \
org.opencontainers.image.revision="${SOURCE_REVISION}" \ org.opencontainers.image.revision="${SOURCE_REVISION}" \
org.opencontainers.image.created="${BUILD_DATE}" \ 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-version="${PI_VERSION}" \
se.jordbo.pi-devbox.pi-toolkit-ref="${PI_TOOLKIT_REF}" \ 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-extensions-ref="${PI_EXTENSIONS_REF}" \
se.jordbo.pi-devbox.pi-fork-ref="${PI_FORK_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-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.mempalace-toolkit-ref="${MEMPALACE_TOOLKIT_REF}" \
se.jordbo.pi-devbox.pi-studio-ref="${PI_STUDIO_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.pi-studio-version="${PI_STUDIO_VERSION}"
@@ -254,6 +310,7 @@ RUN set -e; \
echo " \"pi-extensions\": \"$(rev /opt/pi-extensions)\","; \ echo " \"pi-extensions\": \"$(rev /opt/pi-extensions)\","; \
echo " \"pi-fork\": \"$(rev /opt/pi-fork)\","; \ echo " \"pi-fork\": \"$(rev /opt/pi-fork)\","; \
echo " \"pi-observational-memory\": \"$(rev /opt/pi-observational-memory)\","; \ echo " \"pi-observational-memory\": \"$(rev /opt/pi-observational-memory)\","; \
echo " \"pi-atelier\": \"$(rev /opt/pi-atelier)\","; \
echo " \"mempalace-toolkit\": \"$(rev /opt/mempalace-toolkit)\","; \ echo " \"mempalace-toolkit\": \"$(rev /opt/mempalace-toolkit)\","; \
echo " \"pi-studio\": ${STUDIO_REV}"; \ echo " \"pi-studio\": ${STUDIO_REV}"; \
echo " }"; \ echo " }"; \
+258 -12
View File
@@ -21,6 +21,8 @@ on the host.
mempalace integration, etc.) mempalace integration, etc.)
- `pi-fork` — the `fork` tool for spawning sub-agents - `pi-fork` — the `fork` tool for spawning sub-agents
- `pi-observational-memory` — the `recall` tool for session compaction - `pi-observational-memory` — the `recall` tool for session compaction
- `pi-atelier` — TUI sidebar: ordered panels, split-pane, themes. Pinned to an
audited tag; see [Version pins](#version-pins-pi-pi-atelier-mempalace)
### MemPalace (AI memory) ### MemPalace (AI memory)
@@ -338,6 +340,59 @@ DOT syntax errors instead of crashing. Then in Studio: open the PNG (or a
`.md` that embeds it) and hit **refresh-from-disk** after each edit. `.md` that embeds it) and hit **refresh-from-disk** after each edit.
Note: SVG is **not** in Studio's local-image-link allowlist — use PNG. Note: SVG is **not** in Studio's local-image-link allowlist — use PNG.
## Using pi-atelier (TUI sidebar)
`pi-atelier` is bundled in **both** variants (vendored at `/opt/pi-atelier`,
pinned — see [Version pins](#version-pins-pi-pi-atelier-mempalace)). It adds two
things to pi's terminal UI:
- a **status rail** — activity, token/cost metrics, context usage, model, git
state, extension statuses, and a menu;
- a **sidebar** — ordered panels (agent, activity, alerts, TODOs, context,
workspace, usage, tools) in a split pane beside the transcript.
Nothing needs installing; the entrypoint registers it on container start, and it
binds on the next pi start (or `/reload`).
| Action | How |
|---|---|
| Open the atelier menu | `alt+a`, or `/atelier` |
| Toggle the sidebar for this session | `/atelier sidebar on` / `off` |
| Change settings persistently | atelier menu → **Settings**, then **Save** |
| Turn the whole thing off | `DEVBOX_ATELIER=0` in `.env` |
If your terminal or keymap swallows `alt+a`, use `/atelier` and pick a different
`shortcut` in the config file below.
### Config
Config lives at `~/.pi/agent/pi-atelier.json` on the `devbox-pi-config` volume,
seeded from pi-toolkit with container-appropriate defaults: compact density,
context warnings at 60/85 % (earlier than upstream's 70/90), sidebar tool names
on, and desktop completion notifications **off** (a container has nowhere useful
to pop a toast).
It is **copied, not symlinked** — atelier rewrites this exact path when you hit
**Save**, using write-temp-then-`rename(2)`, and `rename` replaces a symlink with
a regular file instead of following it. A symlink would silently detach on your
first save. Consequently pi-toolkit's `install.sh` only seeds the file when it is
absent: once you have saved your own preferences, image upgrades leave them
alone, and `install.sh` prints a diff hint instead of clobbering.
The seeded file uses atelier's **current** schema — `segmentLayout` with explicit
per-segment visibility, plus `showSidebarAgent` / `showSidebarTodos` /
`showSidebarOnStartup`. Older configs written against the pre-0.7 vocabulary
(`segments`, `ornament`, `showExtensionStatuses`) still load, but only through
upstream's legacy-compatibility shims — so if you are carrying one on an old
volume, expect it to keep working while missing every sidebar control added
since. `sidebarPanelLayout` is deliberately left unset so the panel set follows
upstream's product default as atelier adds panels; set it only if you want to
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.
## docker-compose.yml — basic shape ## docker-compose.yml — basic shape
```yaml ```yaml
@@ -554,6 +609,35 @@ User-level overrides in `~/.ssh/config` win because Debian's
`/etc/ssh/ssh_config` includes `/etc/ssh/ssh_config.d/*.conf` before `/etc/ssh/ssh_config` includes `/etc/ssh/ssh_config.d/*.conf` before
the `Host *` block. the `Host *` block.
### macOS-only keywords in a shared `~/.ssh/config`
The same `~/.ssh/config` is read by macOS ssh *and* by the Linux OpenSSH inside
the container (the sidecar `Include`s it). macOS-only keywords are **fatal**
there, not ignored — a single `UseKeychain yes` in a `Host *` block takes down
every ssh call in the container:
```
/home/developer/.ssh/config: line 2: Bad configuration option: usekeychain
/home/developer/.ssh/config: terminating, 1 bad configuration options
```
That breaks `dssh`/`dscp`, `pi --ssh`, `scp`, and anything that shells out to
ssh (including CI/deploy helpers), while the host keeps working perfectly — so
it presents as a container regression rather than a host config error. Guard the
keyword on the host, *before* it is used:
```diff
Host *
+ IgnoreUnknown UseKeychain
UseKeychain yes
AddKeysToAgent yes
```
`IgnoreUnknown` is understood by both implementations: macOS still honours
`UseKeychain`, Linux skips it. Also keep such a `Host *` block **below** any
`Include` that must come first — OrbStack's own `Include ~/.orbstack/ssh/config`
says so in a comment, and a `Host *` block above it silently violates that.
### Per-host `ControlPath` on a read-only `~/.ssh` ### Per-host `ControlPath` on a read-only `~/.ssh`
`~/.ssh` is usually bind-mounted read-only, so a user `~/.ssh/config` that `~/.ssh` is usually bind-mounted read-only, so a user `~/.ssh/config` that
@@ -573,7 +657,10 @@ this without editing the read-only config:
jump via the host, add `ProxyJump host` overrides in the host-owned jump via the host, add `ProxyJump host` overrides in the host-owned
`~/.config/devbox-shell/ssh-lan.conf` (see `~/.config/devbox-shell/ssh-lan.conf` (see
[Naming LAN peers](#naming-lan-peers)) rather than the read-only [Naming LAN peers](#naming-lan-peers)) rather than the read-only
`~/.ssh/config`. `~/.ssh/config`. If the peer also rejects the host's key — the usual case,
since host keys are normally passphrase-protected and the container has no
Keychain or agent — see
[Giving the container its own key for a peer](#giving-the-container-its-own-key-for-a-peer).
## tmux and 0-indexed sessions ## tmux and 0-indexed sessions
@@ -646,6 +733,7 @@ repoint each one at a mirror, another host, or a local `file://` path
| `MEMPALACE_TOOLKIT_REPO` | `https://gitea.jordbo.se/joakimp/mempalace-toolkit.git` | base | | `MEMPALACE_TOOLKIT_REPO` | `https://gitea.jordbo.se/joakimp/mempalace-toolkit.git` | base |
| `PI_FORK_REPO` | `https://github.com/elpapi42/pi-fork.git` | variant | | `PI_FORK_REPO` | `https://github.com/elpapi42/pi-fork.git` | variant |
| `PI_OBSMEM_REPO` | `https://github.com/elpapi42/pi-observational-memory.git` | variant | | `PI_OBSMEM_REPO` | `https://github.com/elpapi42/pi-observational-memory.git` | variant |
| `PI_ATELIER_REPO` | `https://github.com/michaelmjhhhh/pi-atelier.git` | variant |
| `PI_STUDIO_REPO` | `https://github.com/omaclaren/pi-studio.git` | variant | | `PI_STUDIO_REPO` | `https://github.com/omaclaren/pi-studio.git` | variant |
Each has a matching `*_REF` arg (branch name or commit SHA). Example — build Each has a matching `*_REF` arg (branch name or commit SHA). Example — build
@@ -750,14 +838,109 @@ Host pve pve-2 alpserv-2 lagret
ProxyJump host ProxyJump host
``` ```
`HostName` / `User` / `IdentityFile` are inherited from the matching block in Any option can be set here, not just `ProxyJump`: the file is `Include`d
your real `~/.ssh/config` (first-value-wins, so only `ProxyJump` is taken from *before* `~/.ssh/config` and ssh takes the **first** value it sees for each
here). This file is `Include`d *before* `~/.ssh/config` and read fresh on every option, so whatever you put here wins while everything you omit is inherited
connection — newly added peers work immediately, no container or session from the matching block in your real `~/.ssh/config`. Peer names stay out of the
restart needed — and the peer names stay out of the published image (they're a published image (they are a fact about your LAN, not the image). Alternatively,
fact about your specific LAN, not the image). Alternatively, set set `DEVBOX_LAN_AUTOJUMP_PRIVATE=1` to ProxyJump *any* RFC1918 address through
`DEVBOX_LAN_AUTOJUMP_PRIVATE=1` to ProxyJump *any* RFC1918 address through the the host without naming peers (see `.env.example`).
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".
#### Giving the container its own key for a peer
`ProxyJump` fixes *routing*; it does not fix *authentication*, and inheriting
the host's `IdentityFile` usually fails inside the container:
- Host keys are commonly passphrase-protected, and that passphrase is unlocked
by the macOS Keychain or a running `ssh-agent`. The container has neither, so
the key can never be decrypted — `Permission denied (publickey)` even though
the identical `ssh peer` works in a host terminal.
- `~/.ssh` is mounted read-only, so you can neither drop a container-usable key
in there nor edit `~/.ssh/config` from inside.
The answer is a **container-only keypair** in `~/.ssh-local/` — the named volume
`devbox-ssh-local`, so it survives `docker compose up -d --force-recreate`
plus an `IdentityFile` override in the host-owned `ssh-lan.conf`. Note that
nothing is baked into the *published image*: that volume is created on your
machine at runtime, so no private key ever ships to Docker Hub, and a fresh pull
elsewhere generates its own. (Every key below is a throwaway example.)
**1. In the container** — generate a passphraseless key (there is no agent to
unlock a protected one):
```bash
ssh-keygen -t ed25519 -N '' -C "devbox-$(hostname)" \
-f ~/.ssh-local/mypeer_devbox_ed25519
cat ~/.ssh-local/mypeer_devbox_ed25519.pub
# ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLE0000EXAMPLE0000EXAMPLE0000ex devbox-0d11ec7731c7
```
**2. On the peer** — append that public key to `~/.ssh/authorized_keys` **of
the account you will log in as** (the `User` from step 3), narrowly authorized
rather than bare:
```bash
mkdir -p ~/.ssh && chmod 700 ~/.ssh
cat >> ~/.ssh/authorized_keys <<'KEY'
from="192.168.1.0/24,192.168.4.0/24,10.8.0.7",restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEXAMPLE0000EXAMPLE0000EXAMPLE0000ex devbox-mymachine
KEY
chmod 600 ~/.ssh/authorized_keys
```
Both lines are safe on a peer that is already set up: `mkdir -p` is a no-op
when the directory exists, the `chmod`s only tighten, and appending never
touches keys already listed. Use `>>`, never `>` — one stray truncation
revokes every other key on that account. The options prefix must sit on the
**same physical line** as the key, comma-separated with no spaces: a paste
that wrapped is the likeliest reason a key that looks right is refused.
`ssh-copy-id` cannot add that prefix, so append by hand (or let it copy the
bare key and edit the line afterwards). If authentication still fails with no
clear reason, suspect permissions — sshd's `StrictModes` silently ignores
`authorized_keys` when the home directory, `~/.ssh` or the file itself is
group- or world-writable, and says why only in the peer's own log
(`journalctl -u ssh`, `/var/log/auth.log`).
`restrict` disables pty, agent/X11 and port forwarding; append
`port-forwarding` and `permitopen="127.0.0.1:<port>"` after it if you need one
specific tunnel. `from=` must list the **host's** addresses, not the
container's: container egress is NAT'd through the host, so the peer sees the
host's LAN address (confirm with `echo $SSH_CLIENT` on first login). List every
network the host roams — e.g. both home WLAN subnets plus its VPN address —
because a `from=` mismatch is indistinguishable from a wrong key in the error
message.
**3. On the host** — point the peer at that key in
`~/.config/devbox-shell/ssh-lan.conf`:
```
Host mypeer mypeer.home.arpa
HostName 192.168.1.142
User myuser
IdentityFile ~/.ssh-local/mypeer_devbox_ed25519
IdentitiesOnly yes
# ProxyJump host # only if the container cannot reach the peer directly
```
That path exists only inside containers, which is why it belongs here rather
than in the shared `~/.ssh/config`.
**4. First time only** — restart the container so the `Include` is emitted (see
above), then verify with the master socket bypassed, so a warm connection cannot
fake a pass:
```bash
ssh -F ~/.ssh-local/config -o ControlPath=none mypeer 'echo $SSH_CLIENT'
```
Use one key per machine (`devbox-mbp`, `devbox-studio`, …) so a single
`authorized_keys` line can be revoked without locking out the others.
### Smoke-testing a local build ### Smoke-testing a local build
@@ -794,9 +977,72 @@ pi-devbox follows semver-ish:
- **Minor** — new variants, significant base additions. - **Minor** — new variants, significant base additions.
- **Patch** — pi version bumps, smaller fixes. - **Patch** — pi version bumps, smaller fixes.
The `pi --version` inside the image is asserted by smoke tests to The `pi --version` inside the image is asserted by smoke tests to match the
match the release tag's pi component, so version drift between the version CI resolved (since v1.7.0, the pin below), so drift between what was
image and the tag is caught at CI time. intended and what actually got baked is caught at CI time rather than on a
user's pull.
### Version pins: pi, pi-atelier, mempalace
Three components are pinned to an exact version **in the repo** instead of being
resolved to `latest` at build time:
| Component | Pin | Where |
|---|---|---|
| pi | `0.84.2` | `ARG PI_VERSION``Dockerfile.variant` |
| pi-atelier | `v0.8.1` | `ARG PI_ATELIER_REF``Dockerfile.variant` |
| mempalace | `3.6.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
version is a deliberate, reviewable act, not a side effect of whatever happened
to be published the morning CI ran. Each of these has already drawn blood:
- **pi** — a minor release can move the private TUI/renderer internals that
pi-atelier wraps, or the session `.jsonl` format `pi-session-repair` parses.
- **pi-atelier** — 0.6.0/0.7.0 hang pi 0.84 **at startup**, burning CPU with no
error (fixed in 0.7.1/0.7.2). Its `peerDependencies` still say `>=0.80.7`, so
nothing in the npm metadata expresses the real floor.
- **mempalace** — an unpinned install once swept in the broken `diary_write` MCP
tool schema of 3.3.x/3.4.0, which is why that pin's comment requires a
tool-schema review before every bump.
CI enforces this rather than trusting it:
- `resolve-versions` reads the pins **out of the Dockerfiles** — single source of
truth, so a local `docker build` and a CI release ship the same versions — and
fails the build if a pin is not concrete, not a semver tag, or not actually
published on npm.
- When npm has a newer pi than the pin, CI emits a `::warning::` naming it. That
warning is the prompt to audit and bump; it never adopts the version.
- `smoke-test.sh` asserts the image's `pi --version` equals the pin, and
separately asserts the pairing rule **pi ≥ 0.84 ⇒ pi-atelier ≥ 0.7.1**, so a
bad combination fails the build instead of publishing a TUI that never starts.
To bump pi: read the upstream CHANGELOG for every intervening version (TUI/theme
API, session format, extension loader, Node engine floor), re-check pi-atelier's
CHANGELOG for the pi version it claims to track, then edit the one `ARG` line and
record what you checked in `CHANGELOG.md`.
#### If you previously hand-installed pi-atelier
A hand-installed `pi install npm:pi-atelier` lands in `~/.pi/npm-global`, which
is on the `devbox-pi-config` **volume** — so it outlives image upgrades and stays
at whatever version you installed, unpinned and unaudited. Since the image now
vendors an audited pi-atelier at `/opt/pi-atelier`, the entrypoint removes a
lingering `npm:pi-atelier` entry from `packages[]` (after backing
`settings.json` up to `settings.json.bak.atelier.<timestamp>`) and registers the
pinned `/opt` copy instead. Nothing else in your settings is touched, and the
npm-global copy itself is left on disk — only the registration changes.
This matters more than it sounds: leaving a 0.6.x npm copy registered alongside
pi 0.84 is precisely the combination that hangs at startup.
To opt out of pi-atelier entirely, set `DEVBOX_ATELIER=0` in `.env`. The
entrypoint then removes any pi-atelier entry from `packages[]` on start. That
switch lives in the entrypoint — not in a pi command — deliberately: this
component's failure mode is "pi will not start", which you cannot repair with
`pi uninstall`.
## Acknowledgements ## Acknowledgements
+36 -9
View File
@@ -5,6 +5,7 @@
# Point every client at it by setting, in that client's .env: # Point every client at it by setting, in that client's .env:
# #
# MEMPALACE_REMOTE_URL=http://<reachable-host>:8765/mcp # MEMPALACE_REMOTE_URL=http://<reachable-host>:8765/mcp
# MEMPALACE_REMOTE_TOKEN=<the shared bearer token>
# #
# (see .env.example). When set, the client connects over HTTP and does NOT # (see .env.example). When set, the client connects over HTTP and does NOT
# spawn its own local mempalace-mcp. # spawn its own local mempalace-mcp.
@@ -18,12 +19,21 @@
# (both are pinned by the same image build). Override with a slimmer image via # (both are pinned by the same image build). Override with a slimmer image via
# MEMPALACE_SERVER_IMAGE if you prefer (it must provide `mempalace-mcp`). # MEMPALACE_SERVER_IMAGE if you prefer (it must provide `mempalace-mcp`).
# #
# ⚠ SECURITY: mempalace-mcp's HTTP transport has NO authentication of its own. # ⚠ SECURITY: the HTTP transport IS authenticated as of mempalace 3.6.0 — an
# Do NOT expose port 8765 to an untrusted network. The default below binds to # earlier version of this comment said otherwise and was wrong. The server
# 127.0.0.1 (host loopback) only. To let sibling containers reach it, either # compares `Authorization: Bearer <token>` with hmac.compare_digest and
# attach them to the shared `mempalace-net` network (container-to-container, no # **refuses to start on a non-loopback bind without a token**, so
# host port needed — use http://mempalace-server:8765/mcp), or front it with a # MEMPALACE_REMOTE_TOKEN below is required, not optional: without it this
# reverse proxy that enforces MEMPALACE_REMOTE_TOKEN as `Authorization: Bearer`. # service crash-loops. It also pins `Host` and allowlists `Origin`.
#
# Still do not publish port 8765 to an untrusted network. The default binds to
# 127.0.0.1 (host loopback) only. To let sibling containers reach it, attach
# them to the shared `mempalace-net` network (container-to-container, no host
# port needed — use http://mempalace-server:8765/mcp). To reach it from
# elsewhere, terminate TLS in a tunnel/reverse proxy and let the bearer token be
# the authentication — do NOT add browser-shaped auth (SSO/PIN/password) in
# front, because every MCP client here is a headless JSON-RPC POST and would
# receive a login page where JSON should be.
name: mempalace-server name: mempalace-server
@@ -40,6 +50,11 @@ services:
user: "0:0" user: "0:0"
environment: environment:
- HOME=/data - HOME=/data
# Required: mempalace refuses a non-loopback bind without a token (it
# would exit at startup and, with restart:unless-stopped, crash-loop).
# `:?` fails fast at `docker compose up` with a readable message instead.
# Clients send the same value as MEMPALACE_REMOTE_TOKEN.
- MEMPALACE_MCP_HTTP_TOKEN=${MEMPALACE_REMOTE_TOKEN:?set MEMPALACE_REMOTE_TOKEN in .env — the shared palace requires a bearer token}
command: command:
- mempalace-mcp - mempalace-mcp
- --transport - --transport
@@ -60,16 +75,28 @@ services:
- mempalace-shared:/data/.mempalace - mempalace-shared:/data/.mempalace
# Embedding-model cache (~79 MB, disposable) so search does not re-download. # Embedding-model cache (~79 MB, disposable) so search does not re-download.
- mempalace-shared-chroma:/data/.cache/chroma - mempalace-shared-chroma:/data/.cache/chroma
# Transcript inbox. Clients cannot mine into a remote palace directly:
# `mempalace_mine` expands its source path in THIS process, so it can only
# see paths inside this container. Each client rsyncs its staged session
# exports to a per-device subdirectory on the host (see
# MEMPALACE_PI_SSH_TARGET in .env.example) and then calls mempalace_mine
# with the container-side path below (MEMPALACE_PI_REMOTE_PATH=/data/feed).
# Read-only: mining only reads sources, and all locks live palace-side.
- ${MEMPALACE_FEED_DIR:-./feed}:/data/feed:ro
networks: networks:
- mempalace-net - mempalace-net
healthcheck: healthcheck:
# A tools/list round-trip proves the server is answering MCP (python3 is # GET /healthz, which is Host/Origin-gated but deliberately token-free —
# always present — mempalace itself is a python tool in the image). # so this probe needs no credentials. Do NOT go back to POSTing
# `tools/list` here: that carries no Authorization header and now 401s,
# marking a perfectly healthy server unhealthy forever. The Host pin is
# only enforced on loopback *binds* (this one is 0.0.0.0), so a request to
# 127.0.0.1 inside the container passes.
test: test:
- CMD - CMD
- python3 - python3
- -c - -c
- "import urllib.request,json; d=json.dumps({'jsonrpc':'2.0','id':1,'method':'tools/list','params':{}}).encode(); r=urllib.request.Request('http://127.0.0.1:8765/mcp',data=d,headers={'Content-Type':'application/json','Accept':'application/json'}); urllib.request.urlopen(r,timeout=5).read()" - "import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://127.0.0.1:8765/healthz',timeout=5).status==200 else 1)"
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
+134 -4
View File
@@ -92,6 +92,82 @@ if command -v mempalace &>/dev/null && [ -d /workspace ]; then
fi fi
fi fi
# ── MemPalace: pi transcript feeder ─────────────────────────────────
# mempalace-toolkit ships `mempalace-pi-session`, which mines pi's own JSONL
# session transcripts into the palace. pi's mempalace extension drives it on
# session_shutdown and on a debounced agent_settled; this is the catch-up for
# the one case no handler can cover — a hard kill (docker kill, OOM, host
# reboot) runs nothing at all, so without this the previous life's transcripts
# are never mined.
#
# No MEMPALACE_PI_STAGE override here on purpose: the feeder stages next to the
# palace it feeds (<palace-root>/pi-stage), so the stage and the dedup keys
# referencing it share one lifetime — whatever persistence the palace has, the
# stage inherits. Pinning it elsewhere (e.g. into the ~/.pi volume) would
# re-introduce the very split that design prevents: palace volume kept, stage
# volume dropped, and `mempalace sync` then prunes every conversation drawer.
#
# Backgrounded: a cold mine can take tens of seconds and must never delay the
# shell. Contention with a live session is handled by the tool itself (it exits
# 0 and lets the palace holder do the mine).
# Self-heal onto PATH for images whose base predates the toolkit symlink.
# ~/.local/bin is already ahead of /usr/local/bin on PATH (Dockerfile.base sets
# it in ENV PATH) and is writable by this (non-root) user, unlike /usr/local/bin.
if [ -x /opt/mempalace-toolkit/bin/mempalace-pi-session ] && \
! command -v mempalace-pi-session >/dev/null 2>&1; then
mkdir -p "$HOME/.local/bin"
ln -sf /opt/mempalace-toolkit/bin/mempalace-pi-session "$HOME/.local/bin/mempalace-pi-session"
fi
# Resolve the feeder explicitly rather than trusting PATH: this runs before any
# login shell, and a silently-skipped catch-up is exactly the failure we are
# here to prevent.
MEMPALACE_FEEDER=""
if command -v mempalace-pi-session >/dev/null 2>&1; then
MEMPALACE_FEEDER="mempalace-pi-session"
elif [ -x /opt/mempalace-toolkit/bin/mempalace-pi-session ]; then
MEMPALACE_FEEDER="/opt/mempalace-toolkit/bin/mempalace-pi-session"
fi
if [ "${MEMPALACE_FEED:-1}" != "0" ] && [ -n "$MEMPALACE_FEEDER" ]; then
if [ -n "${MEMPALACE_REMOTE_URL:-}" ] && [ -z "${MEMPALACE_PI_SSH_TARGET:-}" ]; then
# Remote palace, but no inbox to ship transcripts to — the feeder genuinely
# cannot do anything here, so skipping is right. Saying so is the point:
# this branch used to be a bare `:`, and the skip happens *before* the
# subshell below that writes mempalace-catchup.log, so a container in this
# state contributed nothing to the palace and left no artifact at all — not
# even an empty log — to explain why. That is indistinguishable from a
# healthy run that simply had nothing to file. `tee` puts the notice both in
# the container's start output (docker logs) and at the path anyone
# debugging "why is nothing from this container in the palace?" looks first.
# This is an entrypoint: a notice must never be able to stop a container
# from starting. An unwritable ~/.pi (root-owned volume — a classic Docker
# permission accident) makes `mkdir -p` fail, and under `set -e` that would
# abort startup entirely: a brand-new failure mode in precisely the branch
# that used to do nothing at all. Degrade to stdout-only instead.
_mp_log="$HOME/.pi/agent/mempalace-catchup.log"
mkdir -p "$HOME/.pi/agent" 2>/dev/null || _mp_log=/dev/null
{
echo "MemPalace catch-up skipped: remote palace with no transcript inbox."
echo " MEMPALACE_REMOTE_URL is set (${MEMPALACE_REMOTE_URL})"
echo " but MEMPALACE_PI_SSH_TARGET is not, so there is nowhere to ship this"
echo " container's staged sessions. MCP tools still read and write the shared"
echo " palace — but this container's own conversations are mined nowhere."
echo " Fix: set MEMPALACE_PI_SSH_TARGET (and MEMPALACE_PI_DEVICE) in .env,"
echo " or unset MEMPALACE_REMOTE_URL to keep the palace local."
echo " Deliberate? MEMPALACE_FEED=0 turns the feed off and silences this."
} | tee "$_mp_log" 2>/dev/null || true
unset _mp_log
else
mkdir -p "$HOME/.pi/agent"
(
"$MEMPALACE_FEEDER" --reason container-start \
>"$HOME/.pi/agent/mempalace-catchup.log" 2>&1 || true
) &
fi
fi
# ── Git config defaults ────────────────────────────────────────────── # ── Git config defaults ──────────────────────────────────────────────
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
git config --global user.name "$GIT_USER_NAME" git config --global user.name "$GIT_USER_NAME"
@@ -169,9 +245,9 @@ if command -v pi &>/dev/null; then
"$HOME/.pi/agent/extensions/mempalace.ts" "$HOME/.pi/agent/extensions/mempalace.ts"
fi fi
# pi-fork (fork tool) + pi-observational-memory (recall tool) + (in the # pi-fork (fork tool) + pi-observational-memory (recall tool) + pi-atelier
# :latest-studio variant only) pi-studio (/studio command + studio_* # (TUI sidebar panels/split-pane) + (in the :latest-studio variant only)
# tools + theme). These are pi packages (not symlink-style extensions): # pi-studio (/studio command + studio_* tools + theme). These are pi packages (not symlink-style extensions):
# they're cloned to /opt with node_modules baked at BUILD time, then # they're cloned to /opt with node_modules baked at BUILD time, then
# registered here via `pi install <local-path>`. A local-path install is # registered here via `pi install <local-path>`. A local-path install is
# instant + in-place (pi loads the extension directly from /opt) + # instant + in-place (pi loads the extension directly from /opt) +
@@ -206,9 +282,63 @@ if command -v pi &>/dev/null; then
fi fi
} }
for _pkg in /opt/pi-fork /opt/pi-observational-memory /opt/pi-studio; do # ── pi-atelier: retire a stale `npm:pi-atelier`, plus an opt-out ──────
# The image now vendors pi-atelier at a pinned, audited tag (PI_ATELIER_REF
# in Dockerfile.variant). A leftover `npm:pi-atelier` entry from a
# hand-install resolves through ~/.pi/npm-global, which lives on the
# devbox-pi-config VOLUME — so it survives image upgrades and keeps whatever
# version was installed by hand, unpinned and unaudited. That is not
# academic: pi-atelier < 0.7.1 makes pi >= 0.84 hang at startup with
# sustained CPU, so leaving it in place turns a pi bump into a TUI that will
# not start. And `_pi_pkg_registered` deliberately counts `npm:<name>` as
# registered (it respects a user's own npm install), so the loop below would
# never replace it.
#
# We only DELETE the exact `npm:pi-atelier` string; the loop then registers
# /opt/pi-atelier in pi's own canonical serialization, so this code never has
# to guess the stored relative-path form. Idempotent — after the rewrite
# there is no npm entry left to match.
#
# DEVBOX_ATELIER=0 goes further and removes pi-atelier from `packages`
# altogether. That escape hatch lives HERE, in the entrypoint, precisely
# because this component's known failure mode is "pi will not start" — which
# you cannot repair with `pi uninstall`.
_pi_atelier_drop() {
# $1 = jq predicate over one `packages` entry, selecting what to REMOVE.
# Returns 0 only when the file was actually rewritten (caller logs), 1 for
# "nothing to do" — including missing jq or unparseable JSON, which must
# never clobber user settings. Backs up first, same convention as the
# template merge above.
_ad_settings="$HOME/.pi/agent/settings.json"
[ -f "$_ad_settings" ] || return 1
command -v jq >/dev/null 2>&1 || return 1
_ad_new=$(jq "(.packages // []) |= map(select(($1) | not))" "$_ad_settings" 2>/dev/null) || return 1
[ -n "$_ad_new" ] || return 1
if printf '%s' "$_ad_new" | jq -e --slurpfile cur "$_ad_settings" '. == $cur[0]' >/dev/null 2>&1; then
return 1
fi
# `.bak.atelier.` rather than the merge's plain `.bak.` prefix: both can
# fire in the same startup, and a bare seconds-resolution timestamp would
# make the second cp overwrite the first one's backup.
cp "$_ad_settings" "${_ad_settings}.bak.atelier.$(date +%Y%m%d-%H%M%S)"
printf '%s\n' "$_ad_new" > "$_ad_settings"
return 0
}
if [ "${DEVBOX_ATELIER:-1}" = "0" ]; then
if _pi_atelier_drop '(. == "npm:pi-atelier") or ((type == "string") and endswith("/pi-atelier"))'; then
echo "pi-atelier: unregistered per DEVBOX_ATELIER=0 (settings backup saved)"
fi
elif [ -d /opt/pi-atelier ]; then
if _pi_atelier_drop '. == "npm:pi-atelier"'; then
echo "pi-atelier: dropped stale npm: registration — the pinned /opt copy takes over (settings backup saved)"
fi
fi
for _pkg in /opt/pi-fork /opt/pi-observational-memory /opt/pi-studio /opt/pi-atelier; do
[ -d "$_pkg" ] || continue [ -d "$_pkg" ] || continue
_name=$(basename "$_pkg") _name=$(basename "$_pkg")
# DEVBOX_ATELIER=0 → leave pi-atelier unregistered (handled just above).
if [ "$_name" = "pi-atelier" ] && [ "${DEVBOX_ATELIER:-1}" = "0" ]; then continue; fi
if ! _pi_pkg_registered "$_name"; then if ! _pi_pkg_registered "$_name"; then
pi install "$_pkg" >/dev/null 2>&1 || \ pi install "$_pkg" >/dev/null 2>&1 || \
echo "WARN: pi install $_name failed (continuing)" echo "WARN: pi install $_name failed (continuing)"
+50
View File
@@ -218,6 +218,15 @@ _pkg_registered() {
fi fi
} }
# True when a literal `npm:pi-atelier` entry is still present — the
# volume-resident registration the entrypoint migrates away from.
_npm_atelier_present() {
_s="$HOME/.pi/agent/settings.json"
[ -f "$_s" ] || return 1
command -v jq >/dev/null 2>&1 || return 1
jq -e '(.packages // []) | any(. == "npm:pi-atelier")' "$_s" >/dev/null 2>&1
}
if [ -f "$HOME/.pi/agent/settings.json" ]; then if [ -f "$HOME/.pi/agent/settings.json" ]; then
for pkg in pi-fork pi-observational-memory; do for pkg in pi-fork pi-observational-memory; do
if _pkg_registered "$pkg"; then if _pkg_registered "$pkg"; then
@@ -234,6 +243,47 @@ if [ -f "$HOME/.pi/agent/settings.json" ]; then
fail "pi-studio NOT in settings.json packages[] (studio variant)" fail "pi-studio NOT in settings.json packages[] (studio variant)"
fi fi
fi fi
# pi-atelier — vendored from v1.7.0 on. Absent on older images, and
# deliberately unregistered when DEVBOX_ATELIER=0; neither is a failure.
if [ -d /opt/pi-atelier ]; then
if [ "${DEVBOX_ATELIER:-1}" = "0" ]; then
if _pkg_registered pi-atelier; then
fail "pi-atelier still in packages[] despite DEVBOX_ATELIER=0"
else
pass "pi-atelier unregistered (DEVBOX_ATELIER=0, as requested)"
fi
elif _pkg_registered pi-atelier; then
pass "pi-atelier registered in settings.json packages[]"
else
fail "pi-atelier NOT in settings.json packages[] (sidebar will not load)"
fi
if _npm_atelier_present; then
fail "stale npm:pi-atelier still in packages[] — it resolves through the ~/.pi/npm-global VOLUME and shadows the pinned /opt copy (entrypoint migration did not run)"
fi
fi
fi
# ── pi <-> pi-atelier compatibility floor ─────────────────────────────
# atelier < 0.7.1 wraps pi's private TUI renderer in a way that recurses under
# pi >= 0.84: pi hangs at startup burning CPU, with no error message. atelier's
# own peerDependencies (>=0.80.7) do not encode this. Assert it here too, not
# just in the build-time smoke test: this script runs after a real
# `--force-recreate` on a live box, where a volume-resident old copy is exactly
# what could bite.
if [ -d /opt/pi-atelier ] && command -v jq >/dev/null 2>&1; then
_ge() { [ "$(printf '%s\n%s\n' "$1" "$2" | sort -V | head -n1)" = "$2" ]; }
_av=$(jq -r '.version // empty' /opt/pi-atelier/package.json 2>/dev/null || true)
_pv=$(pi --version 2>/dev/null | grep -oE '[0-9]+\.[0-9]+\.[0-9]+' | head -n1 || true)
if [ -n "$_av" ] && [ -n "$_pv" ]; then
if _ge "$_pv" 0.84.0 && ! _ge "$_av" 0.7.1; then
fail "pi $_pv with pi-atelier $_av — atelier < 0.7.1 hangs pi >= 0.84 at startup (bump PI_ATELIER_REF in Dockerfile.variant)"
else
pass "pi $_pv + pi-atelier $_av (compatibility floor OK)"
fi
else
warn "could not compare pi/pi-atelier versions (pi='$_pv' atelier='$_av')"
fi
fi fi
echo echo
+145 -1
View File
@@ -13,6 +13,8 @@
# - tmux 0-indexing baked in /etc/tmux.conf (required for pi-studio variants) # - tmux 0-indexing baked in /etc/tmux.conf (required for pi-studio variants)
# - pi-toolkit cloned at /opt/pi-toolkit # - pi-toolkit cloned at /opt/pi-toolkit
# - pi-extensions cloned at /opt/pi-extensions # - pi-extensions cloned at /opt/pi-extensions
# - pi-atelier vendored at /opt/pi-atelier, registered from /opt (not npm:),
# and >= the version floor pi's TUI requires (see the floor test)
# - pi-fork + pi-observational-memory cloned with node_modules baked # - pi-fork + pi-observational-memory cloned with node_modules baked
# - entrypoint deploys pi-toolkit keybindings symlink # - entrypoint deploys pi-toolkit keybindings symlink
# - entrypoint deploys ≥4 extensions # - entrypoint deploys ≥4 extensions
@@ -41,12 +43,23 @@ PASS=0; FAIL=0
# catching an unexpected +GB regression. # catching an unexpected +GB regression.
SIZE_THRESHOLD_MB=3800 SIZE_THRESHOLD_MB=3800
# On failure, surface the last few lines the command produced. This used to
# discard output entirely (`>/dev/null 2>&1`), which made a red ❌ carry zero
# diagnostic weight: explaining the single v1.8.0 stage-default failure took a
# full CI-log dig plus a registry-config inspection, when the container had
# already printed the answer and thrown it away. Assertions that want a
# diagnostic just echo it to stderr — it stays hidden while they pass.
run() { run() {
local label="$1"; local cmd="$2" local label="$1"; local cmd="$2"
if docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd" >/dev/null 2>&1; then local out
if out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd" 2>&1); then
printf " ✅ %s\n" "$label"; PASS=$((PASS+1)) printf " ✅ %s\n" "$label"; PASS=$((PASS+1))
else else
printf " ❌ %s\n" "$label"; FAIL=$((FAIL+1)) printf " ❌ %s\n" "$label"; FAIL=$((FAIL+1))
# `if`, not `&&` — a trailing false under `set -e` would abort the script.
if [ -n "$out" ]; then
printf " └─ %s\n" "$(printf '%s' "$out" | tail -3 | tr '\n' ' ' | cut -c1-300)"
fi
fi fi
} }
@@ -89,6 +102,99 @@ run "terminfo: modern emulators (ncurses-term)" 'for t in wezterm alacritty foot
run "terminfo: xterm-ghostty alias (tic)" "infocmp -x xterm-ghostty >/dev/null 2>&1" run "terminfo: xterm-ghostty alias (tic)" "infocmp -x xterm-ghostty >/dev/null 2>&1"
run "nvim true-colour default (sysinit.vim)" "nvim --headless -c 'lua os.exit(vim.o.termguicolors and 0 or 1)'" run "nvim true-colour default (sysinit.vim)" "nvim --headless -c 'lua os.exit(vim.o.termguicolors and 0 or 1)'"
run "mempalace-mcp" "mempalace-mcp --help" run "mempalace-mcp" "mempalace-mcp --help"
run "mempalace-pi-session on PATH" "mempalace-pi-session --help"
# The staging dir must sit next to the palace, not in a disposable cache: the
# palace keys per-source dedup on the STAGED path, so a stage that can be wiped
# while the palace survives lets `mempalace sync` prune every drawer mined from
# it. Assert the resolved default, not an env var — the guarantee is "stage
# shares the palace's lifetime", which an ENV pin would quietly break.
# NOTE: --sessions-dir gets an EMPTY temp dir, never /tmp. The stage banner is
# printed before any export, so nothing needs to be found — and pointing a
# default-staged run at a populated dir would export whatever transcripts it
# finds into the real stage, which is how a synthetic test session ends up
# staged for mining as if it were a real conversation.
#
# Asserted $HOME-RELATIVE, not against a literal /home/developer. `run` invokes
# `docker run --entrypoint=""`, and neither Dockerfile sets USER or ENV HOME
# (HOME is set by entrypoint-user.sh, which --entrypoint="" deliberately skips),
# so these assertions execute as root with HOME=/root. The original literal
# /home/developer form could therefore never match and failed the v1.8.0
# release — a test bug, not a product one: the stage resolution was correct all
# along, it just follows $HOME. The invariant under test ("the stage sits beside
# the palace, sharing its lifetime") is user-independent, so pinning the user
# was never part of it. A cache-dir default still fails the pattern below, which
# is the regression this guards.
#
# It went unnoticed for three days because this workflow only triggers on
# `push: tags: v*` — the assertion was added on a main push, so v1.8.0 was its
# first execution ever. Use the `smoke_only` workflow_dispatch input to run
# smoke against HEAD without cutting a tag.
run "pi stage defaults next to the palace (not a cache dir)" '
out=$(mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true
stage=$(echo "$out" | grep -oE "stage=[^ ]+" | head -1)
echo "resolved ${stage:-<no stage= line>} with HOME=$HOME" >&2
case "$stage" in
"stage=$HOME/.mempalace/pi-stage/"*) exit 0 ;;
*) exit 1 ;;
esac
'
# Companion to the above: the deployment-specific case the literal assertion was
# reaching for, done properly by supplying the HOME the container actually runs
# with instead of assuming it.
run "pi stage is palace-adjacent for the developer user" '
out=$(HOME=/home/developer mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true
echo "$out" | grep -oE "stage=[^ ]+" | head -1 >&2
echo "$out" | grep -q "stage=/home/developer/.mempalace/pi-stage/"
'
run "pi stage follows MEMPALACE_PALACE_PATH" '
out=$(MEMPALACE_PALACE_PATH=/tmp/alt/.mempalace/palace \
mempalace-pi-session --dry-run --reason smoke --sessions-dir "$(mktemp -d)" 2>&1) || true
echo "$out" | grep -q "stage=/tmp/alt/.mempalace/pi-stage/"
'
# Regression guard for the pi transcript exporter. If pi ever changes its
# session JSONL shape, the exporter stops recognising sessions and the palace
# silently gets nothing (or, worse, raw JSON chunked as prose). Feed it a
# synthetic session and assert it is actually exported. Uses --dry-run so no
# palace is touched, and a temp stage so nothing real is written.
run "pi transcript exporter recognises a pi session" '
set -e
d=$(mktemp -d); s="$d/sessions/--workspace--"; mkdir -p "$s"
{
printf "%s\n" "{\"type\":\"session\",\"version\":1,\"id\":\"smoke\",\"cwd\":\"/workspace\",\"timestamp\":\"2026-01-01T00:00:00Z\"}"
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"question one\"}}"
a=$(printf "a%.0s" $(seq 1 1200))
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"$a\"}]}}"
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"question two\"}}"
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"short reply\"}]}}"
} > "$s/2026-01-01T00-00-00-000Z_smoke.jsonl"
out=$(mempalace-pi-session --dry-run --sessions-dir "$d/sessions" --stage "$d/stage" 2>&1)
echo "$out" | grep -q "Exported 1 session"
'
# The same guard from the other side: a session with no real assistant output
# (an abandoned prompt, whose bulk is injected skill text) must NOT be filed.
run "pi transcript exporter rejects an abandoned session" '
set -e
d=$(mktemp -d); s="$d/sessions/--workspace--"; mkdir -p "$s"
{
printf "%s\n" "{\"type\":\"session\",\"version\":1,\"id\":\"smoke2\",\"cwd\":\"/workspace\",\"timestamp\":\"2026-01-01T00:00:00Z\"}"
u=$(printf "u%.0s" $(seq 1 13000))
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"user\",\"content\":\"$u\"}}"
printf "%s\n" "{\"type\":\"message\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Ready. What would you like to work on?\"}]}}"
} > "$s/2026-01-01T00-00-00-000Z_smoke2.jsonl"
out=$(mempalace-pi-session --dry-run --sessions-dir "$d/sessions" --stage "$d/stage" 2>&1)
echo "$out" | grep -q "no sessions qualified"
'
# The remote-palace-without-inbox skip must ANNOUNCE itself, not vanish. This
# branch of entrypoint-user.sh runs at container start (not reachable from a
# `docker run` one-shot), so assert against the entrypoint that actually shipped
# in the image. Guards a silent regression back to the bare `:` no-op, which
# left a container contributing nothing to the palace with no artifact saying
# why — the log it would normally leave is written by the other branch.
run_expect "remote-palace-without-inbox skip is announced, not silent" \
"grep -o 'MemPalace catch-up skipped' /usr/local/bin/entrypoint-user.sh | head -1" \
"MemPalace catch-up skipped"
run "...and the skip notice names the variable that fixes it" \
"grep -A6 'MemPalace catch-up skipped' /usr/local/bin/entrypoint-user.sh | grep -q 'MEMPALACE_PI_SSH_TARGET'"
# v1.0.0 base additions — verify presence and basic functionality. # v1.0.0 base additions — verify presence and basic functionality.
run "pandoc" "pandoc --version" run "pandoc" "pandoc --version"
run "typst" "typst --version" run "typst" "typst --version"
@@ -137,6 +243,24 @@ run "pi-fork clone + node_modules" \
"test -f /opt/pi-fork/package.json && test -d /opt/pi-fork/node_modules" "test -f /opt/pi-fork/package.json && test -d /opt/pi-fork/node_modules"
run "pi-observational-memory clone + node_modules" \ run "pi-observational-memory clone + node_modules" \
"test -f /opt/pi-observational-memory/package.json && test -d /opt/pi-observational-memory/node_modules" "test -f /opt/pi-observational-memory/package.json && test -d /opt/pi-observational-memory/node_modules"
# pi-atelier: deliberately NO node_modules assertion, unlike its siblings —
# it declares zero runtime dependencies (only peerDeps, satisfied by the baked
# pi) and has no build step, so Dockerfile.variant skips `npm install` for it.
# Assert what pi actually loads instead: the entry point named by its
# package.json `pi.extensions` key.
run "pi-atelier clone + entry point" \
"test -f /opt/pi-atelier/package.json && test -f /opt/pi-atelier/extensions/index.ts"
# ── pi <-> pi-atelier compatibility floor (executable, not a comment) ──
# pi-atelier < 0.7.1 wraps pi's PRIVATE TUI renderer in a way that recurses
# under pi >= 0.84: pi hangs at startup burning CPU, with no error. Upstream
# fixed it in 0.7.1/0.7.2, but atelier's peerDependencies still say
# `>=0.80.7`, so neither npm nor pi can warn about the real floor. Both
# versions are pinned in Dockerfile.variant; this makes a bad PAIRING fail the
# build instead of publishing an image whose TUI never starts.
run_expect "pi-atelier >= 0.7.1 floor for pi >= 0.84 (startup-hang guard)" \
'ge() { [ "$(printf "%s\n%s\n" "$1" "$2" | sort -V | head -n1)" = "$2" ]; }; AV=$(jq -r ".version // empty" /opt/pi-atelier/package.json 2>/dev/null); PV=$(pi --version 2>/dev/null | grep -oE "[0-9]+\.[0-9]+\.[0-9]+" | head -n1); if [ -z "$AV" ] || [ -z "$PV" ]; then echo "unreadable versions (atelier=$AV pi=$PV)"; elif ge "$PV" 0.84.0 && ! ge "$AV" 0.7.1; then echo "VIOLATION: pi $PV with pi-atelier $AV"; else echo "compatible: pi $PV + pi-atelier $AV"; fi' \
"compatible:"
# pi-studio is present only in the :latest-studio variant. Auto-detect by # pi-studio is present only in the :latest-studio variant. Auto-detect by
# probing /opt/pi-studio so this one script covers both variants. # probing /opt/pi-studio so this one script covers both variants.
@@ -159,6 +283,8 @@ run "/etc/pi-devbox/build-manifest.json present" \
"test -f /etc/pi-devbox/build-manifest.json" "test -f /etc/pi-devbox/build-manifest.json"
run_expect "manifest records pi-extensions component" \ run_expect "manifest records pi-extensions component" \
"cat /etc/pi-devbox/build-manifest.json" '"pi-extensions"' "cat /etc/pi-devbox/build-manifest.json" '"pi-extensions"'
run_expect "manifest records pi-atelier" \
"cat /etc/pi-devbox/build-manifest.json" '"pi-atelier"'
run_expect "manifest records pi_version" \ run_expect "manifest records pi_version" \
"cat /etc/pi-devbox/build-manifest.json" '"pi_version"' "cat /etc/pi-devbox/build-manifest.json" '"pi_version"'
# Every component must be a resolved commit (or null for pi-studio in the # Every component must be a resolved commit (or null for pi-studio in the
@@ -271,6 +397,24 @@ if [ "${STUDIO_VARIANT:-0}" = "1" ]; then
"$(pkg_registered_cmd pi-studio)" "$(pkg_registered_cmd pi-studio)"
fi fi
# pi-atelier registration. It is LAST in the entrypoint's install loop, so a
# pass here also means that loop ran to completion rather than dying midway.
for i in $(seq 1 15); do
if docker exec -u developer "$CID" sh -c "$(pkg_registered_cmd pi-atelier)" \
>/dev/null 2>&1; then
break
fi
sleep 1
done
exec_test "pi-atelier registered in packages[] (TUI sidebar)" \
"$(pkg_registered_cmd pi-atelier)"
# ...and registered from the vendored /opt copy, NOT as `npm:pi-atelier`: an
# npm: entry resolves through ~/.pi/npm-global on the config VOLUME, which
# outlives image upgrades and would silently keep an old, unaudited atelier —
# exactly the shape that pairs a stale 0.6.x with a new pi and hangs at startup.
exec_test "pi-atelier registered from /opt, not npm: (volume-shadowing guard)" \
'jq -e "((.packages // []) | any((type == \"string\") and endswith(\"/pi-atelier\"))) and (((.packages // []) | any(. == \"npm:pi-atelier\")) | not)" $HOME/.pi/agent/settings.json'
# ── /tmp/sshcm directory created by entrypoint ──────────────────────── # ── /tmp/sshcm directory created by entrypoint ────────────────────────
exec_test "/tmp/sshcm dir mode 700 (ssh ControlMaster)" \ exec_test "/tmp/sshcm dir mode 700 (ssh ControlMaster)" \
'test -d /tmp/sshcm && [ "$(stat -c %a /tmp/sshcm)" = "700" ] && echo ok' 'test -d /tmp/sshcm && [ "$(stat -c %a /tmp/sshcm)" = "700" ] && echo ok'