Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 93f986e90e | |||
| 26f223568d | |||
| 01abda3456 | |||
| b5810654f6 | |||
| 4f6f470518 | |||
| fbc1f86612 | |||
| 2ebf00d6d4 | |||
| c3b6d36778 | |||
| 3a509077c2 | |||
| a55f6369b3 | |||
| ffd54750b9 | |||
| d8b745c164 | |||
| ae13c2264e | |||
| a2f0a4a441 | |||
| 53b41cd76b | |||
| 29b62093f0 | |||
| cbd7cf5c67 | |||
| 7c00dd6001 | |||
| 7649d53f3b | |||
| ade58131d6 | |||
| ffd44ad9cf | |||
| 43cd6e22f2 | |||
| 62a2a79b1c | |||
| f20b2a7926 | |||
| 66a19aa394 | |||
| 572430237f | |||
| 1fd524e7fb |
+83
-3
@@ -12,16 +12,81 @@ 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.
|
||||||
|
#
|
||||||
|
# That palace root is resolved with mempalace's own precedence
|
||||||
|
# ($MEMPALACE_PALACE_PATH -> $MEMPAL_PALACE_PATH -> ~/.mempalace/config.json ->
|
||||||
|
# ~/.mempalace/palace), and the feeders derive their stage FROM it
|
||||||
|
# (<palace-root>/pi-stage). Neither the image nor the entrypoint exports it, by
|
||||||
|
# design: pinning the palace without carrying the stage along re-creates the
|
||||||
|
# very split that a shared root removed. Override it only to move the palace off
|
||||||
|
# the default -- e.g. onto a different mount -- and only to a path with the SAME
|
||||||
|
# persistence as the palace itself. A stage that outlives its palace (or dies
|
||||||
|
# first) makes a scoped `mempalace sync` prune conversation drawers, because
|
||||||
|
# their dedup key is the staged path. Setting it to the default buys nothing.
|
||||||
|
# Unlike WORKSPACE_PATH/SSH_KEY_PATH above, this is a path INSIDE the container.
|
||||||
|
# MEMPALACE_PALACE_PATH=/home/developer/.mempalace/palace
|
||||||
#
|
#
|
||||||
# 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 — i.e. the
|
||||||
|
# path the SERVER PROCESS can open. If the palace
|
||||||
|
# server runs in Docker, that is the container path
|
||||||
|
# (see docker-compose.mempalace.yml). If it runs
|
||||||
|
# NATIVELY (systemd unit / uv tool / plain
|
||||||
|
# `mempalace serve`), it sees host paths, so this
|
||||||
|
# must equal the path half of
|
||||||
|
# MEMPALACE_PI_SSH_TARGET. Getting this wrong is
|
||||||
|
# quiet: rsync still succeeds and only the mine
|
||||||
|
# fails with "source directory not found", so
|
||||||
|
# transcripts ship and are filed nowhere. The feeder
|
||||||
|
# warns in preflight when the two paths disagree.
|
||||||
|
# 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 +108,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=
|
||||||
|
|
||||||
|
|||||||
@@ -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,69 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# pi version from npm (catthehacker/ubuntu:act-latest's npm is not
|
# Read a commit SHA from Gitea, surviving a bad build token.
|
||||||
# reliably on PATH in act_runner job containers, so query directly).
|
#
|
||||||
PI_VERSION=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version' 2>/dev/null || true)
|
# These repos are public (see the note at the call sites), so auth is
|
||||||
if ! printf '%s' "${PI_VERSION:-}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then
|
# a convenience, not a requirement — but Gitea REJECTS an invalid
|
||||||
echo "::error::Could not resolve pi version from npm (got '${PI_VERSION:-<empty>}')."
|
# token (401) rather than ignoring it, so a revoked or malformed
|
||||||
|
# GITEA_BUILD_TOKEN could fail an entire release on reads that work
|
||||||
|
# fine anonymously. An ABSENT secret was always safe (Gitea ignores an
|
||||||
|
# empty `token ` value and serves the request, 200); a STALE one was
|
||||||
|
# not. So: try authed, and on 401/403 retry anonymously.
|
||||||
|
#
|
||||||
|
# A non-200 after that emits nothing and returns 0 deliberately, so
|
||||||
|
# require_sha raises the loud explicit abort rather than this helper
|
||||||
|
# inventing a fallback ref.
|
||||||
|
#
|
||||||
|
# Messages go to STDERR, not as ::warning:: annotations: this
|
||||||
|
# function's stdout IS the SHA, so anything written there would be
|
||||||
|
# captured into the ref by the command substitution.
|
||||||
|
gitea_sha() { # $1=repo
|
||||||
|
local repo="$1" url resp code
|
||||||
|
url="https://gitea.jordbo.se/api/v1/repos/joakimp/${repo}/commits?limit=1&sha=main"
|
||||||
|
resp=$(curl -s -w '\n%{http_code}' -H "$AUTH_HEADER" "$url" || printf '\n000')
|
||||||
|
code=${resp##*$'\n'}
|
||||||
|
if [ "$code" = "401" ] || [ "$code" = "403" ]; then
|
||||||
|
printf 'WARNING: Gitea rejected the build token for %s (HTTP %s); retrying anonymously. The read should succeed (public repo), but GITEA_BUILD_TOKEN is stale or malformed and should be rotated.\n' "$repo" "$code" >&2
|
||||||
|
resp=$(curl -s -w '\n%{http_code}' "$url" || printf '\n000')
|
||||||
|
code=${resp##*$'\n'}
|
||||||
|
fi
|
||||||
|
if [ "$code" != "200" ]; then
|
||||||
|
printf 'WARNING: Gitea commit lookup for %s returned HTTP %s\n' "$repo" "$code" >&2
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
printf '%s' "${resp%$'\n'*}" | jq -r '.[0].sha // empty' 2>/dev/null || true
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── pi version: from the PIN, not from npm `latest` ───────────
|
||||||
|
# Until v1.7.0 this followed 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 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,15 +252,47 @@ 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-toolkit / pi-extensions (Gitea) → commit SHAs. Gitea API
|
# pi-atelier → the PINNED TAG's commit SHA. Unlike fork/obsmem
|
||||||
# requires auth even for public-repo commit listing.
|
# (which track a branch head) atelier wraps pi's private TUI
|
||||||
TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \
|
# renderer, so its version is pinned in Dockerfile.variant and read
|
||||||
"https://gitea.jordbo.se/api/v1/repos/joakimp/pi-toolkit/commits?limit=1&sha=main" \
|
# from there; we only resolve tag → SHA, for reproducibility and to
|
||||||
| jq -r '.[0].sha // empty' 2>/dev/null || true)
|
# 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. All three Gitea
|
||||||
|
# repos read in this step are PUBLIC: an unauthenticated GET of these
|
||||||
|
# commit endpoints returns 200 with the IDENTICAL sha (verified
|
||||||
|
# 2026-08-15 for pi-toolkit, pi-extensions and mempalace-toolkit).
|
||||||
|
# The comment that used to sit here claimed the Gitea API "requires
|
||||||
|
# auth even for public-repo commit listing" — it does not. Only
|
||||||
|
# /api/v1/repos/*/actions/* refuses anonymous reads (401), which is
|
||||||
|
# what that claim was almost certainly generalised from.
|
||||||
|
#
|
||||||
|
# The header is still passed on purpose: it keeps working if a repo is
|
||||||
|
# ever flipped private, and an ABSENT secret degrades cleanly, because
|
||||||
|
# Gitea ignores an empty `token ` value and serves the request
|
||||||
|
# anonymously (200). The real hazard is the opposite one — a REVOKED or
|
||||||
|
# malformed token returns 401 where anonymous would have returned 200,
|
||||||
|
# so a stale GITEA_BUILD_TOKEN turns a healthy public read into a
|
||||||
|
# require_sha failure that reads like an API or network fault. If this
|
||||||
|
# step ever fails on a repo you can browse anonymously, suspect the
|
||||||
|
# token before you suspect Gitea.
|
||||||
|
TOOLKIT_REF=$(gitea_sha pi-toolkit)
|
||||||
require_sha PI_TOOLKIT_REF "$TOOLKIT_REF"
|
require_sha PI_TOOLKIT_REF "$TOOLKIT_REF"
|
||||||
EXTENSIONS_REF=$(curl -sf -H "$AUTH_HEADER" \
|
EXTENSIONS_REF=$(gitea_sha pi-extensions)
|
||||||
"https://gitea.jordbo.se/api/v1/repos/joakimp/pi-extensions/commits?limit=1&sha=main" \
|
|
||||||
| jq -r '.[0].sha // empty' 2>/dev/null || true)
|
|
||||||
require_sha PI_EXTENSIONS_REF "$EXTENSIONS_REF"
|
require_sha PI_EXTENSIONS_REF "$EXTENSIONS_REF"
|
||||||
echo "toolkit_ref=${TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
|
echo "toolkit_ref=${TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
|
||||||
echo "extensions_ref=${EXTENSIONS_REF}" >> "$GITHUB_OUTPUT"
|
echo "extensions_ref=${EXTENSIONS_REF}" >> "$GITHUB_OUTPUT"
|
||||||
@@ -194,9 +302,7 @@ jobs:
|
|||||||
# into the base-decide hash (see that job) to force a base rebuild
|
# into the base-decide hash (see that job) to force a base rebuild
|
||||||
# when the toolkit moves — otherwise a toolkit-only fix silently
|
# when the toolkit moves — otherwise a toolkit-only fix silently
|
||||||
# fails to land unless Dockerfile.base itself changes.
|
# fails to land unless Dockerfile.base itself changes.
|
||||||
MEMPALACE_TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \
|
MEMPALACE_TOOLKIT_REF=$(gitea_sha mempalace-toolkit)
|
||||||
"https://gitea.jordbo.se/api/v1/repos/joakimp/mempalace-toolkit/commits?limit=1&sha=main" \
|
|
||||||
| jq -r '.[0].sha // empty' 2>/dev/null || true)
|
|
||||||
require_sha MEMPALACE_TOOLKIT_REF "$MEMPALACE_TOOLKIT_REF"
|
require_sha MEMPALACE_TOOLKIT_REF "$MEMPALACE_TOOLKIT_REF"
|
||||||
echo "mempalace_toolkit_ref=${MEMPALACE_TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
|
echo "mempalace_toolkit_ref=${MEMPALACE_TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
@@ -230,7 +336,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 +464,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 +526,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 +538,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 +590,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 +611,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 +638,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 +685,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 +707,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}" \
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|
||||||
|
|||||||
@@ -76,7 +76,15 @@ 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 fires **only**
|
||||||
|
`docker-publish.yml` — `lint.yml` is scoped to `branches: ['**']`, which
|
||||||
|
excludes tag refs on purpose (the tagged tree was already linted when the
|
||||||
|
commit hit `main`, and a fast lint run sorting above the slow publish run
|
||||||
|
made releases look finished before anything shipped). Verified on v1.8.4:
|
||||||
|
`refs/tags/v1.8.4` produced run 571 (publish) and nothing else. Still filter
|
||||||
|
discovery on `head_sha` **and** the workflow `path` — see *Gitea API access*
|
||||||
|
below — because that guard costs nothing and a future workflow added on `v*`
|
||||||
|
would silently reintroduce the ambiguity.
|
||||||
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
|
||||||
@@ -84,6 +92,34 @@ re-brand of opencode-devbox's `pi-only` variant.
|
|||||||
`GITEA_ACCESS_TOKEN` env var instead (see *Gitea API access* below) —
|
`GITEA_ACCESS_TOKEN` env var instead (see *Gitea API access* below) —
|
||||||
its lifecycle is managed host-side, nothing to revoke.
|
its lifecycle is managed host-side, nothing to revoke.
|
||||||
|
|
||||||
|
## Verifying this repo's reality from inside a container
|
||||||
|
|
||||||
|
Most work on this repo happens **inside** a pi-devbox container, inspecting a
|
||||||
|
host or a peer over SSH. That setup manufactures convincing false negatives, so
|
||||||
|
when you are about to report that something is **absent, unreachable, or not
|
||||||
|
running**, suspect your own command first. Recurring instances:
|
||||||
|
|
||||||
|
- **`docker` is not on the host's non-interactive SSH `PATH`.** `ssh mac 'docker
|
||||||
|
ps'` says *command not found* on a host that plainly runs Docker; use
|
||||||
|
`/usr/local/bin/docker` (or `command -v docker` first). Every step in the
|
||||||
|
*Release-day checklist* that inspects a running container hits this.
|
||||||
|
- **Don't `| head -N` a search whose answer you don't already know.** The host's
|
||||||
|
`~/.ssh/config` is ~500 lines; a `head -20` "proved" a peer absent that was
|
||||||
|
defined at line 454.
|
||||||
|
- **The deployment compose file is not this repo's.** `docker-compose.yml` here
|
||||||
|
is a template pinning `:latest`; a real host runs its own per-machine file
|
||||||
|
(find it with `docker inspect <container> --format '{{ index .Config.Labels
|
||||||
|
"com.docker.compose.project.config_files" }}'`). Recreating from the repo copy
|
||||||
|
can silently move a host off `:latest-studio` onto `:latest`.
|
||||||
|
- **A live SSH ControlMaster hides remote auth changes** — after editing a
|
||||||
|
peer's `authorized_keys`, prove access with `-o ControlPath=none -o
|
||||||
|
ControlMaster=no`, or the breakage surfaces in a later session instead.
|
||||||
|
|
||||||
|
Depth and further mechanisms: the repo-authored `pi-devbox-environment` skill
|
||||||
|
(`rootfs/usr/local/share/pi-devbox/skills/pi-devbox-environment/SKILL.md`) §2
|
||||||
|
and §3 — that file is the one an agent actually loads mid-session, whereas this
|
||||||
|
`AGENTS.md` is only auto-read when the cwd *is* this repo.
|
||||||
|
|
||||||
## Gitea API access (env token)
|
## Gitea API access (env token)
|
||||||
|
|
||||||
`GITEA_ACCESS_TOKEN` + `GITEA_HOST` are passed into the container from the
|
`GITEA_ACCESS_TOKEN` + `GITEA_HOST` are passed into the container from the
|
||||||
@@ -92,13 +128,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
|
||||||
|
|||||||
+1100
File diff suppressed because it is too large
Load Diff
+10
-2
@@ -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
|
||||||
@@ -64,12 +65,19 @@ The entrypoint deploys/registers all of these on first container start. Re-runni
|
|||||||
### Document and image tooling
|
### Document and image tooling
|
||||||
|
|
||||||
- **pandoc** — universal Markdown↔HTML/Org/RST/etc. conversion. Useful well beyond pi: agent-driven doc exports, format conversion, etc.
|
- **pandoc** — universal Markdown↔HTML/Org/RST/etc. conversion. Useful well beyond pi: agent-driven doc exports, format conversion, etc.
|
||||||
|
- **Typst** — markup-based typesetting, used as pandoc's `--pdf-engine`
|
||||||
- **graphviz** (`dot`) — diagram rendering pipelines
|
- **graphviz** (`dot`) — diagram rendering pipelines
|
||||||
- **imagemagick** (`magick`) — image conversion / resizing
|
- **imagemagick** (`magick`) — image conversion / resizing
|
||||||
|
|
||||||
|
### Browser automation
|
||||||
|
|
||||||
|
- **agent-browser** — CLI for driving a real browser (open pages, click/fill/`eval`, snapshot the DOM, screenshots) so agents can verify front-end work instead of guessing
|
||||||
|
- **Playwright** + a headless **Chromium** are pre-installed and pinned together; `AGENT_BROWSER_EXECUTABLE_PATH` is preset to the baked browser, so `agent-browser open <url>` works out of the box with no setup
|
||||||
|
- **socat** — TCP bridge used to expose the pi-studio server outside the container's loopback
|
||||||
|
|
||||||
### Modern CLI tooling
|
### Modern CLI tooling
|
||||||
|
|
||||||
- **Editor**: neovim (LazyVim defaults), tmux (configured for 0-indexed sessions)
|
- **Editor**: neovim (system-wide `termguicolors` default; bring your own config/plugins), tmux (configured for 0-indexed sessions)
|
||||||
- **Search/nav**: ripgrep, fd, fzf, zoxide
|
- **Search/nav**: ripgrep, fd, fzf, zoxide
|
||||||
- **Display**: bat, eza, htop, tree
|
- **Display**: bat, eza, htop, tree
|
||||||
- **Data**: jq, yq
|
- **Data**: jq, yq
|
||||||
|
|||||||
+73
-5
@@ -367,13 +367,73 @@ 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.
|
||||||
|
#
|
||||||
|
# 3.7.1 (from 3.6.0) is safe for anyone with an EXISTING LOCAL palace: verified
|
||||||
|
# against the 3.7.1 source, not the changelog. Legacy drawers lack the new
|
||||||
|
# `chunk_total` marker and both decision sites trust them ("trust the match as
|
||||||
|
# before"), NORMALIZE_VERSION is 2 in both, chromadb stays <2 (no index-format
|
||||||
|
# migration), there is no auto-migration ("We do NOT auto-migrate"), and the one
|
||||||
|
# new palace file (logstream.sqlite3) is created lazily on first logstream use.
|
||||||
|
# Two behaviour changes to know: MEMPALACE_MCP_ALLOW_PEER_WRITER no longer works
|
||||||
|
# on local/chroma palaces, and writer-lock setup failures now fail CLOSED
|
||||||
|
# (refuse the write) rather than fail open. Neither affects the container's
|
||||||
|
# normal MCP-server-plus-CLI-feeder pattern, which already serialised on the
|
||||||
|
# same lock under 3.6.0.
|
||||||
|
#
|
||||||
|
# 3.8.0 (2026-08-23, PyPI, released hours after this project's own v1.8.5 tag
|
||||||
|
# the same day) is additive/reliability only — reviewed for MCP tool-schema
|
||||||
|
# changes before bumping, as always: there are NONE. Two PRs matter:
|
||||||
|
# - PR #2320/#2322: `sync --apply` no longer deletes a drawer solely because
|
||||||
|
# its source_file was unreachable AT THAT MOMENT — it now asks for
|
||||||
|
# corroboration first. This fixes losing a whole mined project to one
|
||||||
|
# `sync --apply` while its volume happened to be unmounted.
|
||||||
|
# IMPORTANT — do not over-read this fix: it addresses TRANSIENT
|
||||||
|
# unreachability, not the standing landmine (documented in the operator's
|
||||||
|
# global AGENTS.md) against running `mempalace_sync` / `mempalace_delete_by_source`
|
||||||
|
# beyond dry-run on the SHARED central palace. On that palace most
|
||||||
|
# source_file paths are PERMANENTLY absent from whichever host runs the
|
||||||
|
# sync — a different machine's paths simply do not exist here, ever, not
|
||||||
|
# merely "right now". That is a different failure shape than #2320/#2322
|
||||||
|
# fixes. The landmine still stands; this bump does not relax it.
|
||||||
|
# - PR #2307: long-running Chroma servers no longer invalidate their own
|
||||||
|
# HNSW cache on their own writes (server-side perf fix). This does NOT
|
||||||
|
# make `mempalace_reconnect` unnecessary — that tool exists for EXTERNAL
|
||||||
|
# writes bypassing the in-process client (e.g. direct sqlite backfills,
|
||||||
|
# CLI commands against a running server), a different scenario #2307
|
||||||
|
# does not touch.
|
||||||
|
#
|
||||||
|
# Known gap, carried forward (flagged in prior release notes, not fixed here):
|
||||||
|
# unlike PI_VERSION, which CI's resolve-versions job verifies is published and
|
||||||
|
# warns — never silently adopts — on npm drift, MEMPALACE_VERSION has NO
|
||||||
|
# equivalent CI-side audit (confirmed: zero references to MEMPALACE_VERSION in
|
||||||
|
# .gitea/workflows/docker-publish.yml). This is a literal Dockerfile string
|
||||||
|
# with no automated freshness or publish check.
|
||||||
|
#
|
||||||
|
# Deployment sequencing note for whoever ships this bump: synlig (the shared
|
||||||
|
# central palace host) currently serves mempalace 3.7.1 SERVER-SIDE via
|
||||||
|
# docker-compose.mempalace.yml, which reuses this same devbox image. Bumping
|
||||||
|
# this ARG changes only the CLIENT version baked into pi-devbox images: it
|
||||||
|
# introduces client/server skew until synlig's compose stack is separately
|
||||||
|
# rebuilt/redeployed with the new pin. Not something to code around here —
|
||||||
|
# just sequence the redeploy.
|
||||||
|
ARG MEMPALACE_VERSION=3.8.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 +473,15 @@ 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 && \
|
||||||
|
ln -sf /opt/mempalace-toolkit/bin/mempalace-census /usr/local/bin/mempalace-census && \
|
||||||
|
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs \
|
||||||
|
/opt/mempalace-toolkit/bin/mempalace-pi-session \
|
||||||
|
/opt/mempalace-toolkit/bin/mempalace-census && \
|
||||||
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 && \
|
||||||
|
mempalace-census --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
|
||||||
|
|
||||||
@@ -657,12 +723,14 @@ COPY rootfs/usr/local/share/pi-devbox/ /usr/local/share/pi-devbox/
|
|||||||
COPY rootfs/usr/local/bin/studio-expose /usr/local/bin/studio-expose
|
COPY rootfs/usr/local/bin/studio-expose /usr/local/bin/studio-expose
|
||||||
COPY rootfs/usr/local/bin/dot-watch /usr/local/bin/dot-watch
|
COPY rootfs/usr/local/bin/dot-watch /usr/local/bin/dot-watch
|
||||||
COPY rootfs/usr/local/bin/pi-devbox-version /usr/local/bin/pi-devbox-version
|
COPY rootfs/usr/local/bin/pi-devbox-version /usr/local/bin/pi-devbox-version
|
||||||
|
COPY rootfs/usr/local/bin/devbox-skill-reconcile /usr/local/bin/devbox-skill-reconcile
|
||||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
|
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
|
||||||
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
|
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
|
||||||
/usr/local/bin/studio-expose \
|
/usr/local/bin/studio-expose \
|
||||||
/usr/local/bin/dot-watch \
|
/usr/local/bin/dot-watch \
|
||||||
/usr/local/bin/pi-devbox-version \
|
/usr/local/bin/pi-devbox-version \
|
||||||
|
/usr/local/bin/devbox-skill-reconcile \
|
||||||
/usr/local/lib/pi-devbox/*.sh 2>/dev/null || true
|
/usr/local/lib/pi-devbox/*.sh 2>/dev/null || true
|
||||||
|
|
||||||
# Start as root — entrypoint adjusts UID/GID then drops to developer
|
# Start as root — entrypoint adjusts UID/GID then drops to developer
|
||||||
|
|||||||
+100
-11
@@ -29,16 +29,49 @@ 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.
|
||||||
|
#
|
||||||
|
# AUDITED AT 0.84.3 (2026-08-25, was 0.84.2): upstream's notes carry a
|
||||||
|
# "Breaking Changes" heading — `GoogleThinkingLevel` renamed to
|
||||||
|
# `GoogleApiThinkingLevel`. INERT FOR THIS IMAGE: all four vendored companions
|
||||||
|
# (/opt/pi-fork, /opt/pi-observational-memory, /opt/pi-atelier, /opt/pi-studio)
|
||||||
|
# were grepped for that symbol and reference it ZERO times, so nothing here
|
||||||
|
# couples to the renamed type. Recorded because the heading will look alarming
|
||||||
|
# to the next reader doing step 1 above — the audit is done, don't redo it.
|
||||||
|
# Adopted for two fixes that land squarely on this repo's own vendored-skill
|
||||||
|
# wiring (see devbox-skill-reconcile, v1.8.5): nested Markdown skills inside
|
||||||
|
# `.agents/skills/<group>/` directories were not discovered, and root Markdown
|
||||||
|
# files such as README.md / AGENTS.md inside a skill dir were reported as
|
||||||
|
# broken skills unless they declared valid skill frontmatter.
|
||||||
|
# pi-atelier needs no companion bump: v0.8.2 clears the >=0.7.1 floor that
|
||||||
|
# pi >= 0.84 requires (see PI_ATELIER_REF below).
|
||||||
|
ARG PI_VERSION=0.84.3
|
||||||
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 +87,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.2
|
||||||
|
# Human-readable tag PI_ATELIER_REF was resolved from; recorded as a label.
|
||||||
|
ARG PI_ATELIER_VERSION=v0.8.2
|
||||||
|
|
||||||
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 +143,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 +277,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}"
|
||||||
@@ -241,6 +312,19 @@ RUN set -e; \
|
|||||||
mkdir -p /etc/pi-devbox; \
|
mkdir -p /etc/pi-devbox; \
|
||||||
rev() { git -C "$1" rev-parse HEAD 2>/dev/null || echo "unknown"; }; \
|
rev() { git -C "$1" rev-parse HEAD 2>/dev/null || echo "unknown"; }; \
|
||||||
PI_V="$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')"; \
|
PI_V="$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')"; \
|
||||||
|
# mempalace CORE (the PyPI package behind the MCP tools) is installed in
|
||||||
|
# Dockerfile.base via `uv tool install`, so no /opt clone reveals it and
|
||||||
|
# until v1.8.6 the manifest could not answer "which palace shipped here?" —
|
||||||
|
# a palace bug could not be correlated to an image, which is precisely the
|
||||||
|
# correlation this file exists to provide. Read from the INSTALLED BINARY,
|
||||||
|
# not from ARG MEMPALACE_VERSION, per the ground-truth rule above: that is
|
||||||
|
# what catches an install which resolved to something other than the pin.
|
||||||
|
# `mempalace --version` prints "MemPalace 3.7.1" — NAME-PREFIXED, unlike
|
||||||
|
# pi's bare "0.84.2" — hence the $NF pick rather than a straight read. The
|
||||||
|
# leading-digit test then rejects usage/error text (a renamed flag prints a
|
||||||
|
# usage block) and degrades to JSON null, so this can never fail the build.
|
||||||
|
MP_V="$(mempalace --version 2>/dev/null | head -n1 | tr -d '\r' | awk '{print $NF}')"; \
|
||||||
|
case "$MP_V" in [0-9]*) MP_CORE="\"${MP_V}\"" ;; *) MP_CORE='null' ;; esac; \
|
||||||
STUDIO_REV='null'; \
|
STUDIO_REV='null'; \
|
||||||
if [ -d /opt/pi-studio/.git ]; then STUDIO_REV="\"$(rev /opt/pi-studio)\""; fi; \
|
if [ -d /opt/pi-studio/.git ]; then STUDIO_REV="\"$(rev /opt/pi-studio)\""; fi; \
|
||||||
{ \
|
{ \
|
||||||
@@ -249,11 +333,16 @@ RUN set -e; \
|
|||||||
echo " \"build_date\": \"${BUILD_DATE}\","; \
|
echo " \"build_date\": \"${BUILD_DATE}\","; \
|
||||||
echo " \"source_revision\": \"${SOURCE_REVISION}\","; \
|
echo " \"source_revision\": \"${SOURCE_REVISION}\","; \
|
||||||
echo " \"pi_version\": \"${PI_V}\","; \
|
echo " \"pi_version\": \"${PI_V}\","; \
|
||||||
|
# Sibling of pi_version, NOT a member of components{}: that map holds git
|
||||||
|
# SHAs and `pi-devbox-version` renders it with .value[0:12], which would
|
||||||
|
# silently truncate a longer version string.
|
||||||
|
echo " \"mempalace_version\": ${MP_CORE},"; \
|
||||||
echo " \"components\": {"; \
|
echo " \"components\": {"; \
|
||||||
echo " \"pi-toolkit\": \"$(rev /opt/pi-toolkit)\","; \
|
echo " \"pi-toolkit\": \"$(rev /opt/pi-toolkit)\","; \
|
||||||
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 " }"; \
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
@@ -68,9 +70,27 @@ so `TERM=xterm-kitty` is understood. Override either in your own
|
|||||||
### Document and image tooling
|
### Document and image tooling
|
||||||
|
|
||||||
- `pandoc` — universal Markdown↔HTML/Org/RST/etc. converter
|
- `pandoc` — universal Markdown↔HTML/Org/RST/etc. converter
|
||||||
|
- `typst` — markup-based typesetting, wired up as pandoc's `--pdf-engine` (see
|
||||||
|
[Generating a PDF with pandoc + typst](#generating-a-pdf-with-pandoc--typst))
|
||||||
- `graphviz` — `dot` rendering for diagram pipelines
|
- `graphviz` — `dot` rendering for diagram pipelines
|
||||||
- `imagemagick` — image conversion / resizing (invoked as `magick`)
|
- `imagemagick` — image conversion / resizing (invoked as `magick`)
|
||||||
|
|
||||||
|
### Browser automation
|
||||||
|
|
||||||
|
- `agent-browser` — CLI for driving a real headless browser: open pages,
|
||||||
|
click/fill/`eval`, snapshot the DOM, take screenshots. Useful whenever a task
|
||||||
|
involves a web UI or verifying how a page actually renders (live DOM, WebGL,
|
||||||
|
layout, popup positioning) instead of guessing from source.
|
||||||
|
- `playwright` + a pre-installed headless **Chromium** back it.
|
||||||
|
`AGENT_BROWSER_EXECUTABLE_PATH` is preset to the baked browser via a stable
|
||||||
|
`/usr/local/bin/agent-chrome` symlink (insulated from Playwright's
|
||||||
|
per-version/arch install directory), so `agent-browser open <url>` works
|
||||||
|
out of the box with no setup. Run `agent-browser skills get core --full`
|
||||||
|
for the command set and workflow patterns.
|
||||||
|
- `socat` — TCP bridge used by `studio-expose` to reach pi-studio's
|
||||||
|
loopback-bound server from outside the container (see
|
||||||
|
[Using pi-studio](#using-pi-studio--studio-variant))
|
||||||
|
|
||||||
### Language toolchains
|
### Language toolchains
|
||||||
|
|
||||||
- `python3` + `python3-venv` + `python3-pip` (system Python)
|
- `python3` + `python3-venv` + `python3-pip` (system Python)
|
||||||
@@ -338,6 +358,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
|
||||||
@@ -492,7 +565,8 @@ directory, and they compose:
|
|||||||
`~/.agents/skills/` by `entrypoint-user.sh` on every start. They need no
|
`~/.agents/skills/` by `entrypoint-user.sh` on every start. They need no
|
||||||
external mount, survive volume recreate (the source is an image path, not a
|
external mount, survive volume recreate (the source is an image path, not a
|
||||||
home dir a named volume would shadow), and are created only when absent so a
|
home dir a named volume would shadow), and are created only when absent so a
|
||||||
same-named skillset skill or user override is never clobbered. The bundled
|
user override is never clobbered. Precedence against a mounted `skillset` repo
|
||||||
|
is per-skill, not blanket — see *Skillset repo* below. The bundled
|
||||||
**`pi-devbox-environment`** skill is delivered this way — it teaches agents
|
**`pi-devbox-environment`** skill is delivered this way — it teaches agents
|
||||||
the container's persistence model, host/LAN SSH reachability, split-DNS
|
the container's persistence model, host/LAN SSH reachability, split-DNS
|
||||||
mechanisms, the interactive-vs-tool-shell alias gotcha (`dssh`/`dscp`),
|
mechanisms, the interactive-vs-tool-shell alias gotcha (`dssh`/`dscp`),
|
||||||
@@ -503,8 +577,11 @@ directory, and they compose:
|
|||||||
pi session to read `~/.agents/skills/pi-extensions/SKILL.md` at start (to fix
|
pi session to read `~/.agents/skills/pi-extensions/SKILL.md` at start (to fix
|
||||||
fork/recall under-utilisation). That pointer would dangle in a container
|
fork/recall under-utilisation). That pointer would dangle in a container
|
||||||
started *without* the private `skillset` repo, so the image also bakes
|
started *without* the private `skillset` repo, so the image also bakes
|
||||||
fallback copies of **`pi-extensions`** and **`mempalace`**. They are
|
fallback copies of **`pi-extensions`** and **`mempalace`**. Whether a mounted
|
||||||
symlinked only when absent, so a mounted skillset always overrides them. The
|
skillset overrides them depends on who *owns* the skill (see *Skillset repo*):
|
||||||
|
`mempalace` is skillset-owned, so the live clone wins; `pi-extensions` is
|
||||||
|
owned by its package repo, so the baked copy keeps winning — the skillset's
|
||||||
|
copy of it is a downstream duplicate that can lag. The
|
||||||
`pi-extensions` skill is *layered*: a committed snapshot in `rootfs/` is the
|
`pi-extensions` skill is *layered*: a committed snapshot in `rootfs/` is the
|
||||||
floor, and `Dockerfile.variant` copies the canonical, package-owned copy from
|
floor, and `Dockerfile.variant` copies the canonical, package-owned copy from
|
||||||
the pinned `pi-extensions` clone (`/opt/pi-extensions/skill/`) over it at
|
the pinned `pi-extensions` clone (`/opt/pi-extensions/skill/`) over it at
|
||||||
@@ -518,7 +595,16 @@ directory, and they compose:
|
|||||||
- **Skillset repo (optional).** If a `skillset` repo is mounted (at
|
- **Skillset repo (optional).** If a `skillset` repo is mounted (at
|
||||||
`$HOME/skillset` or `/workspace/skillset`, or via `SKILLSET_CONTAINER_PATH`),
|
`$HOME/skillset` or `/workspace/skillset`, or via `SKILLSET_CONTAINER_PATH`),
|
||||||
`deploy-skills.sh` symlinks its skills in too. Image-baked skills are
|
`deploy-skills.sh` symlinks its skills in too. Image-baked skills are
|
||||||
classified as foreign-links by its `--prune-stale` pass and left untouched.
|
classified as foreign-links by its `--prune-stale` pass and left untouched —
|
||||||
|
which through v1.8.4 meant the baked copy *always* won, so an edit pushed to a
|
||||||
|
skillset-owned skill was invisible until the next image build. Since v1.8.5
|
||||||
|
`devbox-skill-reconcile` runs right after the deploy and repoints the links for
|
||||||
|
skills the skillset owns, listed in
|
||||||
|
`/usr/local/share/pi-devbox/skills/skillset-owned.txt` (today: `mempalace`).
|
||||||
|
Effective precedence, highest first: **user override** (a real directory, or a
|
||||||
|
symlink pointing outside the baked tree) → **live skillset clone** (owned names
|
||||||
|
only) → **baked snapshot** (everything else, and every skill when no skillset
|
||||||
|
is mounted). Check with `readlink -f ~/.agents/skills/<skill>`.
|
||||||
|
|
||||||
To make agents *proactively* load a baked skill at session start (rather than
|
To make agents *proactively* load a baked skill at session start (rather than
|
||||||
only on description match), the image appends a short, gated pointer to the
|
only on description match), the image appends a short, gated pointer to the
|
||||||
@@ -554,6 +640,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 +688,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 +764,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
|
||||||
@@ -686,8 +805,9 @@ docker inspect --format '{{json .Config.Labels}}' joakimp/pi-devbox:latest | jq
|
|||||||
`org.opencontainers.image.{version,revision,created}` plus
|
`org.opencontainers.image.{version,revision,created}` plus
|
||||||
`se.jordbo.pi-devbox.*-ref` record the intended pi version and companion
|
`se.jordbo.pi-devbox.*-ref` record the intended pi version and companion
|
||||||
refs. The on-disk `/etc/pi-devbox/build-manifest.json` records **ground
|
refs. The on-disk `/etc/pi-devbox/build-manifest.json` records **ground
|
||||||
truth** — the actual checked-out commit of each `/opt` clone and the live
|
truth** — the actual checked-out commit of each `/opt` clone, the live
|
||||||
`pi --version` — so a tag is reconstructable after CI logs rotate:
|
`pi --version`, and (from v1.8.6) the live `mempalace --version` of the
|
||||||
|
installed palace core — so a tag is reconstructable after CI logs rotate:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
docker run --rm --entrypoint= joakimp/pi-devbox:latest cat /etc/pi-devbox/build-manifest.json
|
docker run --rm --entrypoint= joakimp/pi-devbox:latest cat /etc/pi-devbox/build-manifest.json
|
||||||
@@ -750,14 +870,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 +1009,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.2` | `ARG PI_ATELIER_REF` — `Dockerfile.variant` |
|
||||||
|
| mempalace | `3.7.1` | `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
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,23 @@ for OS packages, the per-package copyright files inside the image at
|
|||||||
| pi-fork | github.com/elpapi42/pi-fork | MIT |
|
| pi-fork | github.com/elpapi42/pi-fork | MIT |
|
||||||
| pi-observational-memory | github.com/elpapi42/pi-observational-memory | MIT |
|
| pi-observational-memory | github.com/elpapi42/pi-observational-memory | MIT |
|
||||||
| pi-studio *(`-studio` variant only)* | github.com/omaclaren/pi-studio | MIT |
|
| pi-studio *(`-studio` variant only)* | github.com/omaclaren/pi-studio | MIT |
|
||||||
|
| pi-atelier | github.com/michaelmjhhhh/pi-atelier | MIT |
|
||||||
| pi-toolkit, pi-extensions, mempalace-toolkit | authored by the maintainer (Joakim Persson) | MIT |
|
| pi-toolkit, pi-extensions, mempalace-toolkit | authored by the maintainer (Joakim Persson) | MIT |
|
||||||
|
|
||||||
|
## MemPalace (AI memory)
|
||||||
|
|
||||||
|
| Component | Upstream | License |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| mempalace (core, MCP server) | github.com/MemPalace/mempalace (PyPI: `mempalace`) | MIT — the GitHub repo declares MIT; the PyPI package's own metadata omits a license classifier, so if you need clearance from the package artifact alone, verify against the repo's `LICENSE` file rather than the sdist/wheel metadata |
|
||||||
|
|
||||||
|
## Browser automation
|
||||||
|
|
||||||
|
| Component | Upstream | License |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| agent-browser | github.com/vercel-labs/agent-browser (npm: `agent-browser`) | Apache-2.0 |
|
||||||
|
| Playwright | github.com/microsoft/playwright (npm: `playwright`) | Apache-2.0 |
|
||||||
|
| Chromium | chromium.googlesource.com/chromium/src | BSD-3-Clause for Chromium's own code, plus a large set of bundled third-party components each under their own license (see Chromium's own `LICENSE`/`about:credits`). The binary in this image is **not compiled here** — it is the build Playwright downloads for its pinned version ("Chrome for Testing"), installed via `playwright install --with-deps chromium` at `/usr/local/share/ms-playwright/`. Treat Playwright's own distribution terms for that build as authoritative over any summary here. |
|
||||||
|
|
||||||
## Tooling baked into the base image
|
## Tooling baked into the base image
|
||||||
|
|
||||||
| Component | Upstream | License (best effort) |
|
| Component | Upstream | License (best effort) |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+162
-9
@@ -58,10 +58,17 @@ fi
|
|||||||
# the runtime skill-link assertion. Pointing at the image path (/usr/local/...)
|
# the runtime skill-link assertion. Pointing at the image path (/usr/local/...)
|
||||||
# keeps the skill fresh from the image and surviving volume recreate (unlike
|
# keeps the skill fresh from the image and surviving volume recreate (unlike
|
||||||
# anything baked under a home dir, which a named volume would shadow). Created
|
# anything baked under a home dir, which a named volume would shadow). Created
|
||||||
# only when absent, so a same-named skillset skill (deployed later, at the end
|
# only when absent, so a user override is never clobbered.
|
||||||
# of this script) or a user override is never clobbered; the skillset deploy
|
#
|
||||||
# classifies these as foreign-links and its --prune-stale pass leaves them
|
# NB: "created only when absent" does NOT hand a same-named skillset skill
|
||||||
# alone (only dangling symlinks are pruned).
|
# priority — the opposite. The skillset deploy runs at the end of this script
|
||||||
|
# and classifies these links as foreign, so through v1.8.4 the BAKED copy
|
||||||
|
# always won and an edit pushed to a skillset-owned skill was invisible until
|
||||||
|
# the next image build. The links below are therefore the FALLBACK only;
|
||||||
|
# devbox-skill-reconcile (invoked right after the skillset deploy) hands the
|
||||||
|
# skillset-OWNED skills back to the live clone. Ownership is per-skill, listed
|
||||||
|
# in skills/skillset-owned.txt — see VENDORED.md for why pi-extensions must
|
||||||
|
# keep losing to the baked copy.
|
||||||
DEVBOX_SKILLS_SRC=/usr/local/share/pi-devbox/skills
|
DEVBOX_SKILLS_SRC=/usr/local/share/pi-devbox/skills
|
||||||
if [ -d "$DEVBOX_SKILLS_SRC" ]; then
|
if [ -d "$DEVBOX_SKILLS_SRC" ]; then
|
||||||
mkdir -p "$HOME/.agents/skills"
|
mkdir -p "$HOME/.agents/skills"
|
||||||
@@ -69,7 +76,16 @@ if [ -d "$DEVBOX_SKILLS_SRC" ]; then
|
|||||||
[ -d "$_sk" ] || continue
|
[ -d "$_sk" ] || continue
|
||||||
_skname=$(basename "$_sk")
|
_skname=$(basename "$_sk")
|
||||||
if [ ! -e "$HOME/.agents/skills/$_skname" ]; then
|
if [ ! -e "$HOME/.agents/skills/$_skname" ]; then
|
||||||
ln -s "${_sk%/}" "$HOME/.agents/skills/$_skname"
|
# -sfn, not -s: `[ ! -e ]` is TRUE for a DANGLING symlink (-e follows the
|
||||||
|
# link), and since v1.8.5 these links can point into /workspace/skillset
|
||||||
|
# (see devbox-skill-reconcile, invoked after the skillset deploy). If that
|
||||||
|
# mount vanishes while the writable layer survives — a `docker restart` or
|
||||||
|
# a host reboot under restart: unless-stopped, as opposed to a recreate —
|
||||||
|
# plain `ln -s` fails with "File exists" and, under `set -e`, aborts
|
||||||
|
# container start before `exec "$@"`. With -f the broken link heals back to
|
||||||
|
# the baked fallback, and the reconciler re-points it in the same boot if
|
||||||
|
# the clone is back.
|
||||||
|
ln -sfn "${_sk%/}" "$HOME/.agents/skills/$_skname"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
fi
|
fi
|
||||||
@@ -92,6 +108,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 +261,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 +298,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)"
|
||||||
@@ -255,6 +401,13 @@ elif [ -x /workspace/skillset/deploy-skills.sh ]; then
|
|||||||
fi
|
fi
|
||||||
if [ -n "$SKILLSET_DEPLOY" ]; then
|
if [ -n "$SKILLSET_DEPLOY" ]; then
|
||||||
"$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true
|
"$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true
|
||||||
|
# The deploy leaves the early baked links (above) in place as foreign links,
|
||||||
|
# which silently shadows the live clone for skills the skillset OWNS. Repoint
|
||||||
|
# just those; baked stays the fallback, user overrides still win. `|| true`:
|
||||||
|
# a skill-link refinement must never break container start.
|
||||||
|
if command -v devbox-skill-reconcile >/dev/null 2>&1; then
|
||||||
|
devbox-skill-reconcile "$(dirname "$SKILLSET_DEPLOY")" || true
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Execute command ──────────────────────────────────────────────────
|
# ── Execute command ──────────────────────────────────────────────────
|
||||||
|
|||||||
Executable
+91
@@ -0,0 +1,91 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# devbox-skill-reconcile — hand skillset-OWNED skills back to the live clone.
|
||||||
|
#
|
||||||
|
# WHY THIS EXISTS
|
||||||
|
# ---------------
|
||||||
|
# entrypoint-user.sh links the image-baked skills into ~/.agents/skills/ EARLY
|
||||||
|
# (before pi-deploy), because the smoke readiness probe gates on markers that
|
||||||
|
# only land later, and a link created after that gate produced a flaky
|
||||||
|
# assertion. Those links are created with a `[ ! -e ]` guard — "only when
|
||||||
|
# absent" — and the skillset deploy runs LAST, treating already-present links
|
||||||
|
# as foreign and leaving them alone. Net effect through v1.8.4: the baked copy
|
||||||
|
# always won, so an edit pushed to a skillset-owned skill was invisible in
|
||||||
|
# every container until the next image build (measured on two hosts: live
|
||||||
|
# skillset md5 129bcc4752 vs baked 5236024fef, the new section absent).
|
||||||
|
#
|
||||||
|
# The fix is NOT "the skillset always wins". Ownership is per-skill (see
|
||||||
|
# rootfs/usr/local/share/pi-devbox/skills/VENDORED.md):
|
||||||
|
#
|
||||||
|
# pi-devbox-environment authored in pi-devbox → baked IS canonical
|
||||||
|
# pi-extensions owned by the package repo, copied over the snapshot
|
||||||
|
# at build time; skillset carries a DOWNSTREAM copy
|
||||||
|
# that can lag → baked must keep winning
|
||||||
|
# mempalace owned by the skillset repo; baked is a snapshot
|
||||||
|
# fallback for containers with no skillset mounted
|
||||||
|
# → the live clone must win when it is present
|
||||||
|
#
|
||||||
|
# So only skills listed in skills/skillset-owned.txt are handed over. Baked
|
||||||
|
# links stay as the fallback (the early-link race fix is untouched), and a user
|
||||||
|
# override always beats both: a real directory is never replaced, and neither is
|
||||||
|
# a symlink that already points somewhere other than the baked tree.
|
||||||
|
#
|
||||||
|
# Usage: devbox-skill-reconcile <skillset-root> [skills-dir] [baked-src]
|
||||||
|
# skillset-root the mounted skillset repo (contains skills/<name>/)
|
||||||
|
# skills-dir default $HOME/.agents/skills
|
||||||
|
# baked-src default /usr/local/share/pi-devbox/skills
|
||||||
|
#
|
||||||
|
# Idempotent, and silent unless it changes something. Exits 0 when there is
|
||||||
|
# nothing to do (no skillset, no list) so the entrypoint never fails on it.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SKILLSET_ROOT="${1:-}"
|
||||||
|
SKILLS_DIR="${2:-$HOME/.agents/skills}"
|
||||||
|
BAKED_SRC="${3:-/usr/local/share/pi-devbox/skills}"
|
||||||
|
BAKED_SRC="${BAKED_SRC%/}" # a trailing slash would make the prefix
|
||||||
|
# match below ("$BAKED_SRC"/*) match nothing
|
||||||
|
|
||||||
|
[ -n "$SKILLSET_ROOT" ] || exit 0
|
||||||
|
[ -d "$SKILLSET_ROOT/skills" ] || exit 0
|
||||||
|
[ -d "$SKILLS_DIR" ] || exit 0
|
||||||
|
|
||||||
|
# Absolutise BOTH roots before they are used, because each has its own way of
|
||||||
|
# failing silently when relative: a relative symlink TARGET is resolved against
|
||||||
|
# the link's directory (~/.agents/skills), not $PWD, so it would dangle on
|
||||||
|
# creation; and a relative BAKED_SRC would never prefix-match the absolute
|
||||||
|
# target that `readlink` reports, so every skill would be skipped and the fix
|
||||||
|
# would look like it had simply done nothing.
|
||||||
|
SKILLSET_ROOT=$(CDPATH= cd -- "$SKILLSET_ROOT" 2>/dev/null && pwd) || exit 0
|
||||||
|
BAKED_SRC=$(CDPATH= cd -- "$BAKED_SRC" 2>/dev/null && pwd) || exit 0
|
||||||
|
OWNED_LIST="$BAKED_SRC/skillset-owned.txt"
|
||||||
|
[ -f "$OWNED_LIST" ] || exit 0
|
||||||
|
|
||||||
|
while IFS= read -r _line || [ -n "$_line" ]; do
|
||||||
|
# strip comments and surrounding whitespace; skip blanks
|
||||||
|
_name=$(printf '%s\n' "$_line" | sed -e 's/#.*$//' -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')
|
||||||
|
[ -n "$_name" ] || continue
|
||||||
|
# defensive: a list entry must be a plain skill name, never a path
|
||||||
|
case "$_name" in */*|.*) continue ;; esac
|
||||||
|
|
||||||
|
_live="$SKILLSET_ROOT/skills/$_name"
|
||||||
|
_link="$SKILLS_DIR/$_name"
|
||||||
|
|
||||||
|
# the skillset does not ship it → the baked fallback is all there is
|
||||||
|
[ -d "$_live" ] || continue
|
||||||
|
# a real directory is a user override → never touch
|
||||||
|
[ -L "$_link" ] || continue
|
||||||
|
|
||||||
|
# only ever replace OUR OWN link. readlink is deliberate: `readlink -f`
|
||||||
|
# would resolve a link that already points into the skillset clone and,
|
||||||
|
# since both trees hold a same-named skill, could not tell them apart.
|
||||||
|
_target=$(readlink "$_link" 2>/dev/null || true)
|
||||||
|
case "$_target" in
|
||||||
|
"$BAKED_SRC"/*|"$BAKED_SRC") ;; # baked link → ours to replace
|
||||||
|
*) continue ;; # user/foreign target → leave alone
|
||||||
|
esac
|
||||||
|
|
||||||
|
# -n so an existing symlink-to-directory is replaced rather than followed
|
||||||
|
# (without it, ln would create $_link/$_name inside the baked tree).
|
||||||
|
if ln -sfn "$_live" "$_link" 2>/dev/null; then
|
||||||
|
printf 'skill %s: baked snapshot -> live skillset (%s)\n' "$_name" "$_live"
|
||||||
|
fi
|
||||||
|
done < "$OWNED_LIST"
|
||||||
@@ -55,6 +55,10 @@ release_tag=$(jq -r '.release_tag' "$MANIFEST")
|
|||||||
build_date=$(jq -r '.build_date' "$MANIFEST")
|
build_date=$(jq -r '.build_date' "$MANIFEST")
|
||||||
source_rev=$(jq -r '.source_revision' "$MANIFEST")
|
source_rev=$(jq -r '.source_revision' "$MANIFEST")
|
||||||
pi_version_baked=$(jq -r '.pi_version' "$MANIFEST")
|
pi_version_baked=$(jq -r '.pi_version' "$MANIFEST")
|
||||||
|
# `// empty` matters: images built before v1.8.6 have no such field, and
|
||||||
|
# `jq -r` renders a JSON null as the 4-char string "null" — which would
|
||||||
|
# print as a bogus version rather than being treated as absent.
|
||||||
|
mp_version_baked=$(jq -r '.mempalace_version // empty' "$MANIFEST")
|
||||||
|
|
||||||
if [ "$MODE" = "quiet" ]; then
|
if [ "$MODE" = "quiet" ]; then
|
||||||
printf '%s (%s)\n' "$release_tag" "${source_rev:0:7}"
|
printf '%s (%s)\n' "$release_tag" "${source_rev:0:7}"
|
||||||
@@ -71,6 +75,16 @@ if command -v pi >/dev/null 2>&1; then
|
|||||||
pi_version_live=$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')
|
pi_version_live=$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Same check for the palace, which matters more than it looks: mempalace is
|
||||||
|
# the one component that is BOTH client (here) and server (synlig runs this
|
||||||
|
# same image), so a skew between the two is a real failure mode rather than
|
||||||
|
# cosmetic. `mempalace --version` prints "MemPalace 3.8.0" — name-prefixed,
|
||||||
|
# unlike pi's bare "0.84.3" — hence $NF rather than reading the whole line.
|
||||||
|
mp_version_live=""
|
||||||
|
if command -v mempalace >/dev/null 2>&1; then
|
||||||
|
mp_version_live=$(mempalace --version 2>/dev/null | head -n1 | awk '{print $NF}' | tr -d '\r\n')
|
||||||
|
fi
|
||||||
|
|
||||||
printf 'pi-devbox %s\n' "$release_tag"
|
printf 'pi-devbox %s\n' "$release_tag"
|
||||||
printf ' built: %s (source %s)\n' "$build_date" "${source_rev:0:12}"
|
printf ' built: %s (source %s)\n' "$build_date" "${source_rev:0:12}"
|
||||||
if [ -n "$pi_version_live" ] && [ "$pi_version_live" != "$pi_version_baked" ]; then
|
if [ -n "$pi_version_live" ] && [ "$pi_version_live" != "$pi_version_baked" ]; then
|
||||||
@@ -79,5 +93,15 @@ else
|
|||||||
printf ' pi: %s\n' "${pi_version_live:-$pi_version_baked}"
|
printf ' pi: %s\n' "${pi_version_live:-$pi_version_baked}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Printed only when known, so this degrades quietly on pre-v1.8.6 images
|
||||||
|
# instead of showing an empty or "null" palace line.
|
||||||
|
if [ -n "$mp_version_live" ] || [ -n "$mp_version_baked" ]; then
|
||||||
|
if [ -n "$mp_version_live" ] && [ -n "$mp_version_baked" ] && [ "$mp_version_live" != "$mp_version_baked" ]; then
|
||||||
|
printf ' palace: %s \033[33m(baked as %s — drift detected)\033[0m\n' "$mp_version_live" "$mp_version_baked"
|
||||||
|
else
|
||||||
|
printf ' palace: %s\n' "${mp_version_live:-$mp_version_baked}"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
printf ' components:\n'
|
printf ' components:\n'
|
||||||
jq -r '.components | to_entries[] | select(.value != null) | " \(.key): \(.value[0:12])"' "$MANIFEST"
|
jq -r '.components | to_entries[] | select(.value != null) | " \(.key): \(.value[0:12])"' "$MANIFEST"
|
||||||
|
|||||||
@@ -41,3 +41,32 @@ especially load-bearing here — a pi-devbox container is frequently recreated,
|
|||||||
the palace is your only memory across recreates. Without the habit it is just
|
the palace is your only memory across recreates. Without the habit it is just
|
||||||
storage, not memory. (The skill is the consumer side; feeding the palace is the
|
storage, not memory. (The skill is the consumer side; feeding the palace is the
|
||||||
separate `opencode-mempalace-bridge` skill, if present.)
|
separate `opencode-mempalace-bridge` skill, if present.)
|
||||||
|
|
||||||
|
### If the palace is central, it is shared — three rules
|
||||||
|
|
||||||
|
If `MEMPALACE_REMOTE_URL` is set, the MCP tools write to a **central palace
|
||||||
|
shared with other machines**, not to a local one. Your drawers are not the only
|
||||||
|
ones in there, and most drawers' `source_file` paths do not exist on this host.
|
||||||
|
The skill covers the orientation side (provenance, chronology, whose diary is
|
||||||
|
whose); these three are here instead because getting them wrong does *damage*
|
||||||
|
rather than merely confusing you:
|
||||||
|
|
||||||
|
- **Never run `mempalace sync` / `mempalace_sync` against a shared palace.** It
|
||||||
|
prunes drawers whose source files look gitignored, deleted, or moved — and on
|
||||||
|
a shared palace that describes most of the content, including every other
|
||||||
|
machine's. Compounding it (RFC-001 §7.2): feeders now stage *inside* the
|
||||||
|
palace root, so a scoped sync can delete the very drawers it just filed.
|
||||||
|
`mempalace_delete_by_source` is exact-match rather than existence-based, but
|
||||||
|
its blast radius is now the whole fleet's palace — leave it on its default
|
||||||
|
`dry_run=true` and confirm the match count before committing.
|
||||||
|
- **A timeout is not a failure.** The palace is single-writer, and one large
|
||||||
|
mine can block every client for minutes, so a write or mine that exceeds the
|
||||||
|
client's deadline has usually *completed* server-side. Verify with
|
||||||
|
`mempalace_get_drawer` or `mempalace_search` before retrying — a blind retry
|
||||||
|
files a duplicate. `[mempalace ext] feed (tick) failed: mine timed out after
|
||||||
|
30000ms` is the common benign instance: the transcript is already in the
|
||||||
|
server's inbox and the mine is idempotent, so nothing is lost either way.
|
||||||
|
- **The `mempalace` CLI is not remote-aware.** It always opens a palace on
|
||||||
|
local disk, so `mempalace search` can return older and different results than
|
||||||
|
the MCP tools while both look correct. Use the MCP tools for the central
|
||||||
|
palace; the CLI only for a local one.
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
# Vendored fallback skills
|
# Vendored fallback skills
|
||||||
|
|
||||||
Most directories here are **image-baked skills** that `entrypoint-user.sh`
|
Most directories here are **image-baked skills** that `entrypoint-user.sh`
|
||||||
symlinks into `~/.agents/skills/` on container start (only when a skill of the
|
symlinks into `~/.agents/skills/` on container start. They are the **fallback**
|
||||||
same name is not already present, so a mounted `skillset` repo or a user
|
layer: see *Runtime precedence* below for which copy actually wins when a
|
||||||
override always wins).
|
`skillset` repo is mounted (through v1.8.4 the answer was "always the baked
|
||||||
|
one", which was a bug).
|
||||||
|
|
||||||
| skill | owner | how it gets here |
|
| skill | owner | how it gets here |
|
||||||
|-------|-------|------------------|
|
|-------|-------|------------------|
|
||||||
@@ -38,6 +39,35 @@ its skill file needed baking.
|
|||||||
*different* skill, `opencode-mempalace-bridge`), so there is no public
|
*different* skill, `opencode-mempalace-bridge`), so there is no public
|
||||||
package source to copy from. This snapshot is refreshed manually per release.
|
package source to copy from. This snapshot is refreshed manually per release.
|
||||||
|
|
||||||
|
## Runtime precedence (v1.8.5+)
|
||||||
|
|
||||||
|
The baked links are created **early** in `entrypoint-user.sh` (before pi-deploy,
|
||||||
|
to close a smoke readiness race) with a create-only-when-absent guard, and the
|
||||||
|
skillset deploy runs **last** and treats them as foreign links. Through v1.8.4
|
||||||
|
that combination meant the baked snapshot always won: an edit pushed to
|
||||||
|
`skillset/skills/mempalace/SKILL.md` was invisible in every container until the
|
||||||
|
next image build (measured on two hosts — live `md5 129bcc4752` vs baked
|
||||||
|
`5236024fef`, new section absent). Editing those skills *appeared* to work.
|
||||||
|
|
||||||
|
`devbox-skill-reconcile` now runs immediately after the skillset deploy and
|
||||||
|
repoints the links for skills the **skillset owns**, listed one per line in
|
||||||
|
`skillset-owned.txt`. Precedence, highest first:
|
||||||
|
|
||||||
|
1. **user override** — a real directory, or a symlink pointing outside the baked
|
||||||
|
tree; never touched by anything
|
||||||
|
2. **live skillset clone** — but only for names in `skillset-owned.txt`
|
||||||
|
3. **baked snapshot** — everything else, and every skill when no skillset is
|
||||||
|
mounted
|
||||||
|
|
||||||
|
Ownership is per-skill on purpose: `pi-extensions`' authoritative source is the
|
||||||
|
package repo (copied over the snapshot at build), and `skillset` carries a
|
||||||
|
downstream copy that can lag, so handing it to the clone would *regress* the
|
||||||
|
skill. Only `mempalace` is skillset-owned today.
|
||||||
|
|
||||||
|
Verify with `readlink -f ~/.agents/skills/<skill>` — not by reading the
|
||||||
|
entrypoint. Smoke covers both directions (baked resolution with no skillset
|
||||||
|
mounted, plus a fabricated-skillset run of the reconciler).
|
||||||
|
|
||||||
## Refreshing the snapshots
|
## Refreshing the snapshots
|
||||||
|
|
||||||
cp <pi-extensions-pkg>/skill/SKILL.md pi-extensions/SKILL.md
|
cp <pi-extensions-pkg>/skill/SKILL.md pi-extensions/SKILL.md
|
||||||
@@ -50,4 +80,9 @@ also carries a copy, but it is a downstream duplicate and can lag), and
|
|||||||
`mempalace` from `skillset`. Copying `pi-extensions` from `skillset` would
|
`mempalace` from `skillset`. Copying `pi-extensions` from `skillset` would
|
||||||
regress the snapshot to whatever that repo last mirrored.
|
regress the snapshot to whatever that repo last mirrored.
|
||||||
|
|
||||||
Snapshot provenance at last refresh: skillset `63f3bf5`, pi-extensions pkg `e73cb9f`.
|
Snapshot provenance at last refresh: skillset `670f7f1`, pi-extensions pkg `e73cb9f`.
|
||||||
|
|
||||||
|
When you refresh the `mempalace` snapshot, also update the phrase asserted by
|
||||||
|
the "mempalace skill snapshot is current" smoke test — it deliberately pins the
|
||||||
|
**newest** section, because the previous canary grepped a phrase that survived
|
||||||
|
the very edit that made the snapshot stale, and so passed on stale content.
|
||||||
|
|||||||
@@ -275,18 +275,30 @@ Wings are top-level categories, typically one per project or domain:
|
|||||||
- Named after the project directory (e.g., `cli_utils`, `opencode_devbox`)
|
- Named after the project directory (e.g., `cli_utils`, `opencode_devbox`)
|
||||||
- Agent diaries live in `wing_<agent_name>` (e.g., `wing_orchestrator`, `wing_pi`)
|
- Agent diaries live in `wing_<agent_name>` (e.g., `wing_orchestrator`, `wing_pi`)
|
||||||
|
|
||||||
#### Multi-harness palace
|
#### Shared palace: multiple harnesses, and possibly multiple machines
|
||||||
|
|
||||||
A single palace can be fed by multiple coding-agent harnesses. On this machine the palace is shared between **opencode** and **pi** (Mario Zechner's pi-coding-agent). Implications:
|
A single palace can be fed by multiple coding-agent harnesses, and — when
|
||||||
|
`MEMPALACE_REMOTE_URL` points at a central palace — by multiple *machines*. On
|
||||||
|
this machine the palace is shared between **opencode** and **pi** (Mario
|
||||||
|
Zechner's pi-coding-agent). Implications:
|
||||||
|
|
||||||
- **`wing_conversations` mixes sources.** Both harnesses' session feeders write into the same wing. To tell them apart, look at the `source_file` metadata on each drawer:
|
- **`wing_conversations` mixes sources.** Both harnesses' session feeders write into the same wing. To tell them apart, look at the `source_file` metadata on each drawer:
|
||||||
- `pi_<uuid>.jsonl` → pi session
|
- `pi_<uuid>.jsonl` → pi session
|
||||||
- `<slug>_ses_<id>.jsonl` → opencode session
|
- `<slug>_ses_<id>.jsonl` → opencode session
|
||||||
- The first chunk of each session also carries a `| source: opencode` or `| source: pi` marker in the synthetic header line.
|
- The first chunk of each session also carries a `| source: opencode` or `| source: pi` marker in the synthetic header line.
|
||||||
- **Other wings may belong to other harnesses.** For example `wing_pi` is pi's diary, not opencode's. Don't assume every diary entry was written by you — check `agent_name` on the entry.
|
- **Other wings may belong to other harnesses.** For example `wing_pi` is pi's diary, not opencode's. Don't assume every diary entry was written by you — check `agent_name` on the entry.
|
||||||
- **Session feeders run on different schedules.** Pi sessions are fed Tue 03:00, opencode sessions Mon 03:00. Recent sessions from either harness can lag the palace by up to a week, so absence-of-evidence in `wing_conversations` is not evidence-of-absence for recent work.
|
- **Session feeders run on different schedules.** Pi sessions are fed Tue 03:00, opencode sessions Mon 03:00 (launchd `Weekday`: `0`/`7`=Sunday, `1`=Monday, `2`=Tuesday — misreading this by one day is easy). Recent sessions from either harness can lag the palace by up to a week, so absence-of-evidence in `wing_conversations` is not evidence-of-absence for recent work.
|
||||||
- **Reading another harness's diary is useful.** When orienting after a gap, `mempalace_diary_read agent_name=pi` (or whichever sibling agent has been active) often gives a fresher picture than waiting for the conversations feeder to catch up.
|
- **Reading another harness's diary is useful.** When orienting after a gap, `mempalace_diary_read agent_name=pi` (or whichever sibling agent has been active) often gives a fresher picture than waiting for the conversations feeder to catch up.
|
||||||
|
|
||||||
|
When the palace is **central** (shared across machines), five more things apply:
|
||||||
|
|
||||||
|
- **Check which machine a conversation came from.** Transcripts are fed per device, so `source_path` reads `…/mempalace-feed/<device>/pi_<uuid>.jsonl` while the displayed `source_file` is only the basename. One search can legitimately return hits from several machines at once — look at the device segment before attributing a decision to *this* project.
|
||||||
|
- **Attribute what you file yourself.** Drawers now carry `device` and `agent_kind` metadata (plus `device_source`/`agent_kind_source` recording *how* each was determined, so an inference is never mistaken for a fact). Mined content gets these for free — the inbox path gives the device, the filename shape gives the harness — and a timer on the palace host re-stamps hourly, because live re-mining replaces metadata rows and silently drops earlier stamps. But for anything **you** file by hand, the only signal is what you pass: set `added_by="<harness>@<device>"` (e.g. `pi@emb-7kj4vr4g`, from `$MEMPALACE_PI_DEVICE`) on `add_drawer`/`checkpoint`/`mine`. Skip it and your drawer joins the ~16k historic `/workspace` project mines that are permanently unattributable, because `/workspace` exists identically on every devbox. Note the palace preserves `source_file` in full (see `source_path`) but *displays* only the basename — so a device prefix there survives storage even though it looks stripped.
|
||||||
|
- **Mined drawers carry the MINE date, not the session date.** When history is imported, or re-mined on the palace host, `filed_at`/`created_at` is the *import* time — so sorting by them does not give chronological order. Real session time is recoverable from the UUIDv7 in `pi_<uuid>.jsonl`: the first 12 hex digits are milliseconds since the epoch (and UUIDv7 sorts lexicographically in time order, so a plain filename sort is already chronological). Agent-authored drawers and diaries have no such backdoor — for those `filed_at` is the only chronology, which is why it must never be restamped.
|
||||||
|
- **Beware the timezone mismatch when you combine those.** Palace `filed_at`/`created_at` are naive timestamps in the palace host's local time, while a UUIDv7 decodes to UTC. Comparing them directly introduces a silent offset (2 h for a CEST host). Normalise before drawing conclusions about ordering.
|
||||||
|
- **`agent_name` is not device-scoped.** `mempalace_diary_read(agent_name="pi")` returns *every* machine's `pi` diary, interleaved. Read the entry before assuming it is your own history.
|
||||||
|
- **One writer, no queue.** A concurrent mine returns a structured `already-running` error rather than waiting its turn, and one large mine can make the palace unresponsive to every client for minutes. After another client's mine, call `mempalace_reconnect` to see the new drawers. A client-side timeout is not evidence of failure — verify before retrying, or you file a duplicate.
|
||||||
|
|
||||||
### Rooms
|
### Rooms
|
||||||
|
|
||||||
Rooms are aspects within a wing:
|
Rooms are aspects within a wing:
|
||||||
@@ -324,3 +336,4 @@ Entity-relationship triples with temporal validity. Query with `mempalace_kg_que
|
|||||||
- **Don't mine .git directories or node_modules.** The CLI miner respects .gitignore by default.
|
- **Don't mine .git directories or node_modules.** The CLI miner respects .gitignore by default.
|
||||||
- **Don't create duplicate drawers.** Use `mempalace_check_duplicate` before adding manually.
|
- **Don't create duplicate drawers.** Use `mempalace_check_duplicate` before adding manually.
|
||||||
- **Don't treat the palace as a task list.** It's for knowledge and context, not todos.
|
- **Don't treat the palace as a task list.** It's for knowledge and context, not todos.
|
||||||
|
- **Don't hand-craft provenance.** Leave `added_by` alone (and never put a machine name in a diary's `agent_name` — it becomes the wing name and hides your entries from `diary_read`). Recording *which device* wrote a record is client/server infrastructure, not your job: a hostname or container ID is not a stable identity, and an invented value is worse than none because it silently corrupts any future palace merge. If you find notes in the palace describing an `origin_device` scheme, that is a design for the client to implement — not an instruction for you to start stamping.
|
||||||
|
|||||||
@@ -130,6 +130,36 @@ are "command not found" there — you must spell out the underlying command.
|
|||||||
If a command "works in my terminal but not when the agent runs it," this alias
|
If a command "works in my terminal but not when the agent runs it," this alias
|
||||||
gap is the first thing to suspect.
|
gap is the first thing to suspect.
|
||||||
|
|
||||||
|
### A negative result is usually your own filter
|
||||||
|
|
||||||
|
**When you are about to report that something is absent, unreachable, or not
|
||||||
|
running, the filter you wrote is the prime suspect — not the thing.** This
|
||||||
|
environment produces false negatives cheaply, and they are convincing because
|
||||||
|
the command "succeeded". Three real instances from one session, all wrong, all
|
||||||
|
mine:
|
||||||
|
|
||||||
|
| Claim I made | Why it was false |
|
||||||
|
|---|---|
|
||||||
|
| "`tor-ms22` is not in the SSH config" | `grep … \| head -20` — the entry was at **line 454**. `~/.ssh/config` here is ~500 lines. |
|
||||||
|
| "the Docker host has no `docker`" | non-interactive SSH `PATH` lacks `/usr/local/bin` (§2, §3). It was at `/usr/local/bin/docker`. |
|
||||||
|
| "no ControlMaster is running" | pattern `ssh ` (trailing space) cannot match a master: those processes **rename themselves** to `ssh: <controlpath> [mux]`. |
|
||||||
|
|
||||||
|
Habits that would have caught all three:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# don't cap the output of a search whose answer you don't already know
|
||||||
|
grep -n -i -A6 'tor-ms22' ~/.ssh/config # not | head -20
|
||||||
|
|
||||||
|
# on the host, resolve the binary instead of trusting PATH
|
||||||
|
ssh -F "$HOME/.ssh-local/config" mac 'command -v docker || ls /usr/local/bin/docker'
|
||||||
|
|
||||||
|
# match a process's ACTUAL argv, not the name you imagine
|
||||||
|
ps -eo pid,etime,args | grep -Ei 'mux|mosh|ssh'
|
||||||
|
```
|
||||||
|
|
||||||
|
A positive result needs no such scepticism — it carries its own evidence. Only
|
||||||
|
absence has to be *earned*, so spend the extra command there.
|
||||||
|
|
||||||
**`dscp`/`scp` with accented filenames on a macOS host.** macOS stores filenames
|
**`dscp`/`scp` with accented filenames on a macOS host.** macOS stores filenames
|
||||||
in Unicode **NFD** (decomposed — e.g. `ä` is `a` + combining U+0308), while the
|
in Unicode **NFD** (decomposed — e.g. `ä` is `a` + combining U+0308), while the
|
||||||
string you type or paste is usually **NFC** (precomposed `ä`, U+00E4). The bytes
|
string you type or paste is usually **NFC** (precomposed `ä`, U+00E4). The bytes
|
||||||
@@ -175,6 +205,23 @@ Two related mechanisms (don't reinvent them):
|
|||||||
CGNAT per-destination flow caps on residential ISPs. If `~/.ssh/config` pins
|
CGNAT per-destination flow caps on residential ISPs. If `~/.ssh/config` pins
|
||||||
a `ControlPath` under the read-only `~/.ssh`, override with
|
a `ControlPath` under the read-only `~/.ssh`, override with
|
||||||
`-o ControlPath=none` (or use the sidecar, which already redirects it).
|
`-o ControlPath=none` (or use the sidecar, which already redirects it).
|
||||||
|
- **A live master socket MASKS auth and config changes on the far end.** Once
|
||||||
|
`~/.ssh-local/cm/<user>@<host>:22` exists, later commands ride it and
|
||||||
|
authenticate **not at all** — so after editing remote `authorized_keys`,
|
||||||
|
`sshd_config`, host keys, or firewall rules, "it still works" proves nothing.
|
||||||
|
A corrupted `authorized_keys` then bites on the next *cold* connect, likely in
|
||||||
|
a future session with no memory of the edit. Prove it immediately instead:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
ssh -F "$HOME/.ssh-local/config" -O check <host> # 'Master running (pid=N)'
|
||||||
|
ssh -F "$HOME/.ssh-local/config" -o ControlPath=none -o ControlMaster=no \
|
||||||
|
-o BatchMode=yes <host> 'echo COLD AUTH OK'
|
||||||
|
```
|
||||||
|
|
||||||
|
To attribute a socket rather than guess whose it is: `ps -p <pid> -o
|
||||||
|
pid,ppid,lstart,etime,args`. A `mosh` the *user* started on the host
|
||||||
|
bootstraps with the **host's** `~/.ssh/cm/` and is invisible from in here;
|
||||||
|
only a mosh started *inside* the container shares `~/.ssh-local/cm/`.
|
||||||
- **`pi --ssh <host>`** rewires pi's own read/write/edit/bash tools to run on a
|
- **`pi --ssh <host>`** rewires pi's own read/write/edit/bash tools to run on a
|
||||||
remote host; it has its own writable-socket fallback. See the `pi-extensions`
|
remote host; it has its own writable-socket fallback. See the `pi-extensions`
|
||||||
skill for that path.
|
skill for that path.
|
||||||
@@ -257,6 +304,10 @@ hardcode. Details are in the `mempalace` skill.
|
|||||||
- [ ] Writing durable output? → `/workspace`, not the ephemeral layer.
|
- [ ] Writing durable output? → `/workspace`, not the ephemeral layer.
|
||||||
- [ ] Using `dssh`/`dscp`/`ll` in the bash tool? → spell out the real command.
|
- [ ] Using `dssh`/`dscp`/`ll` in the bash tool? → spell out the real command.
|
||||||
- [ ] Assuming a hostname / domain / nameserver / host OS? → stop, detect it.
|
- [ ] Assuming a hostname / domain / nameserver / host OS? → stop, detect it.
|
||||||
|
- [ ] About to report something **absent / unreachable / not running**? → re-run
|
||||||
|
without your own `head`/pattern/`PATH` assumptions first (§2).
|
||||||
|
- [ ] Changed remote `authorized_keys` / `sshd_config`? → prove it with a **cold**
|
||||||
|
connect; a live master socket hides breakage (§3).
|
||||||
- [ ] "Resolves but won't connect"? → check route *and* DNS (§3 + §4).
|
- [ ] "Resolves but won't connect"? → check route *and* DNS (§3 + §4).
|
||||||
- [ ] `apt`/toolchain install? → tell the user it's ephemeral unless imaged.
|
- [ ] `apt`/toolchain install? → tell the user it's ephemeral unless imaged.
|
||||||
- [ ] Editing a skill? → `readlink -f ~/.agents/skills/<name>` first (§1).
|
- [ ] Editing a skill? → `readlink -f ~/.agents/skills/<name>` first (§1).
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Skills in this directory whose OWNER is the skillset repo.
|
||||||
|
#
|
||||||
|
# Read by devbox-skill-reconcile, which runs after the skillset deploy in
|
||||||
|
# entrypoint-user.sh: for each name below, if the mounted skillset ships a
|
||||||
|
# skill of that name, the baked link in ~/.agents/skills/ is repointed at the
|
||||||
|
# live clone. The baked copy remains the fallback for containers started
|
||||||
|
# WITHOUT a skillset mount, and a user override always wins over both.
|
||||||
|
#
|
||||||
|
# Add a name here ONLY if the skillset repo is the authoritative source (see
|
||||||
|
# the ownership table in VENDORED.md). Do NOT add:
|
||||||
|
# pi-devbox-environment — authored in this repo; baked IS canonical
|
||||||
|
# pi-extensions — owned by the pi-extensions package repo and copied
|
||||||
|
# over the snapshot at build time; the skillset copy
|
||||||
|
# is a downstream duplicate that can lag, so letting
|
||||||
|
# it win would regress the skill.
|
||||||
|
mempalace
|
||||||
@@ -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
|
||||||
|
|||||||
+274
-1
@@ -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,136 @@ 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/"
|
||||||
|
'
|
||||||
|
# The feeder's --agent default is WHO a drawer is attributed to. mempalace core
|
||||||
|
# records neither the machine nor the harness on a write, and one shared bearer
|
||||||
|
# token means the server cannot tell clients apart, so toolkit c64ffa1 changed
|
||||||
|
# this default from $USER to pi@$MEMPALACE_PI_DEVICE — the one string that makes
|
||||||
|
# a write attributable to both. Nothing ever PRINTED the resolved value (the
|
||||||
|
# banner shows mode= and stage= only), so an image built from a pre-c64ffa1
|
||||||
|
# toolkit ref would ship unattributed writes with every check still green.
|
||||||
|
#
|
||||||
|
# `--help` assigns AGENT (script top) before it parses args, then exits 0 with
|
||||||
|
# no side effects — so `bash -x` observes the REAL resolution, env interpolation
|
||||||
|
# and fallback included, rather than grepping the source for a literal line that
|
||||||
|
# any reformat would break. Two-sided on purpose: device set => pi@<device>;
|
||||||
|
# device UNSET => must not be pi@anything. The second half is what fails against
|
||||||
|
# the old unconditional $USER default, which ignored the device entirely.
|
||||||
|
#
|
||||||
|
# Probes the PATH entry (a symlink into the /opt clone) rather than that clone
|
||||||
|
# path directly: this is the invocation the systemd/launchd timers and
|
||||||
|
# entrypoint-user.sh actually use, so it is the default that reaches the palace.
|
||||||
|
run "feeder resolves --agent to pi@<device> (drawer attribution)" '
|
||||||
|
f=$(command -v mempalace-pi-session) || { echo "feeder not on PATH" >&2; exit 1; }
|
||||||
|
with=$(MEMPALACE_PI_DEVICE=smoke-device bash -x $f --help 2>&1 | sed -n "s/^+* *AGENT=//p" | tail -n1)
|
||||||
|
without=$(env -u MEMPALACE_PI_DEVICE bash -x $f --help 2>&1 | sed -n "s/^+* *AGENT=//p" | tail -n1)
|
||||||
|
echo "resolved with-device=[$with] without-device=[$without]" >&2
|
||||||
|
[ "$with" = "pi@smoke-device" ] || exit 1
|
||||||
|
case "$without" in pi@*) exit 1 ;; esac
|
||||||
|
echo ok
|
||||||
|
'
|
||||||
|
# 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'"
|
||||||
|
# A remote mine that FAILS must not report success. MCP answers a hard tool
|
||||||
|
# failure with HTTP 200 and the tool's own JSON escaped inside
|
||||||
|
# result.content[].text, so the feeder's old `'\"error\"' in body` check could
|
||||||
|
# never see it: on 2026-08-15 a mine that died with "source directory not found:
|
||||||
|
# '/data/feed/...'" logged "Done. Wing updated." and exited 0, and this
|
||||||
|
# container's transcripts were filed nowhere for a whole session. The feeder
|
||||||
|
# carries fixtures for that exact body; run them against the baked toolkit so a
|
||||||
|
# stale/reverted toolkit ref can't reintroduce a silent feed.
|
||||||
|
run "baked feeder detects a failed remote mine (no silent false success)" \
|
||||||
|
"mempalace-pi-session --self-test"
|
||||||
# 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"
|
||||||
@@ -119,6 +262,16 @@ run "image-baked mempalace fallback skill" \
|
|||||||
# baked copy must be the fresh package copy (Option 1), not the stale snapshot.
|
# baked copy must be the fresh package copy (Option 1), not the stale snapshot.
|
||||||
run "pi-extensions skill refreshed from package when present" \
|
run "pi-extensions skill refreshed from package when present" \
|
||||||
"if [ -f /opt/pi-extensions/skill/SKILL.md ]; then cmp -s /opt/pi-extensions/skill/SKILL.md /usr/local/share/pi-devbox/skills/pi-extensions/SKILL.md; else true; fi"
|
"if [ -f /opt/pi-extensions/skill/SKILL.md ]; then cmp -s /opt/pi-extensions/skill/SKILL.md /usr/local/share/pi-devbox/skills/pi-extensions/SKILL.md; else true; fi"
|
||||||
|
# Runtime ownership handover (v1.8.5): the baked links are a FALLBACK, and
|
||||||
|
# skillset-OWNED skills must be repointed at the live clone when one is mounted.
|
||||||
|
# The list is data, so assert its content, not just its presence: mempalace in,
|
||||||
|
# pi-extensions deliberately out (its skillset copy is a lagging duplicate).
|
||||||
|
run "devbox-skill-reconcile helper present + executable" \
|
||||||
|
"test -x /usr/local/bin/devbox-skill-reconcile"
|
||||||
|
run "skillset-owned list ships and names mempalace" \
|
||||||
|
"grep -qx 'mempalace' /usr/local/share/pi-devbox/skills/skillset-owned.txt"
|
||||||
|
run "skillset-owned list excludes pi-extensions (ownership)" \
|
||||||
|
"! grep -qx 'pi-extensions' /usr/local/share/pi-devbox/skills/skillset-owned.txt"
|
||||||
|
|
||||||
# ── tmux 0-indexing (required for pi-studio variants) ─────────────────
|
# ── tmux 0-indexing (required for pi-studio variants) ─────────────────
|
||||||
echo ""
|
echo ""
|
||||||
@@ -137,6 +290,44 @@ 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"
|
||||||
|
# ...and that the clone carries the AUTH FIX, not merely that it exists. om's
|
||||||
|
# pre-flight hasUsableAuth() check silently disabled `recall` for ~8 weeks once
|
||||||
|
# pi moved to request-time SigV4 signing and stopped exposing a static Bedrock
|
||||||
|
# key; upstream fixed it in ce9fc98, adopted in v1.8.4. PI_OBSMEM_REF tracks
|
||||||
|
# master, so an upstream revert or force-push would ship a dead `recall` with
|
||||||
|
# the clone assertion above still green — the exact gap flagged as open in the
|
||||||
|
# v1.8.5 changelog.
|
||||||
|
#
|
||||||
|
# Pin the markers to src/runtime.ts, the fix SITE, rather than grepping the
|
||||||
|
# repo: two of these three strings also appear under tests/, so a repo-wide
|
||||||
|
# grep stays green with runtime.ts itself reverted. That is a false green of the
|
||||||
|
# same family as the old skill-snapshot canary.
|
||||||
|
run "pi-observational-memory carries the ce9fc98 auth fix (recall stays alive)" '
|
||||||
|
f=/opt/pi-observational-memory/src/runtime.ts
|
||||||
|
test -f "$f" || { echo "fix site missing: $f" >&2; exit 1; }
|
||||||
|
for m in availability_recheck providerCredentialConfigured hasConfiguredAuth; do
|
||||||
|
grep -q "$m" "$f" || { echo "marker absent from runtime.ts: $m" >&2; exit 1; }
|
||||||
|
done
|
||||||
|
echo ok
|
||||||
|
'
|
||||||
|
# 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,8 +350,24 @@ 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"'
|
||||||
|
# mempalace CORE was absent from the manifest through v1.8.5: the toolkit SHA
|
||||||
|
# was recorded but the palace version behind the MCP tools was not, so a palace
|
||||||
|
# bug could not be correlated to an image version. Assert the field exists AND
|
||||||
|
# equals the installed binary — recording it from ARG MEMPALACE_VERSION instead
|
||||||
|
# would look identical here yet drift silently the first time an install
|
||||||
|
# resolved to something other than the pin, which is the whole reason this file
|
||||||
|
# is built from ground truth. `// empty` matters: jq -r prints the 4-char
|
||||||
|
# string "null" for a JSON null, which would satisfy a naive -n test.
|
||||||
|
run "manifest mempalace_version matches the installed core" '
|
||||||
|
m=$(jq -r ".mempalace_version // empty" /etc/pi-devbox/build-manifest.json)
|
||||||
|
b=$(mempalace --version 2>/dev/null | head -n1 | tr -d "\r"); b=${b##* }
|
||||||
|
echo "manifest=[$m] installed=[$b]" >&2
|
||||||
|
[ -n "$m" ] && [ "$m" = "$b" ]
|
||||||
|
'
|
||||||
# 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
|
||||||
# non-studio variant) — 'unknown' means a clone silently failed to resolve.
|
# non-studio variant) — 'unknown' means a clone silently failed to resolve.
|
||||||
run "manifest has no unresolved ('unknown') components" \
|
run "manifest has no unresolved ('unknown') components" \
|
||||||
@@ -232,6 +439,54 @@ exec_test "settings.json bootstrapped" 'test -f $HOME/.pi/agent/sett
|
|||||||
exec_test "pi-devbox-environment skill linked" 'test -L $HOME/.agents/skills/pi-devbox-environment && test -f $HOME/.agents/skills/pi-devbox-environment/SKILL.md && echo ok'
|
exec_test "pi-devbox-environment skill linked" 'test -L $HOME/.agents/skills/pi-devbox-environment && test -f $HOME/.agents/skills/pi-devbox-environment/SKILL.md && echo ok'
|
||||||
exec_test "pi-extensions skill linked (fallback)" 'test -L $HOME/.agents/skills/pi-extensions && test -f $HOME/.agents/skills/pi-extensions/SKILL.md && echo ok'
|
exec_test "pi-extensions skill linked (fallback)" 'test -L $HOME/.agents/skills/pi-extensions && test -f $HOME/.agents/skills/pi-extensions/SKILL.md && echo ok'
|
||||||
exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills/mempalace && test -f $HOME/.agents/skills/mempalace/SKILL.md && echo ok'
|
exec_test "mempalace skill linked (fallback)" 'test -L $HOME/.agents/skills/mempalace && test -f $HOME/.agents/skills/mempalace/SKILL.md && echo ok'
|
||||||
|
# The vendored mempalace snapshot is refreshed MANUALLY per release (see
|
||||||
|
# rootfs/usr/local/share/pi-devbox/skills/VENDORED.md). Through v1.8.4 it also
|
||||||
|
# silently SHADOWED the live skillset copy, so staleness was invisible — and the
|
||||||
|
# canary that was supposed to catch it could not: it grepped "Shared palace:
|
||||||
|
# multiple harnesses", a phrase present in BOTH the stale and the fresh copy.
|
||||||
|
# A snapshot canary must pin the NEWEST section, so update this string whenever
|
||||||
|
# the snapshot is refreshed — that is the point of it.
|
||||||
|
exec_test "mempalace skill snapshot is current" 'grep -q "Attribute what you file yourself" $HOME/.agents/skills/mempalace/SKILL.md && echo ok'
|
||||||
|
# Link TARGETS, not just link existence: with no skillset mounted (as here) the
|
||||||
|
# baked tree must be what resolves, for all three vendored skills.
|
||||||
|
exec_test "vendored skills resolve to the baked tree (no skillset mounted)" \
|
||||||
|
'for s in mempalace pi-extensions pi-devbox-environment; do
|
||||||
|
case "$(readlink -f $HOME/.agents/skills/$s)" in
|
||||||
|
/usr/local/share/pi-devbox/skills/$s) ;;
|
||||||
|
*) echo "$s resolves to $(readlink -f $HOME/.agents/skills/$s)" >&2; exit 1 ;;
|
||||||
|
esac
|
||||||
|
done; echo ok'
|
||||||
|
# The handover path itself. CI never mounts a skillset, so without this the
|
||||||
|
# v1.8.5 fix would ship untested: fabricate a skillset + a skills dir holding
|
||||||
|
# baked-style links, run the reconciler, and assert all three outcomes —
|
||||||
|
# owned skill repointed, unowned skill left baked, user override untouched.
|
||||||
|
exec_test "reconciler: owned skill handed to live clone, others untouched" \
|
||||||
|
'set -e; t=$(mktemp -d); mkdir -p $t/ss/skills/mempalace $t/ss/skills/pi-extensions $t/skills
|
||||||
|
echo LIVE > $t/ss/skills/mempalace/SKILL.md; echo LIVE > $t/ss/skills/pi-extensions/SKILL.md
|
||||||
|
ln -s /usr/local/share/pi-devbox/skills/mempalace $t/skills/mempalace
|
||||||
|
ln -s /usr/local/share/pi-devbox/skills/pi-extensions $t/skills/pi-extensions
|
||||||
|
mkdir -p $t/skills/mine; echo MINE > $t/skills/mine/SKILL.md
|
||||||
|
devbox-skill-reconcile $t/ss $t/skills >/dev/null
|
||||||
|
devbox-skill-reconcile $t/ss $t/skills >/dev/null # idempotent
|
||||||
|
[ "$(readlink $t/skills/mempalace)" = "$t/ss/skills/mempalace" ] || { echo "owned skill NOT repointed" >&2; exit 1; }
|
||||||
|
[ "$(readlink $t/skills/pi-extensions)" = /usr/local/share/pi-devbox/skills/pi-extensions ] || { echo "unowned skill was repointed" >&2; exit 1; }
|
||||||
|
[ "$(cat $t/skills/mine/SKILL.md)" = MINE ] || { echo "user override clobbered" >&2; exit 1; }
|
||||||
|
rm -rf $t; echo ok'
|
||||||
|
# The case above cannot fail if the reconciler stops checking WHERE a link
|
||||||
|
# points — a mutation test showed all three of its assertions still passing with
|
||||||
|
# that guard deleted, which is the same false-green shape as the old snapshot
|
||||||
|
# canary. This one discriminates: an OWNED name (so it is considered) whose link
|
||||||
|
# is a user override pointing outside the baked tree (so it must be left alone).
|
||||||
|
exec_test "reconciler: user override on an owned name is left alone" \
|
||||||
|
'set -e; t=$(mktemp -d); mkdir -p $t/ss/skills/mempalace $t/skills $t/mine-skill
|
||||||
|
echo LIVE > $t/ss/skills/mempalace/SKILL.md; echo USERLINK > $t/mine-skill/SKILL.md
|
||||||
|
ln -sfn $t/mine-skill $t/skills/mempalace
|
||||||
|
devbox-skill-reconcile $t/ss $t/skills >/dev/null
|
||||||
|
[ "$(cat $t/skills/mempalace/SKILL.md)" = USERLINK ] || { echo "user symlink override clobbered" >&2; exit 1; }
|
||||||
|
rm -rf $t; echo ok'
|
||||||
|
# mempalace-census gained a /usr/local/bin symlink in v1.8.3; its three siblings
|
||||||
|
# had one since they were added, so this asserts the set stays complete.
|
||||||
|
exec_test "mempalace-census on PATH" 'command -v mempalace-census >/dev/null && mempalace-census --help >/dev/null && echo ok'
|
||||||
|
|
||||||
# pi-fork + pi-observational-memory are registered by entrypoint-user.sh via
|
# pi-fork + pi-observational-memory are registered by entrypoint-user.sh via
|
||||||
# `pi install /opt/<pkg>`, which runs slightly after the keybindings marker.
|
# `pi install /opt/<pkg>`, which runs slightly after the keybindings marker.
|
||||||
@@ -271,6 +526,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'
|
||||||
|
|||||||
Reference in New Issue
Block a user