Compare commits
24 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| ed49b8d97a | |||
| 9eff3f3c48 | |||
| a0abacaafb | |||
| da7d70825e | |||
| 41c2c2b716 | |||
| 5c08bfc8a8 | |||
| 1371584634 | |||
| d902b2d056 | |||
| c48abf41d1 | |||
| 777d53354f | |||
| 52fe09d79d | |||
| c9534c639f | |||
| 4ed6764323 | |||
| f8da7890df | |||
| b17dc1fa1f | |||
| 3eec9bc23c | |||
| 4744f05232 | |||
| 314c3767a8 | |||
| 05e88c5c75 | |||
| 7f67c36a1c | |||
| ab5ff8ec56 | |||
| 421558477d | |||
| b655faab9f | |||
| 3b0335f34e |
@@ -47,6 +47,7 @@ env:
|
||||
jobs:
|
||||
# ── Phase 1: decide whether base needs rebuilding ──────────────────
|
||||
base-decide:
|
||||
needs: [resolve-versions]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -57,6 +58,9 @@ jobs:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Guard — base *_REF args must be folded into the base hash
|
||||
run: bash scripts/check-base-hash.sh
|
||||
|
||||
- name: Compute base tag from Dockerfile.base + dependencies
|
||||
id: compute
|
||||
run: |
|
||||
@@ -75,6 +79,10 @@ jobs:
|
||||
! -name '._*' \
|
||||
-print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
|
||||
cat entrypoint.sh entrypoint-user.sh
|
||||
# mempalace-toolkit is cloned in Dockerfile.base at a ref CI
|
||||
# resolves to a SHA; fold it in so base_tag changes when the
|
||||
# toolkit moves (otherwise a toolkit-only fix never lands).
|
||||
echo "${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}"
|
||||
} | sha256sum | cut -c1-12
|
||||
)
|
||||
BASE_TAG="base-${HASH}"
|
||||
@@ -117,54 +125,85 @@ jobs:
|
||||
toolkit_ref: ${{ steps.resolve.outputs.toolkit_ref }}
|
||||
extensions_ref: ${{ steps.resolve.outputs.extensions_ref }}
|
||||
studio_ref: ${{ steps.resolve.outputs.studio_ref }}
|
||||
mempalace_toolkit_ref: ${{ steps.resolve.outputs.mempalace_toolkit_ref }}
|
||||
steps:
|
||||
- name: Resolve pi version + companion refs
|
||||
id: resolve
|
||||
shell: bash
|
||||
run: |
|
||||
set -eu
|
||||
# Query npm registry directly; catthehacker/ubuntu:act-latest's npm
|
||||
# is not reliably on PATH in act_runner job containers.
|
||||
PI_VERSION=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version')
|
||||
set -euo pipefail
|
||||
AUTH_HEADER="Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}"
|
||||
|
||||
# Fail loud rather than silently shipping a floating branch. A
|
||||
# transient network/API failure must ABORT the release, not bake
|
||||
# an unpinned ref that defeats both cache-busting AND after-the-
|
||||
# fact reproducibility. (Previously each lookup fell back to
|
||||
# `main`/`master` via `|| echo`.)
|
||||
require_sha() { # $1=label $2=value
|
||||
if ! printf '%s' "${2:-}" | grep -qiE '^[0-9a-f]{40}$'; then
|
||||
echo "::error::Could not resolve $1 to a commit SHA (got '${2:-<empty>}'). Refusing to fall back to a floating ref — published images must stay reproducible. Check connectivity and GITEA_BUILD_TOKEN/GITHUB_TOKEN."
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
# pi version from npm (catthehacker/ubuntu:act-latest's npm is not
|
||||
# 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)
|
||||
if ! printf '%s' "${PI_VERSION:-}" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+'; then
|
||||
echo "::error::Could not resolve pi version from npm (got '${PI_VERSION:-<empty>}')."
|
||||
exit 1
|
||||
fi
|
||||
echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT"
|
||||
# Resolve pi-fork / pi-observational-memory git refs to commit
|
||||
# SHAs so the build-arg string changes whenever upstream moves.
|
||||
|
||||
# pi-fork / pi-observational-memory (GitHub) → commit SHAs.
|
||||
FORK_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||
"https://api.github.com/repos/elpapi42/pi-fork/commits/master" || echo "master")
|
||||
"https://api.github.com/repos/elpapi42/pi-fork/commits/master" || true)
|
||||
require_sha PI_FORK_REF "$FORK_REF"
|
||||
OBSMEM_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||
"https://api.github.com/repos/elpapi42/pi-observational-memory/commits/master" || echo "master")
|
||||
[ -n "$FORK_REF" ] || FORK_REF=master
|
||||
[ -n "$OBSMEM_REF" ] || OBSMEM_REF=master
|
||||
"https://api.github.com/repos/elpapi42/pi-observational-memory/commits/master" || true)
|
||||
require_sha PI_OBSMEM_REF "$OBSMEM_REF"
|
||||
echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "obsmem_ref=${OBSMEM_REF}" >> "$GITHUB_OUTPUT"
|
||||
# Also resolve pi-toolkit / pi-extensions main HEADs to SHAs so a
|
||||
# workflow_dispatch re-run produces byte-identical images when
|
||||
# those repos haven't moved (and a clean diff in build-arg strings
|
||||
# when they have, defeating the registry buildcache footgun).
|
||||
# Gitea API requires auth even for public-repo commit listing.
|
||||
TOOLKIT_REF=$(curl -sf -H "Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}" \
|
||||
|
||||
# pi-toolkit / pi-extensions (Gitea) → commit SHAs. Gitea API
|
||||
# requires auth even for public-repo commit listing.
|
||||
TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \
|
||||
"https://gitea.jordbo.se/api/v1/repos/joakimp/pi-toolkit/commits?limit=1&sha=main" \
|
||||
| jq -r '.[0].sha // "main"' 2>/dev/null || echo "main")
|
||||
EXTENSIONS_REF=$(curl -sf -H "Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}" \
|
||||
| jq -r '.[0].sha // empty' 2>/dev/null || true)
|
||||
require_sha PI_TOOLKIT_REF "$TOOLKIT_REF"
|
||||
EXTENSIONS_REF=$(curl -sf -H "$AUTH_HEADER" \
|
||||
"https://gitea.jordbo.se/api/v1/repos/joakimp/pi-extensions/commits?limit=1&sha=main" \
|
||||
| jq -r '.[0].sha // "main"' 2>/dev/null || echo "main")
|
||||
[ -n "$TOOLKIT_REF" ] || TOOLKIT_REF=main
|
||||
[ -n "$EXTENSIONS_REF" ] || EXTENSIONS_REF=main
|
||||
| jq -r '.[0].sha // empty' 2>/dev/null || true)
|
||||
require_sha PI_EXTENSIONS_REF "$EXTENSIONS_REF"
|
||||
echo "toolkit_ref=${TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "extensions_ref=${EXTENSIONS_REF}" >> "$GITHUB_OUTPUT"
|
||||
# Resolve pi-studio (omaclaren/pi-studio) main HEAD to a SHA for
|
||||
# the :latest-studio variant — same cache-busting rationale.
|
||||
|
||||
# mempalace-toolkit (Gitea) → commit SHA. UNLIKE the others this
|
||||
# is cloned in Dockerfile.base, so the SAME SHA is ALSO folded
|
||||
# into the base-decide hash (see that job) to force a base rebuild
|
||||
# when the toolkit moves — otherwise a toolkit-only fix silently
|
||||
# fails to land unless Dockerfile.base itself changes.
|
||||
MEMPALACE_TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \
|
||||
"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"
|
||||
echo "mempalace_toolkit_ref=${MEMPALACE_TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
# pi-studio (omaclaren/pi-studio) → commit SHA for :latest-studio.
|
||||
STUDIO_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||
"https://api.github.com/repos/omaclaren/pi-studio/commits/main" || echo "main")
|
||||
[ -n "$STUDIO_REF" ] || STUDIO_REF=main
|
||||
"https://api.github.com/repos/omaclaren/pi-studio/commits/main" || true)
|
||||
require_sha PI_STUDIO_REF "$STUDIO_REF"
|
||||
echo "studio_ref=${STUDIO_REF}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
echo "Resolved PI_VERSION=${PI_VERSION}"
|
||||
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_STUDIO_REF=${STUDIO_REF}"
|
||||
echo "Resolved MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}"
|
||||
|
||||
# ── Phase 2: build & push base (multi-arch), only when needed ──────
|
||||
build-base:
|
||||
needs: [base-decide]
|
||||
needs: [base-decide, resolve-versions]
|
||||
if: needs.base-decide.outputs.need_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
@@ -206,6 +245,7 @@ jobs:
|
||||
shell: bash
|
||||
env:
|
||||
BASE_TAG_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 3-attempt retry around `docker buildx build --push` for transient
|
||||
@@ -219,6 +259,7 @@ jobs:
|
||||
if docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file Dockerfile.base \
|
||||
--build-arg MEMPALACE_TOOLKIT_REF="${MEMPALACE_TOOLKIT_REF}" \
|
||||
--push \
|
||||
--tag "${BASE_TAG_FULL}" \
|
||||
.; then
|
||||
@@ -280,6 +321,9 @@ jobs:
|
||||
PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
PI_TOOLKIT_REF=${{ needs.resolve-versions.outputs.toolkit_ref }}
|
||||
PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }}
|
||||
MEMPALACE_TOOLKIT_REF=${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
|
||||
RELEASE_TAG=smoke
|
||||
SOURCE_REVISION=${{ github.sha }}
|
||||
- name: Smoke test (amd64)
|
||||
env:
|
||||
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
@@ -336,6 +380,9 @@ jobs:
|
||||
PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }}
|
||||
INSTALL_STUDIO=true
|
||||
PI_STUDIO_REF=${{ needs.resolve-versions.outputs.studio_ref }}
|
||||
MEMPALACE_TOOLKIT_REF=${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
|
||||
RELEASE_TAG=smoke-studio
|
||||
SOURCE_REVISION=${{ github.sha }}
|
||||
- name: Smoke test studio (amd64)
|
||||
env:
|
||||
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
@@ -387,10 +434,12 @@ jobs:
|
||||
OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }}
|
||||
EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }}
|
||||
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_FLAGS=()
|
||||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||||
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
# 3-attempt retry (see build-base step for rationale).
|
||||
for attempt in 1 2 3; do
|
||||
echo "==> Build+push attempt ${attempt}/3"
|
||||
@@ -404,6 +453,10 @@ jobs:
|
||||
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
|
||||
--build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \
|
||||
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
|
||||
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
|
||||
--build-arg "RELEASE_TAG=${RELEASE_TAG}" \
|
||||
--build-arg "BUILD_DATE=${BUILD_DATE}" \
|
||||
--build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \
|
||||
"${TAG_FLAGS[@]}" \
|
||||
.; then
|
||||
echo "==> Attempt ${attempt} succeeded"
|
||||
@@ -468,10 +521,12 @@ jobs:
|
||||
TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }}
|
||||
EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }}
|
||||
STUDIO_REF: ${{ needs.resolve-versions.outputs.studio_ref }}
|
||||
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_FLAGS=()
|
||||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||||
BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
||||
# 3-attempt retry (see build-base step for rationale).
|
||||
for attempt in 1 2 3; do
|
||||
echo "==> Build+push attempt ${attempt}/3"
|
||||
@@ -485,8 +540,12 @@ jobs:
|
||||
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
|
||||
--build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \
|
||||
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
|
||||
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
|
||||
--build-arg "INSTALL_STUDIO=true" \
|
||||
--build-arg "PI_STUDIO_REF=${STUDIO_REF}" \
|
||||
--build-arg "RELEASE_TAG=${RELEASE_TAG}" \
|
||||
--build-arg "BUILD_DATE=${BUILD_DATE}" \
|
||||
--build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \
|
||||
"${TAG_FLAGS[@]}" \
|
||||
.; then
|
||||
echo "==> Attempt ${attempt} succeeded"
|
||||
|
||||
@@ -45,9 +45,17 @@ re-brand of opencode-devbox's `pi-only` variant.
|
||||
|
||||
1. Confirm `pi --version` resolves from npm to the expected version
|
||||
(`curl -sf 'https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest' | jq -r .version`).
|
||||
Check release notes at https://github.com/earendil-works/pi/releases for
|
||||
the upstream changelog to include in `CHANGELOG.md`.
|
||||
2. Update `CHANGELOG.md` Unreleased → vX.Y.Z section.
|
||||
3. Verify `docker compose up` works locally with the current `latest` image
|
||||
if you're upgrading users from a previous version.
|
||||
if you're upgrading users from a previous version. Then run the
|
||||
**post-recreate sanity check** inside the running container to confirm
|
||||
persisted volumes survived and the pi runtime wiring re-deployed (not just
|
||||
that the container booted):
|
||||
`docker compose exec devbox bash scripts/recreate-sanity-check.sh --expected-version X.Y.Z`
|
||||
(or just `pi-devbox-sanity --expected-version X.Y.Z` if `cli_utils/bin` is
|
||||
on PATH). This is the runtime peer of the build-time `smoke-test.sh` gate.
|
||||
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 +
|
||||
pi version + new-base-tooling presence. Variant build is multi-arch
|
||||
@@ -55,7 +63,24 @@ re-brand of opencode-devbox's `pi-only` variant.
|
||||
6. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus
|
||||
base-latest if the base was rebuilt this run).
|
||||
7. **Revoke any short-lived Gitea PAT** used during the release at
|
||||
`gitea.jordbo.se/user/settings/applications`.
|
||||
`gitea.jordbo.se/user/settings/applications`. N/A if you used the
|
||||
`GITEA_ACCESS_TOKEN` env var instead (see *Gitea API access* below) —
|
||||
its lifecycle is managed host-side, nothing to revoke.
|
||||
|
||||
## Gitea API access (env token)
|
||||
|
||||
`GITEA_ACCESS_TOKEN` + `GITEA_HOST` are passed into the container from the
|
||||
host `.env` via `docker-compose.yml` (`${GITEA_ACCESS_TOKEN:-}` /
|
||||
`${GITEA_HOST:-}`), primarily to enable the `gitea-mcp` server. They are
|
||||
**not** baked into the image. When configured, they are also available for
|
||||
**any** direct Gitea API interaction from inside the container — inspecting
|
||||
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"`.
|
||||
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
|
||||
unauthenticated too, so the token matters mainly for private repos or
|
||||
rate-limit headroom; its lifecycle is host-managed, so there is nothing to
|
||||
revoke after use. Never echo the token value (including into logs).
|
||||
|
||||
## Cache-hit footgun (must-know)
|
||||
|
||||
|
||||
+318
@@ -13,6 +13,324 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v1.1.6 — 2026-06-19
|
||||
|
||||
Build provenance + reproducibility hardening, plus pi `0.79.7` → `0.79.8`
|
||||
(auto-resolved at build). Companion refs are auto-resolved to SHAs at build
|
||||
as before.
|
||||
|
||||
### Bumped: pi 0.79.7 → 0.79.8
|
||||
|
||||
Notable upstream changes (from [pi releases](https://github.com/earendil-works/pi/releases/tag/v0.79.8)):
|
||||
|
||||
- **Selective provider base entry points** — SDK users can pair
|
||||
`@earendil-works/pi-ai/base` and `@earendil-works/pi-agent-core/base` with
|
||||
explicit provider registration to keep bundled apps from including unused
|
||||
provider transports.
|
||||
- **Mistral prompt caching** — Mistral sessions use provider-side prompt
|
||||
caching keyed on the pi session ID, with cached-token usage/cost
|
||||
accounting.
|
||||
- **Post-compaction token estimates** — compact results and compaction
|
||||
events now include estimated post-compaction token counts.
|
||||
- **OpenRouter Fusion alias** — `openrouter/fusion` available as a built-in
|
||||
OpenRouter model alias.
|
||||
|
||||
### Added
|
||||
|
||||
- **Self-describing images: OCI labels + on-disk build manifest.** The
|
||||
variant build now records exactly which pi version and companion-repo
|
||||
commits were baked into each image. Previously the SHAs resolved by CI
|
||||
only ever reached the build log (which rotates), so a published tag was
|
||||
not reconstructable after the fact — confirming what shipped meant
|
||||
triangulating from `git`, `pi --version`, and extension source.
|
||||
- OCI labels: `org.opencontainers.image.{version,revision,created}` plus
|
||||
`se.jordbo.pi-devbox.{pi,pi-toolkit,pi-extensions,pi-fork,pi-obsmem,mempalace-toolkit,pi-studio}-*ref` —
|
||||
inspect with `docker inspect`.
|
||||
- `/etc/pi-devbox/build-manifest.json` written from **ground truth** (the
|
||||
actual checked-out `HEAD` of each `/opt` clone + live `pi --version`),
|
||||
not just the intended build-args, so it also exposes a clone that
|
||||
silently resolved to the wrong ref. The provenance ARGs are declared
|
||||
last so a changing `BUILD_DATE` never invalidates the expensive
|
||||
install/clone layers.
|
||||
- **`scripts/check-base-hash.sh` — base-rebuild invariant guard.** Every
|
||||
floating `ARG *_REF` consumed by `Dockerfile.base` must be folded into the
|
||||
`base_tag` hash, or a ref-only change won't trigger a base rebuild (the
|
||||
v1.1.2 mempalace-toolkit staleness footgun). The guard fails CI the moment
|
||||
someone adds an `ARG *_REF` to `Dockerfile.base` without folding it in; it
|
||||
runs in the `base-decide` job and locally. Smoke-test gained assertions for
|
||||
the manifest (present, no `"unknown"` components) and the OCI labels.
|
||||
- **Overridable companion repo URLs.** The three gitea-hosted companions
|
||||
(`pi-toolkit`, `pi-extensions`, `mempalace-toolkit`) gained `*_REPO`
|
||||
build-args defaulting to their canonical `gitea.jordbo.se` origin —
|
||||
matching the existing `PI_FORK_REPO` / `PI_OBSMEM_REPO` / `PI_STUDIO_REPO`
|
||||
pattern. A relocated or forked build can now repoint a companion at a
|
||||
mirror, another host, or a local path (`--build-arg PI_EXTENSIONS_REPO=...`)
|
||||
without editing the Dockerfiles. Defaults are unchanged, so the canonical
|
||||
CI build is byte-identical.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`resolve-versions` now fails loud instead of falling back to a floating
|
||||
branch.** Each pi-version / companion-ref lookup previously degraded to
|
||||
`main`/`master` on a transient API/network failure (`|| echo "main"`),
|
||||
silently shipping an unpinned ref that defeats both cache-busting and
|
||||
reproducibility. Resolution now validates each result is a 40-hex commit
|
||||
SHA (and pi a real semver) and aborts the release otherwise.
|
||||
|
||||
---
|
||||
|
||||
## v1.1.5 — 2026-06-18
|
||||
|
||||
Patch release: SSH ControlMaster read-only-socket fix + pi `0.79.6` → `0.79.7`
|
||||
(auto-resolved at build). The `pi-extensions` ref is auto-resolved to `main`
|
||||
HEAD at build, so the `ssh-controlmaster` fix below lands automatically.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`pi --ssh <host>` no longer fails with "Read-only file system" when the
|
||||
user's `~/.ssh/config` sets a per-host `ControlPath` under the read-only
|
||||
`~/.ssh` mount** (e.g. the common CGNAT idiom `ControlPath ~/.ssh/cm/%r@%h:%p`).
|
||||
Root cause: SSH precedence means a user's per-host `ControlPath` always wins
|
||||
over the baked `/etc/ssh/ssh_config.d` default, so the master socket tried to
|
||||
bind under the RO `~/.ssh` and `ssh … pwd` exited 255 ("Could not resolve
|
||||
remote pwd"). The `ssh-controlmaster` extension (pulled from `pi-extensions`
|
||||
`main` via `PI_EXTENSIONS_REF`) now (a) resolves the remote pwd with a direct
|
||||
connection (`-o ControlPath=none -o ControlMaster=no`), and (b) tests whether
|
||||
the system `ControlPath` dir is actually writable — falling back to its own
|
||||
`/tmp` master (whose command-line `-o ControlPath` overrides the user's path)
|
||||
when it is not. OS-agnostic and independent of whether the user uses
|
||||
ControlMaster, so the majority of configs (no ControlMaster at all) are
|
||||
unaffected.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`setup-lan-access.sh` now renders the writable SSH sidecar
|
||||
(`~/.ssh-local/config`) on every host OS, not just VM-backed ones.**
|
||||
Previously the whole script no-oped on native Linux, so a Linux host that
|
||||
also bind-mounts `~/.ssh` read-only got no `ControlPath` redirect. The
|
||||
`ControlPath` redirect + `Include ~/.ssh/config` (and `dssh`/`dscp` usability)
|
||||
now work on Linux too; only the host-jump block (`Host host mac`), its key
|
||||
generation, and the authorize hints remain gated on VM-backed detection
|
||||
(`DEVBOX_LAN_ACCESS=auto`) or `=jump`.
|
||||
|
||||
### Bumped: pi 0.79.6 → 0.79.7
|
||||
|
||||
Notable upstream changes (from [pi releases](https://github.com/earendil-works/pi/releases/tag/v0.79.7)):
|
||||
|
||||
- **Automatic theme mode** — `/settings` can choose separate light and dark
|
||||
themes and follow terminal color-scheme changes (`/` is now reserved in
|
||||
theme names for this).
|
||||
- **Self-only `pi update` by default** — bare `pi update` updates pi only;
|
||||
`pi update --all` updates pi and packages together.
|
||||
- **Extension API helpers** — `CONFIG_DIR_NAME` exported so extensions resolve
|
||||
project config paths without hardcoding `.pi`; edit-diff helpers
|
||||
(`generateDiffString`, `generateUnifiedPatch`, `EditDiffResult`) exported.
|
||||
- **Warp inline images** via Kitty graphics capability detection.
|
||||
- Fixes: RPC unknown-command errors now include the request id (clients no
|
||||
longer hang); `/model` autocomplete matches provider/model regardless of
|
||||
token order; tree navigator horizontally pans deep entries.
|
||||
|
||||
## v1.1.4 — 2026-06-17
|
||||
|
||||
Patch release: config and shell-quality fixes on a preserved volume. No pi
|
||||
version bump (still `0.79.6`, latest). The `pi-toolkit` ref is auto-resolved
|
||||
to `main` HEAD at build, so the AGENTS.md change below lands automatically.
|
||||
|
||||
### Added
|
||||
|
||||
- **Global `AGENTS.md` auto-loads the pi-extensions skill.** `pi-toolkit` now
|
||||
ships `pi-global-AGENTS.md` and symlinks it to `~/.pi/agent/AGENTS.md` (pi's
|
||||
global-instructions file, loaded at every start). It directs the agent to
|
||||
read the `pi-extensions` skill at session start and carries a core
|
||||
fork/recall cheat-sheet, since on-demand skill description-matching was
|
||||
leaving `pi-fork` / `pi-observational-memory` under-utilised. **Heads-up:**
|
||||
on a preserved volume any pre-existing real `~/.pi/agent/AGENTS.md` is backed
|
||||
up to `*.bak.<timestamp>` and replaced by the symlink (same behavior as
|
||||
`keybindings.json`).
|
||||
- **`settings.json` merge-on-recreate.** The bootstrap only ever copied the
|
||||
template when `settings.json` was *absent*, so a file on a preserved volume
|
||||
never picked up config added in a later image (e.g. the
|
||||
`observational-memory` / `pi-fork` blocks, a newly-enabled model). The
|
||||
entrypoint now deep-merges the template into an existing `settings.json` on
|
||||
start with `jq -s '.[0] * .[1]'` (template first, live second): the user's
|
||||
values always win and only *missing* keys are filled in. Arrays are treated
|
||||
as leaves (a model the user removed is not re-added); the file is only
|
||||
rewritten when the merge changes something, the original is backed up first,
|
||||
and invalid JSON on either side is skipped rather than clobbered. Opt out
|
||||
with `PI_SETTINGS_MERGE=0`.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **bash history loss in nested / tmux shells.** The `DEVBOX_HIST_SET` guard
|
||||
that installs the per-prompt `history -a` flush was `export`ed, so it leaked
|
||||
into child processes. Any nested shell — crucially each tmux pane, which
|
||||
inherits the tmux server's env — saw the guard already set and skipped
|
||||
installing `history -a`, persisting history only on a clean exit. Abrupt
|
||||
termination (`docker stop`, `tmux kill-server`, SIGKILL) then silently lost
|
||||
that shell's in-memory history. The guard is now shell-local (no `export`),
|
||||
so every new interactive shell re-installs its own flush. `zoxide` was less
|
||||
affected (its hook is unguarded and writes immediately). History and zoxide
|
||||
storage were never the issue — `~/.cache/bash` (`devbox-shell-history`) and
|
||||
`~/.local/share/zoxide` (`devbox-zoxide`) are persistent named volumes.
|
||||
**Note:** existing shells/panes keep the old behavior until restarted
|
||||
(`tmux kill-server` or open fresh shells).
|
||||
|
||||
### Maintainer
|
||||
|
||||
- `scripts/recreate-sanity-check.sh` gained assertions for the new wiring: the
|
||||
`~/.pi/agent/AGENTS.md` symlink, a nested login shell installing
|
||||
`history -a`, and `settings.json` carrying the `observational-memory` +
|
||||
`pi-fork` blocks after recreate.
|
||||
|
||||
---
|
||||
|
||||
## v1.1.3 — 2026-06-16
|
||||
|
||||
Patch release: pi `0.79.4` → `0.79.5` (auto-resolved at build).
|
||||
|
||||
### Bumped: pi 0.79.4 → 0.79.5
|
||||
|
||||
Notable upstream changes (from [pi releases](https://github.com/earendil-works/pi/releases/tag/v0.79.5)):
|
||||
|
||||
- **Provider-scoped API key environments** — `auth.json` API key entries can
|
||||
now include `env` overrides for provider-specific Cloudflare, Azure OpenAI,
|
||||
Google Vertex, Amazon Bedrock, cache retention, and proxy settings without
|
||||
changing the project shell.
|
||||
- **Global HTTP proxy setting** — configure `httpProxy` once in global settings
|
||||
to apply `HTTP_PROXY` / `HTTPS_PROXY` to Pi-managed HTTP clients.
|
||||
- **Vercel AI Gateway attribution** — requests now include Pi attribution
|
||||
headers by default.
|
||||
- **Fixes:** inherited OpenAI Responses streaming tolerates null message content
|
||||
before tool calls; DeepSeek V4 thinking no longer sends both `thinking` and
|
||||
`reasoning_effort`; device-code login no longer auto-opens the browser;
|
||||
various Google/Vertex Gemini model metadata corrections; session selector
|
||||
empty-state fix; Cursor Up history navigation fix.
|
||||
|
||||
---
|
||||
|
||||
## v1.1.2 — 2026-06-15
|
||||
|
||||
Patch release: pi `0.79.3` → `0.79.4` (auto-resolved at build), plus the
|
||||
build-plumbing fix, maintainer tooling, and docs accumulated since v1.1.1.
|
||||
|
||||
### Changed
|
||||
|
||||
- **`mempalace-toolkit` is now CI-resolved to a commit SHA**, closing a
|
||||
silent-staleness footgun. It is the only companion cloned in
|
||||
`Dockerfile.base` (all others are cloned in `Dockerfile.variant`), so it
|
||||
was never run through the `resolve-versions` → build-arg plumbing. Its
|
||||
ref stayed a literal `main`, and because the base only rebuilds when the
|
||||
hash of `Dockerfile.base + rootfs/* + entrypoints` changes, a
|
||||
toolkit-only fix would *not* land in the image unless `Dockerfile.base`
|
||||
itself happened to change (as it did, incidentally, in v1.1.1).
|
||||
|
||||
Now `resolve-versions` resolves `mempalace-toolkit` `main` HEAD to a SHA
|
||||
(new `mempalace_toolkit_ref` output), `base-decide` folds that SHA into
|
||||
the base-tag hash (so a moved toolkit forces a base rebuild), and
|
||||
`build-base` passes it as `--build-arg MEMPALACE_TOOLKIT_REF`. The base
|
||||
clone switched from `git clone --branch` to a SHA-capable
|
||||
`git fetch <ref> + checkout FETCH_HEAD` (the `--branch <40-char-SHA>`
|
||||
footgun previously fixed in `Dockerfile.variant`, run 374).
|
||||
|
||||
Note: `base-decide` now depends on `resolve-versions`, so the base tag
|
||||
reflects a live gitea API lookup. On an API blip it falls back to `main`
|
||||
— which hashes differently than a SHA and triggers one *extra* rebuild,
|
||||
never a *missed* one (fail-toward-rebuild).
|
||||
|
||||
### Added (maintainer tooling, no image change)
|
||||
|
||||
- **`scripts/recreate-sanity-check.sh`** — runtime post-recreate sanity
|
||||
check; the runtime peer of `smoke-test.sh`. Where `smoke-test.sh` runs at
|
||||
build time with `--entrypoint=""` (and so can never see persisted volumes
|
||||
or the entrypoint's runtime deploy), this verifies what is actually live
|
||||
in the container *after* `docker compose up -d --force-recreate`:
|
||||
persisted named volumes survived, the pi runtime wiring is intact
|
||||
(keybindings symlink, ≥4 extensions, `mempalace.ts` bridge, `settings.json`,
|
||||
and pi-fork / pi-observational-memory / pi-studio registrations),
|
||||
`/tmp/sshcm` is mode 700, shell defaults re-seeded, and `/opt` toolkits
|
||||
intact. Variant (studio/plain) auto-detected via `/opt/pi-studio`. Since
|
||||
pi is built from `latest` (no concrete Dockerfile pin), the version check
|
||||
asserts only when `--expected-version` is passed, else WARNs. Not baked
|
||||
into the image — repo/maintainer tooling, same category as
|
||||
`smoke-test.sh`. A short-name wrapper (`pi-devbox-sanity`) lives in
|
||||
`cli_utils/bin`, kept separate from opencode-devbox's `devbox-sanity` so
|
||||
hosts with only one devbox checked out stay self-contained.
|
||||
|
||||
### Docs (no image change)
|
||||
|
||||
- Correct the MemPalace `diary_write` anyOf workaround watch-target in
|
||||
`Dockerfile.base`: upstream PR #1735 was **closed unmerged** (2026-06-11),
|
||||
so the old “remove once #1735 ships” TODO pointed at a dead PR. Issue #1728
|
||||
is still open; PR #1717 is the current live candidate; mempalace PyPI latest
|
||||
is still 3.4.0 (== our pin), so the workaround stays. Removal trigger is now
|
||||
a PyPI release > 3.4.0 that actually strips the root-level anyOf.
|
||||
|
||||
- Document the post-recreate sanity check: AGENTS.md release-day checklist
|
||||
(step 3) now runs `scripts/recreate-sanity-check.sh` inside the recreated
|
||||
container, and README gains a "Post-recreate sanity check" subsection
|
||||
alongside the build-time smoke-test note.
|
||||
|
||||
---
|
||||
|
||||
## v1.1.1 — 2026-06-13
|
||||
|
||||
Patch release: pi `0.79.1` → `0.79.3` (auto-resolved at build) plus the
|
||||
mempalace-mcp hang fix below.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`mempalace-mcp` no longer hangs the pi TUI uninterruptibly.** When
|
||||
the palace is bind-mounted from the macOS host (OrbStack virtiofs) and
|
||||
the container opened a large `chroma.sqlite3` for the first time, a
|
||||
cold storage open / HNSW load could stall the server before it emitted
|
||||
its JSON-RPC response. The awaiting promise then hung forever and the
|
||||
TUI froze — ESC cancels the LLM stream, not a pending MCP tool call, so
|
||||
there was no way out short of `docker exec <container> pkill -9 -f
|
||||
mempalace-mcp` and restarting pi.
|
||||
|
||||
The fix lives in the `mempalace.ts` pi extension shipped by
|
||||
**mempalace-toolkit** (cloned into the base at build time via
|
||||
`MEMPALACE_TOOLKIT_REF`, default `main`): the JSON-RPC client now arms
|
||||
a **per-request** timeout. On expiry it rejects the request *and* kills
|
||||
the stalled child (SIGTERM→SIGKILL), so pi surfaces an error instead of
|
||||
hanging; the bridge then marks itself unavailable so subsequent calls
|
||||
fail fast (restart pi to retry). This is deliberately per-REQUEST, not
|
||||
a process-lifetime `timeout 60 mempalace-mcp` wrapper — the long-lived
|
||||
server is only killed when a request genuinely stalls.
|
||||
|
||||
Tunables (env): `MEMPALACE_MCP_TIMEOUT_MS` (tool-call timeout, default
|
||||
`60000`), `MEMPALACE_MCP_INIT_TIMEOUT_MS` (initialize/tools-list
|
||||
handshake, default `120000`); set either to `0` to disable. Requires a
|
||||
base rebuild to pull the updated extension. The earlier plan of a
|
||||
standalone Python stdio-watchdog shim was dropped: the extension
|
||||
already owns request/response correlation, so a separate
|
||||
framing-reparsing shim is unnecessary.
|
||||
|
||||
Still open (out of scope here): sharing one palace across harnesses
|
||||
ideally wants a single host-side `mempalace-mcp` daemon multiplexing
|
||||
stdio over a UNIX socket, so all clients share one writer on native
|
||||
APFS rather than each cold-opening over virtiofs.
|
||||
`mempalace-mcp` that applies a per-request timeout and kills the child
|
||||
on stall, **without** killing the long-lived server itself (a naive
|
||||
`timeout 60 mempalace-mcp` wrapper is wrong — it kills the server
|
||||
mid-session). Sharing the palace across harnesses (native pi, container
|
||||
pi, opencode) remains the goal — isolated palaces defeat the point.
|
||||
Longer term: run a single mempalace-mcp daemon on the host and
|
||||
multiplex stdio over a UNIX socket so all clients share one writer on
|
||||
native APFS.
|
||||
|
||||
### Added
|
||||
|
||||
- **`dot-watch` helper** (`/usr/local/bin/dot-watch`) — auto-rerenders a
|
||||
Graphviz `.dot` file to PNG on every save via mtime polling (no
|
||||
`inotify` dependency). pi-studio renders Mermaid natively but has no
|
||||
DOT renderer; since its markdown preview displays local PNG/JPG/GIF/WEBP
|
||||
images, this closes the loop for Graphviz: edit `.dot` → `dot-watch`
|
||||
regenerates `<name>.png` → Studio *refresh-from-disk* shows the update.
|
||||
`graphviz` was already in the base image, so no new package. Baked into
|
||||
`Dockerfile.base` following the `studio-expose` pattern; documented in
|
||||
the README Studio section.
|
||||
|
||||
## v1.1.0 — 2026-06-10
|
||||
|
||||
### Added — `:latest-studio` variant
|
||||
|
||||
+1
-1
@@ -97,7 +97,7 @@ The entrypoint deploys/registers all of these on first container start. Re-runni
|
||||
|
||||
### SSH and networking
|
||||
|
||||
- OpenSSH client with **ControlMaster auto** preconfigured on a writable socket path (`/tmp/sshcm/`). Mitigates ssh banner-exchange failures behind CGNAT-restricted residential ISPs (~4-flow caps).
|
||||
- OpenSSH client with **ControlMaster auto** preconfigured on a writable socket path (`/tmp/sshcm/`). Mitigates ssh banner-exchange failures behind CGNAT-restricted residential ISPs (~4-flow caps). A read-only `~/.ssh` carrying a per-host `ControlPath` (common CGNAT configs) is handled too — redirected to a writable socket dir for both `pi --ssh` and `dssh`/`dscp`.
|
||||
- A **LAN-access helper** that auto-configures ssh jump-via-host on VM-backed hosts (OrbStack / Docker Desktop on macOS) so the container can reach the host's directly-attached LAN peers (`dssh <peer>` alias; `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER`).
|
||||
|
||||
## Versioning
|
||||
|
||||
+55
-7
@@ -48,6 +48,8 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
# preview/export pipelines and broadly useful for any
|
||||
# agent-driven document workflow. ~200 MB.
|
||||
# graphviz — `dot` rendering for many diagram tools. ~10 MB.
|
||||
# See the bundled `dot-watch` helper for live .dot -> PNG
|
||||
# re-render (handy with pi-studio's image preview).
|
||||
# imagemagick — image conversion / resizing for thumbnails, etc. ~50 MB.
|
||||
# yq — YAML-aware companion to jq.
|
||||
# socat — TCP relay. Powers `studio-expose`, which bridges
|
||||
@@ -128,6 +130,15 @@ RUN printf '%s\n' \
|
||||
# `Include /etc/ssh/ssh_config.d/*.conf` *before* the `Host *` block,
|
||||
# so user config can override these defaults if desired.
|
||||
#
|
||||
# CAVEAT (and why it is handled elsewhere): a user per-host override that
|
||||
# points ControlPath BACK under the read-only ~/.ssh (e.g. the common CGNAT
|
||||
# idiom `ControlPath ~/.ssh/cm/%r@%h:%p`) re-introduces the unwritable-socket
|
||||
# failure — a system drop-in here can never override a user's per-host value.
|
||||
# For `pi --ssh`, the ssh-controlmaster extension handles this by detecting an
|
||||
# unwritable system ControlPath and falling back to its own /tmp master; for
|
||||
# `ssh -F ~/.ssh-local/config` (dssh/dscp), setup-lan-access.sh redirects
|
||||
# ControlPath into the writable ~/.ssh-local. See CHANGELOG "Unreleased".
|
||||
#
|
||||
# ControlPersist=10m means the master socket sticks around 10 min after
|
||||
# the last session closes, so consecutive ssh calls in a workflow reuse
|
||||
# the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm
|
||||
@@ -277,6 +288,16 @@ RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64"
|
||||
# Provides semantic search over conversation history via 29 MCP tools.
|
||||
# Always installed in the base. Set INSTALL_MEMPALACE=false at base-build
|
||||
# time to shave ~300 MB.
|
||||
#
|
||||
# Stall protection (fixed 2026-06-13): mempalace-mcp is launched by the
|
||||
# `mempalace.ts` pi extension from mempalace-toolkit (cloned below). That
|
||||
# extension now applies a per-REQUEST timeout in its JSON-RPC client and
|
||||
# kills the child on stall, so a virtiofs cold-open of chroma.sqlite3 /
|
||||
# HNSW load can no longer hang the pi TUI uninterruptibly. Tunables:
|
||||
# MEMPALACE_MCP_TIMEOUT_MS (default 60000), MEMPALACE_MCP_INIT_TIMEOUT_MS
|
||||
# (default 120000); 0 disables. A standalone stdio-watchdog shim is NOT
|
||||
# needed — the extension already owns request/response correlation. See
|
||||
# CHANGELOG.md "Unreleased > Fixed".
|
||||
ARG INSTALL_MEMPALACE=true
|
||||
# Pin to a known-good version. Bump deliberately, not implicitly: an
|
||||
# unpinned install silently swept in mempalace 3.3.x/3.4.0 with a broken
|
||||
@@ -304,12 +325,18 @@ RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
# kwarg alias so existing callers still work.
|
||||
#
|
||||
# Idempotent and self-deactivating: once upstream releases the fix the
|
||||
# regex no longer matches and this RUN is a silent no-op.
|
||||
# Upstream tracking:
|
||||
# regex no longer matches (and the WARN below fires) — that's the signal
|
||||
# to delete this RUN.
|
||||
# Upstream status (last checked 2026-06-14):
|
||||
# issue #1728 — STILL OPEN (root-level anyOf rejected by Anthropic/Codex)
|
||||
# PR #1735 — CLOSED UNMERGED 2026-06-11; do NOT watch it (dead)
|
||||
# PR #1717 — open; the current live fix candidate to watch
|
||||
# mempalace PyPI latest = 3.4.0 (== our pin) → no release contains the fix yet
|
||||
# https://github.com/MemPalace/mempalace/issues/1728
|
||||
# https://github.com/MemPalace/mempalace/pull/1735
|
||||
# TODO: remove this RUN once a mempalace release containing PR #1735 is on
|
||||
# PyPI and installed by the line above.
|
||||
# https://github.com/MemPalace/mempalace/pull/1717
|
||||
# TODO: remove this RUN once a mempalace release > 3.4.0 that actually strips
|
||||
# the root-level anyOf ships on PyPI and is installed by the line above.
|
||||
# Keep MEMPALACE_VERSION in lockstep with opencode-devbox when bumping.
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
MP_FILE="$(find /opt/uv-tools/mempalace -path '*/mempalace/mcp_server.py' | head -n1)" && \
|
||||
if [ -z "$MP_FILE" ]; then echo "mempalace mcp_server.py not found" >&2; exit 1; fi && \
|
||||
@@ -324,9 +351,28 @@ RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
# ── mempalace-toolkit — bash wrappers for session/docs mining ────────
|
||||
ARG INSTALL_MEMPALACE_TOOLKIT=true
|
||||
ARG MEMPALACE_TOOLKIT_REF=main
|
||||
# MEMPALACE_TOOLKIT_REPO defaults to the canonical gitea origin but is
|
||||
# overridable so a relocated/forked build can clone from a mirror or a
|
||||
# different host without editing this Dockerfile (mirrors the
|
||||
# PI_FORK_REPO / PI_OBSMEM_REPO / PI_STUDIO_REPO pattern in the variant).
|
||||
ARG MEMPALACE_TOOLKIT_REPO=https://gitea.jordbo.se/joakimp/mempalace-toolkit.git
|
||||
# MEMPALACE_TOOLKIT_REF accepts EITHER a branch name OR a commit SHA. CI
|
||||
# resolves it to a SHA (resolve-versions job) and folds that SHA into the
|
||||
# base-decide hash so the base rebuilds when the toolkit moves. `git clone
|
||||
# --branch <40-char-SHA>` fails ("Remote branch not found") — the same
|
||||
# footgun fixed in Dockerfile.variant (v1.0.0-rerun, run 374) — so use
|
||||
# `git fetch <ref> + checkout FETCH_HEAD`, which works for name and SHA.
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \
|
||||
git clone --depth 1 --branch "${MEMPALACE_TOOLKIT_REF}" \
|
||||
https://gitea.jordbo.se/joakimp/mempalace-toolkit.git /opt/mempalace-toolkit && \
|
||||
rm -rf /opt/mempalace-toolkit && mkdir -p /opt/mempalace-toolkit && \
|
||||
git -C /opt/mempalace-toolkit init -q && \
|
||||
git -C /opt/mempalace-toolkit remote add origin "${MEMPALACE_TOOLKIT_REPO}" && \
|
||||
ok=0; for i in 1 2 3 4 5; do \
|
||||
if git -C /opt/mempalace-toolkit fetch --depth 1 origin "${MEMPALACE_TOOLKIT_REF}" && \
|
||||
git -C /opt/mempalace-toolkit checkout -q FETCH_HEAD; then ok=1; break; fi; \
|
||||
echo "git fetch mempalace-toolkit@${MEMPALACE_TOOLKIT_REF} failed (attempt $i/5), retrying in $((i*5))s..."; \
|
||||
sleep $((i*5)); \
|
||||
done; \
|
||||
[ "$ok" = "1" ] && \
|
||||
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 && \
|
||||
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs && \
|
||||
@@ -436,10 +482,12 @@ COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
|
||||
# ── Entrypoint ────────────────────────────────────────────────────────
|
||||
COPY rootfs/usr/local/lib/pi-devbox/ /usr/local/lib/pi-devbox/
|
||||
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 entrypoint.sh /usr/local/bin/entrypoint.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 \
|
||||
/usr/local/bin/studio-expose \
|
||||
/usr/local/bin/dot-watch \
|
||||
/usr/local/lib/pi-devbox/*.sh 2>/dev/null || true
|
||||
|
||||
# Start as root — entrypoint adjusts UID/GID then drops to developer
|
||||
|
||||
+64
-2
@@ -41,6 +41,12 @@ ARG USER_NAME=developer
|
||||
ARG PI_VERSION=latest
|
||||
ARG PI_TOOLKIT_REF=main
|
||||
ARG PI_EXTENSIONS_REF=main
|
||||
# Repo URLs default to the canonical gitea origin but are overridable so a
|
||||
# relocated/forked build can clone from a mirror or a different host
|
||||
# without editing this Dockerfile — same pattern as PI_FORK_REPO /
|
||||
# PI_OBSMEM_REPO / PI_STUDIO_REPO below.
|
||||
ARG PI_TOOLKIT_REPO=https://gitea.jordbo.se/joakimp/pi-toolkit.git
|
||||
ARG PI_EXTENSIONS_REPO=https://gitea.jordbo.se/joakimp/pi-extensions.git
|
||||
# pi-fork (fork tool) + pi-observational-memory (recall tool) live on GitHub
|
||||
# under elpapi42. CI resolves these to commit SHAs to defeat the same
|
||||
# cache-hit footgun that affects PI_VERSION.
|
||||
@@ -77,8 +83,8 @@ RUN set -e && \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
|
||||
fi && \
|
||||
pi --version && \
|
||||
git_fetch_ref "https://gitea.jordbo.se/joakimp/pi-toolkit.git" "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \
|
||||
git_fetch_ref "https://gitea.jordbo.se/joakimp/pi-extensions.git" "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
|
||||
git_fetch_ref "${PI_TOOLKIT_REPO}" "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \
|
||||
git_fetch_ref "${PI_EXTENSIONS_REPO}" "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
|
||||
git_fetch_ref "${PI_FORK_REPO}" "${PI_FORK_REF}" /opt/pi-fork && \
|
||||
git_fetch_ref "${PI_OBSMEM_REPO}" "${PI_OBSMEM_REF}" /opt/pi-observational-memory && \
|
||||
(cd /opt/pi-fork && npm install --omit=dev --no-audit --no-fund) && \
|
||||
@@ -154,4 +160,60 @@ RUN if [ "${INSTALL_GO}" = "true" ]; then \
|
||||
ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt; \
|
||||
fi
|
||||
|
||||
# ── Build provenance: OCI labels + on-disk build manifest ────────────
|
||||
# Records exactly which pi version and companion-repo commits were baked
|
||||
# into THIS image, so a published tag is self-describing and reproducible
|
||||
# after the fact (CI logs rotate; a released image must not depend on
|
||||
# them). Previously the resolved SHAs only ever reached the CI build log.
|
||||
#
|
||||
# These ARGs are declared LAST, immediately before the layer that uses
|
||||
# them, so a changing BUILD_DATE / RELEASE_TAG / SOURCE_REVISION never
|
||||
# invalidates the expensive pi-install / clone layers above.
|
||||
ARG RELEASE_TAG=dev
|
||||
ARG BUILD_DATE=
|
||||
ARG SOURCE_REVISION=
|
||||
# MEMPALACE_TOOLKIT_REF is consumed in Dockerfile.base; re-declared here
|
||||
# only so its intended ref lands in the label set alongside the others.
|
||||
ARG MEMPALACE_TOOLKIT_REF=main
|
||||
|
||||
LABEL org.opencontainers.image.version="${RELEASE_TAG}" \
|
||||
org.opencontainers.image.revision="${SOURCE_REVISION}" \
|
||||
org.opencontainers.image.created="${BUILD_DATE}" \
|
||||
se.jordbo.pi-devbox.pi-version="${PI_VERSION}" \
|
||||
se.jordbo.pi-devbox.pi-toolkit-ref="${PI_TOOLKIT_REF}" \
|
||||
se.jordbo.pi-devbox.pi-extensions-ref="${PI_EXTENSIONS_REF}" \
|
||||
se.jordbo.pi-devbox.pi-fork-ref="${PI_FORK_REF}" \
|
||||
se.jordbo.pi-devbox.pi-obsmem-ref="${PI_OBSMEM_REF}" \
|
||||
se.jordbo.pi-devbox.mempalace-toolkit-ref="${MEMPALACE_TOOLKIT_REF}" \
|
||||
se.jordbo.pi-devbox.pi-studio-ref="${PI_STUDIO_REF}"
|
||||
|
||||
# The manifest is written from GROUND TRUTH — the actual checked-out HEAD
|
||||
# of each /opt clone and the live `pi --version` — not merely the intended
|
||||
# build-args. That way it also exposes a clone that silently resolved to
|
||||
# something other than the requested ref. pi-studio is present only in the
|
||||
# studio variant (JSON null otherwise).
|
||||
RUN set -e; \
|
||||
mkdir -p /etc/pi-devbox; \
|
||||
rev() { git -C "$1" rev-parse HEAD 2>/dev/null || echo "unknown"; }; \
|
||||
PI_V="$(pi --version 2>/dev/null | head -n1 | tr -d '\r\n')"; \
|
||||
STUDIO_REV='null'; \
|
||||
if [ -d /opt/pi-studio/.git ]; then STUDIO_REV="\"$(rev /opt/pi-studio)\""; fi; \
|
||||
{ \
|
||||
echo '{'; \
|
||||
echo " \"release_tag\": \"${RELEASE_TAG}\","; \
|
||||
echo " \"build_date\": \"${BUILD_DATE}\","; \
|
||||
echo " \"source_revision\": \"${SOURCE_REVISION}\","; \
|
||||
echo " \"pi_version\": \"${PI_V}\","; \
|
||||
echo " \"components\": {"; \
|
||||
echo " \"pi-toolkit\": \"$(rev /opt/pi-toolkit)\","; \
|
||||
echo " \"pi-extensions\": \"$(rev /opt/pi-extensions)\","; \
|
||||
echo " \"pi-fork\": \"$(rev /opt/pi-fork)\","; \
|
||||
echo " \"pi-observational-memory\": \"$(rev /opt/pi-observational-memory)\","; \
|
||||
echo " \"mempalace-toolkit\": \"$(rev /opt/mempalace-toolkit)\","; \
|
||||
echo " \"pi-studio\": ${STUDIO_REV}"; \
|
||||
echo " }"; \
|
||||
echo '}'; \
|
||||
} > /etc/pi-devbox/build-manifest.json; \
|
||||
echo "── build manifest ──"; cat /etc/pi-devbox/build-manifest.json
|
||||
|
||||
# WORKDIR / ENTRYPOINT / CMD inherited from base.
|
||||
|
||||
@@ -82,6 +82,9 @@ For Python REPLs and notebooks beyond the system interpreter, see the
|
||||
- A LAN-access helper that auto-configures ssh jump-via-host on
|
||||
VM-backed hosts (OrbStack / Docker Desktop on macOS) so the container
|
||||
can reach the host's directly-attached LAN peers.
|
||||
- Read-only `~/.ssh` is handled transparently: a per-host `ControlPath`
|
||||
under it (common CGNAT configs like `~/.ssh/cm/...`) is redirected to a
|
||||
writable socket dir for both `pi --ssh` and `dssh`/`dscp`.
|
||||
|
||||
## Quickstart
|
||||
|
||||
@@ -199,9 +202,15 @@ With `STUDIO_EXPOSE=1`, the entrypoint starts the bridge for you; just run
|
||||
(leave `STUDIO_EXPOSE` unset), run `studio-expose` in a container shell:
|
||||
|
||||
```bash
|
||||
studio-expose # bridges $STUDIO_PORT (default 8765); --help for details
|
||||
studio-expose & # bridges $STUDIO_PORT (default 8765); --help for details
|
||||
```
|
||||
|
||||
> **`studio-expose` runs in the foreground** (it's a `socat` relay) — it
|
||||
> blocks the shell until Ctrl-C. Background it with `&` or run it in its
|
||||
> own tmux pane. It only relays traffic; it does **not** print a token.
|
||||
> The lines it prints ending in `...token=...` are literal help text, not
|
||||
> a truncated URL — the real token comes from `/studio` (see below).
|
||||
|
||||
> **Security:** the bridge intentionally exposes Studio beyond loopback;
|
||||
> its tokenized URL is the only auth. Keep the host-side publish on
|
||||
> `127.0.0.1:` and use `ssh -L` for remote access. Default is **off**.
|
||||
@@ -221,10 +230,75 @@ tunnel alongside mosh (mosh for the shell, ssh for the port), or reach the
|
||||
host's published port directly over a trusted network (LAN / Tailscale /
|
||||
WireGuard).
|
||||
|
||||
#### End-to-end recipe: remote host, mosh shell, `studio-expose` bridge
|
||||
|
||||
The full path has four network hops, each added by one step:
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
browser["laptop browser"]
|
||||
host["host :8765"]
|
||||
eth0["container eth0 :8765"]
|
||||
loop["container 127.0.0.1 :8765"]
|
||||
studio["pi-studio"]
|
||||
|
||||
browser -->|"ssh -L"| host
|
||||
host -->|"docker -p"| eth0
|
||||
eth0 -->|"studio-expose (socat)"| loop
|
||||
studio -->|"binds"| loop
|
||||
```
|
||||
|
||||
Assuming the compose file publishes `127.0.0.1:8765:8765` (see method B):
|
||||
|
||||
1. **In a container shell** — start the bridge (skip if `STUDIO_EXPOSE=1`
|
||||
is set in compose, which auto-starts it):
|
||||
```bash
|
||||
studio-expose &
|
||||
```
|
||||
2. **In your pi session** (the pi TUI in the container) — start Studio and
|
||||
print the tokenized URL. `/studio` is a slash command you type in the
|
||||
TUI, not a shell command:
|
||||
```
|
||||
/studio --no-browser --port 8765
|
||||
/studio --status # reprint the URL anytime
|
||||
```
|
||||
Copy the `http://…:8765/?token=<token>` it prints. **This** is where
|
||||
the real token comes from — not `studio-expose`.
|
||||
3. **On your laptop** — open the ssh port-forward alongside mosh:
|
||||
```bash
|
||||
ssh -L 8765:127.0.0.1:8765 user@docker-host
|
||||
```
|
||||
4. **In your laptop browser** — open `http://127.0.0.1:8765/?token=<token>`
|
||||
(keep the port and token verbatim; only the host part is `127.0.0.1`).
|
||||
|
||||
> **Order check:** nothing listens on the container's `127.0.0.1:8765`
|
||||
> until step 2 runs. If the browser can't connect, verify Studio is up
|
||||
> (`/studio --status`) and the bridge is running (`ps aux | grep socat`).
|
||||
|
||||
> PDF export (`/studio-pdf`, `studio_export_pdf`) needs a LaTeX engine,
|
||||
> which is **not** in `-studio` (only the planned `-studio-tex`). HTML
|
||||
> export, KaTeX, Mermaid, and all REPL features work without it.
|
||||
|
||||
### Graphviz diagrams in Studio: `dot-watch`
|
||||
|
||||
pi-studio renders **Mermaid** natively but has **no Graphviz/DOT renderer**.
|
||||
Its markdown preview *does* render local image links (`.png`/`.jpg`/`.gif`/
|
||||
`.webp`), so the workflow for Graphviz is: write a `.dot` file, render it to
|
||||
PNG with `dot`, and preview the PNG (directly, or embedded in a markdown
|
||||
file). The bundled **`dot-watch`** helper automates the re-render so edits
|
||||
show up on Studio's *refresh-from-disk*:
|
||||
|
||||
```bash
|
||||
dot-watch graph.dot # dot engine, 150 dpi -> graph.png
|
||||
dot-watch graph.dot neato 200 # pick layout engine + dpi
|
||||
```
|
||||
|
||||
It polls the file's mtime (no `inotify` dependency) and regenerates
|
||||
`<name>.png` on every save, printing timestamped status and indenting any
|
||||
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.
|
||||
Note: SVG is **not** in Studio's local-image-link allowlist — use PNG.
|
||||
|
||||
## docker-compose.yml — basic shape
|
||||
|
||||
```yaml
|
||||
@@ -236,10 +310,15 @@ services:
|
||||
container_name: pi-devbox
|
||||
stdin_open: true
|
||||
tty: true
|
||||
# pi-studio (only on `-studio` images): publish loopback + enable the
|
||||
# socat bridge so the browser UI is reachable. See "Using pi-studio".
|
||||
# ports:
|
||||
# - "127.0.0.1:8765:8765" # host-localhost only; use ssh -L for remote
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TERM=xterm-256color
|
||||
# - STUDIO_EXPOSE=1 # -studio only: auto-start the socat bridge on boot
|
||||
- GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
|
||||
- GITEA_HOST=${GITEA_HOST:-}
|
||||
- GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_PERSONAL_ACCESS_TOKEN:-}
|
||||
@@ -385,6 +464,23 @@ User-level overrides in `~/.ssh/config` win because Debian's
|
||||
`/etc/ssh/ssh_config` includes `/etc/ssh/ssh_config.d/*.conf` before
|
||||
the `Host *` block.
|
||||
|
||||
### Per-host `ControlPath` on a read-only `~/.ssh`
|
||||
|
||||
`~/.ssh` is usually bind-mounted read-only, so a user `~/.ssh/config` that
|
||||
points `ControlPath` back under it (e.g. the CGNAT idiom
|
||||
`ControlPath ~/.ssh/cm/%r@%h:%p`) can't bind its master socket here — and a
|
||||
system default can never override a user's per-host value. Two layers handle
|
||||
this without editing the read-only config:
|
||||
|
||||
- **`pi --ssh <host>`** — the `ssh-controlmaster` extension detects an
|
||||
unwritable system `ControlPath` and falls back to its own writable
|
||||
`/tmp/pi-cm-<pid>.sock` master (its command-line `-o ControlPath` overrides
|
||||
the user's path); the remote-`pwd` probe uses `-o ControlPath=none` so it
|
||||
cannot fail on the read-only socket dir.
|
||||
- **`ssh -F ~/.ssh-local/config` / `dssh` / `dscp`** — `setup-lan-access.sh`
|
||||
redirects `ControlPath` into the writable `~/.ssh-local/cm` for every host
|
||||
(the sidecar is rendered on all host OSes).
|
||||
|
||||
## tmux and 0-indexed sessions
|
||||
|
||||
The image installs `/etc/tmux.conf` with:
|
||||
@@ -441,6 +537,68 @@ pi-coding-agent@latest` (the build-arg string would otherwise be
|
||||
byte-identical across releases and the layer would silently reuse the
|
||||
previous version's bytes).
|
||||
|
||||
### Building a fork / relocated build
|
||||
|
||||
The canonical build clones its companions from `gitea.jordbo.se`. Every
|
||||
companion repo URL is an overridable build-arg (defaulting to the canonical
|
||||
origin), so a fork or a build on a host that can't reach that gitea can
|
||||
repoint each one at a mirror, another host, or a local `file://` path
|
||||
**without editing the Dockerfiles**:
|
||||
|
||||
| Build-arg | Default | Dockerfile |
|
||||
|---|---|---|
|
||||
| `PI_TOOLKIT_REPO` | `https://gitea.jordbo.se/joakimp/pi-toolkit.git` | variant |
|
||||
| `PI_EXTENSIONS_REPO` | `https://gitea.jordbo.se/joakimp/pi-extensions.git` | variant |
|
||||
| `MEMPALACE_TOOLKIT_REPO` | `https://gitea.jordbo.se/joakimp/mempalace-toolkit.git` | base |
|
||||
| `PI_FORK_REPO` | `https://github.com/elpapi42/pi-fork.git` | variant |
|
||||
| `PI_OBSMEM_REPO` | `https://github.com/elpapi42/pi-observational-memory.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
|
||||
the variant against forked toolkit/extensions and a pinned pi:
|
||||
|
||||
```bash
|
||||
# base first (mempalace-toolkit lives here)
|
||||
docker build -f Dockerfile.base -t myorg/pi-devbox:base-dev \
|
||||
--build-arg MEMPALACE_TOOLKIT_REPO=https://github.com/myorg/mempalace-toolkit.git .
|
||||
|
||||
# then the variant FROM that base
|
||||
docker build -f Dockerfile.variant -t myorg/pi-devbox:dev \
|
||||
--build-arg BASE_IMAGE=myorg/pi-devbox:base-dev \
|
||||
--build-arg PI_VERSION=0.79.7 \
|
||||
--build-arg PI_TOOLKIT_REPO=https://github.com/myorg/pi-toolkit.git \
|
||||
--build-arg PI_EXTENSIONS_REPO=https://github.com/myorg/pi-extensions.git .
|
||||
```
|
||||
|
||||
Note: the gitea companions clone anonymously (no token needed); only the
|
||||
`resolve-versions` CI job calls the gitea *API* (which needs a token even
|
||||
for public repos). A plain `docker build` like the above skips that job
|
||||
entirely, so no credentials are required for a local/forked build.
|
||||
|
||||
Provenance build-args (all optional; populate the OCI labels and
|
||||
`/etc/pi-devbox/build-manifest.json` — see below): `RELEASE_TAG`,
|
||||
`BUILD_DATE`, `SOURCE_REVISION`. CI sets these automatically; a manual build
|
||||
leaves them at harmless defaults.
|
||||
|
||||
### Build provenance (labels + manifest)
|
||||
|
||||
Every published image is self-describing. Inspect the OCI labels without
|
||||
pulling the filesystem:
|
||||
|
||||
```bash
|
||||
docker inspect --format '{{json .Config.Labels}}' joakimp/pi-devbox:latest | jq .
|
||||
```
|
||||
|
||||
`org.opencontainers.image.{version,revision,created}` plus
|
||||
`se.jordbo.pi-devbox.*-ref` record the intended pi version and companion
|
||||
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
|
||||
`pi --version` — so a tag is reconstructable after CI logs rotate:
|
||||
|
||||
```bash
|
||||
docker run --rm --entrypoint= joakimp/pi-devbox:latest cat /etc/pi-devbox/build-manifest.json
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Image grew unexpectedly
|
||||
@@ -463,6 +621,26 @@ ssh-jump-via-host configuration. Set `DEVBOX_LAN_ACCESS=jump` and
|
||||
./scripts/smoke-test.sh joakimp/pi-devbox:latest
|
||||
```
|
||||
|
||||
`smoke-test.sh` is a **build-time** check (runs with `--entrypoint=""`), so
|
||||
it validates image contents and a fresh entrypoint deploy — it never sees a
|
||||
recreated container's persisted volumes.
|
||||
|
||||
### Post-recreate sanity check
|
||||
|
||||
After `docker compose up -d --force-recreate`, run the **runtime** peer of
|
||||
`smoke-test.sh` from *inside* the container to confirm the new image is live,
|
||||
persisted volumes survived, and pi runtime wiring is intact:
|
||||
|
||||
```bash
|
||||
./scripts/recreate-sanity-check.sh # auto-detects variant
|
||||
./scripts/recreate-sanity-check.sh --expected-version 0.79.4 # assert pi version
|
||||
```
|
||||
|
||||
If `cli_utils` is on your PATH, the `pi-devbox-sanity` wrapper runs the same
|
||||
check by short name and locates the repo automatically (override with
|
||||
`PI_DEVBOX_REPO=/path/to/pi-devbox`). Like `smoke-test.sh`, this script is
|
||||
maintainer tooling and is **not** shipped in the published image.
|
||||
|
||||
## Versioning and release
|
||||
|
||||
pi-devbox follows semver-ish:
|
||||
|
||||
@@ -0,0 +1,302 @@
|
||||
# Design: single-writer MemPalace broker (cross-host serialization)
|
||||
|
||||
> **Status:** DRAFT / RFC — not yet implemented. Captures the design so it can be
|
||||
> picked up later. Authored 2026-06-14.
|
||||
> **Owner:** unassigned. **Tracking:** queue item #4 ("host-side mempalace-mcp
|
||||
> daemon over a UNIX/shared socket").
|
||||
|
||||
## Problem
|
||||
|
||||
The pi-devbox container's `~/.mempalace` (`/home/developer/.mempalace`) is a
|
||||
**virtiofs bind-mount of the host's `/Users/joakim/.mempalace`** (verified
|
||||
2026-06-14 via `/proc/mounts`: `mac /home/developer/.mempalace virtiofs rw`).
|
||||
Container pi and host-native pi therefore **read and write ONE shared palace** —
|
||||
full memory parity already exists; nothing needs to be built to *enable* sharing.
|
||||
|
||||
The actual hazard is the opposite of sharing: **concurrency**. Two pi processes
|
||||
(one native on the host, one in the container) can open the same
|
||||
`chroma.sqlite3` / `knowledge_graph.sqlite3` and write at the same time. The
|
||||
palace directory already shows the scars of this:
|
||||
|
||||
- `chroma.sqlite3.broken-20260505`
|
||||
- many `*.corrupt-20260528`
|
||||
- a long run of `*.drift-2026*`
|
||||
- `locks/` with `mine_palace_*.lock` files, including a **stale** one.
|
||||
|
||||
These are mempalace's defensive lock + auto-snapshot/repair machinery firing
|
||||
under concurrent access.
|
||||
|
||||
### Why a shared lock file is NOT sufficient
|
||||
|
||||
The container runs inside a Linux VM (OrbStack / Docker Desktop on macOS); the
|
||||
palace bytes live on the macOS host, surfaced into the VM via virtiofs.
|
||||
Consequences:
|
||||
|
||||
- A **UNIX-domain socket file** visible at `~/.mempalace/broker.sock` inside the
|
||||
container is a *host-kernel* object. The container's kernel can see the inode
|
||||
but **cannot connect to it** across the VM boundary.
|
||||
- **flock / advisory lockfiles are not coherent across the host↔VM boundary.**
|
||||
A lock taken on the host is not reliably seen in the container and vice-versa.
|
||||
(The stale `mine_palace_*.lock` is direct evidence the existing lock scheme is
|
||||
not bulletproof across this boundary.)
|
||||
|
||||
**Therefore the only trustworthy serialization is to route every write through a
|
||||
single process.** That single process is the broker. The design question is *not*
|
||||
"how do we lock" — it's "**where does the one writer live, and how does every pi
|
||||
(host or container) reach it across the VM boundary?**"
|
||||
|
||||
## Goals
|
||||
|
||||
1. Exactly one process opens the palace SQLite files at any time (single writer;
|
||||
concurrent reads are fine).
|
||||
2. Works in all three topologies on a given host:
|
||||
- native pi only,
|
||||
- native pi + container pi,
|
||||
- container pi only.
|
||||
3. pi configuration is **identical** in every topology (no per-environment MCP
|
||||
config divergence).
|
||||
4. No new corruption pathway introduced; degrade safely when the broker is
|
||||
genuinely unreachable and there are no peers.
|
||||
|
||||
### Non-goals (for this iteration)
|
||||
|
||||
- opencode / opencode-devbox co-existence (see "Co-existence with opencode"
|
||||
below — deferred until the pi case is solved).
|
||||
- Multi-host palace replication. This is about one host's local palace.
|
||||
- Changing mempalace's on-disk format or its public MCP tool surface.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
pi (host) ─stdio─► mp-shim ─┐
|
||||
├─► mempalace-broker ─► chroma.sqlite3
|
||||
pi (ctr) ─stdio─► mp-shim ─┘ (SINGLE owner; knowledge_graph.sqlite3
|
||||
serialized writer, + in-memory HNSW index
|
||||
concurrent readers)
|
||||
```
|
||||
|
||||
### `mempalace-broker`
|
||||
|
||||
A long-lived process that is the **only** opener of the palace SQLite files. It:
|
||||
|
||||
- runs the real mempalace engine,
|
||||
- holds the HNSW index in memory,
|
||||
- pushes all mutations through a single writer queue (reads may fan out),
|
||||
- exposes the mempalace MCP JSON-RPC surface over one or more transports,
|
||||
- is the canonical owner of palace state for the lifetime of the host session.
|
||||
|
||||
**Bonus:** a single always-resident owner also eliminates the stale-HNSW-index
|
||||
problem that `mempalace_reconnect` exists to work around — there is never an
|
||||
external writer to desync the in-memory index against.
|
||||
|
||||
### `mp-shim`
|
||||
|
||||
A tiny stdio↔transport adapter. pi's mempalace MCP config points at the shim
|
||||
**everywhere, unchanged**. pi still believes it is speaking stdio MCP to a local
|
||||
server; the shim forwards JSON-RPC to the broker over whichever transport is
|
||||
available, and handles all discovery / startup / election complexity. Keeping
|
||||
pi's config identical across topologies is a hard requirement (goal #3) and the
|
||||
shim is what makes it possible.
|
||||
|
||||
## Canonical owner = the host
|
||||
|
||||
The broker's home is **always the host**, because:
|
||||
|
||||
1. The palace bytes physically live there (`/Users/joakim/.mempalace`).
|
||||
2. The host outlives any container — ownership does not evaporate on
|
||||
`docker compose down`.
|
||||
3. Containers already have a route back to it (`host.docker.internal` and the
|
||||
verified dssh ControlMaster bridge).
|
||||
|
||||
The broker binds **two listeners feeding one queue**:
|
||||
|
||||
- **AF_UNIX** at `$MEMPALACE_PATH/broker.sock` — for host-native pi (fast,
|
||||
filesystem-perms-secured).
|
||||
- a **cross-boundary** transport for container clients (below).
|
||||
|
||||
## Transport matrix
|
||||
|
||||
| Topology | Broker runs on | Host pi reaches it via | Container pi reaches it via |
|
||||
|---|---|---|---|
|
||||
| native only | host | AF_UNIX socket | — |
|
||||
| native + container | host | AF_UNIX socket | SSH-forwarded socket (preferred) or TCP |
|
||||
| container only | host (started via bridge) | — | SSH-forwarded socket or TCP |
|
||||
|
||||
### Cross-boundary transport options
|
||||
|
||||
**(a) SSH-forwarded UNIX socket over the existing dssh ControlMaster — PREFERRED.**
|
||||
The container's `setup-lan-access.sh` already establishes a ControlMaster to the
|
||||
host with `ControlPersist 4h`. The container shim forwards the host broker socket
|
||||
over that master:
|
||||
|
||||
```
|
||||
ssh -F ~/.ssh-local/config \
|
||||
-L "$XDG_RUNTIME_DIR/mp.sock:$HOME/.mempalace/broker.sock" host
|
||||
```
|
||||
|
||||
then connects to the local forwarded socket. Auth = SSH key; nothing is
|
||||
LAN-exposed; no extra shared secret needed; rides the persistent master so setup
|
||||
cost is near-zero. Most portable across non-OrbStack hosts.
|
||||
|
||||
**(b) TCP on `host.docker.internal:PORT` — fallback.** Simpler, but the broker
|
||||
must bind a routable interface (not just `127.0.0.1`), which requires a
|
||||
**shared-secret token** to prevent other local/LAN processes from talking to it.
|
||||
The token is written to `broker.json` in the virtiofs-mounted palace dir
|
||||
(readable from both sides). More care required to get the bind + auth right.
|
||||
|
||||
## Discovery + on-demand start (the shim's algorithm)
|
||||
|
||||
Run by the shim on every pi session start, so it is correct regardless of who is
|
||||
already running:
|
||||
|
||||
```
|
||||
1. If $MEMPALACE_BROKER is set → use it verbatim (escape hatch).
|
||||
2. Read $MEMPALACE_PATH/broker.json → endpoint + pid + token.
|
||||
Try to connect (UNIX if host; forwarded-sock / TCP if container).
|
||||
If connected & healthy → done.
|
||||
3. Broker not reachable → START IT:
|
||||
- On host: flock($MEMPALACE_PATH/broker.lock, non-blocking)
|
||||
win → exec broker, wait for broker.json, connect.
|
||||
lose → someone else is starting it; backoff + retry connect.
|
||||
- In container: run `ssh host 'mempalace-broker --ensure'` (idempotent;
|
||||
performs the SAME flock election ON THE HOST), then forward +
|
||||
connect.
|
||||
4. Last-resort fallback (no broker, cannot start one):
|
||||
open the palace DIRECTLY — but ONLY after asserting this process is the sole
|
||||
writer (no other live broker/pid recorded in broker.json). Degrades to
|
||||
today's behaviour for the genuinely-alone case; never used when a broker
|
||||
exists.
|
||||
```
|
||||
|
||||
**Key trick:** host-side election uses `flock` on the host, where it is coherent
|
||||
(same kernel) — bulletproof. The cross-boundary case **never relies on cross-VM
|
||||
locking**; it relies on `ssh host 'broker --ensure'`, which runs the election on
|
||||
the host where flock works. That is what makes the design topology-independent.
|
||||
|
||||
### Lifecycle
|
||||
|
||||
- Broker writes `broker.json` (endpoint + pid + token) **atomically** after
|
||||
binding.
|
||||
- Broker holds `broker.lock` for its entire lifetime → at most one host broker.
|
||||
- Idle-exit after N minutes with no connected clients; the next client
|
||||
re-elects. (Or keep-alive; idle-exit is friendlier on resources.)
|
||||
- Clients reclaim a stale lock if the pid recorded in `broker.json` is dead.
|
||||
- Clients retry with backoff while a broker is mid-startup.
|
||||
|
||||
## Engine vs. shim — what the image must still ship
|
||||
|
||||
The component bundled in the images today is really **two separable pieces**:
|
||||
|
||||
- the **mempalace engine** — opens the SQLite files, computes embeddings, owns
|
||||
the HNSW index (the heavy part: chromadb, embedding model, etc.), and
|
||||
- the thin client surface pi actually talks to.
|
||||
|
||||
In the brokered design these split cleanly:
|
||||
|
||||
- the **broker** is the only thing that runs the *engine*;
|
||||
- the **shim** is **engine-free** — it just forwards MCP JSON-RPC. It needs no
|
||||
chromadb, no embedding model, no heavy deps. Embeddings/search happen
|
||||
broker-side. (Potential image-slimming opportunity, though see below for why
|
||||
we keep the engine bundled anyway.)
|
||||
|
||||
Whether the bundled engine is "used as-is" or merely fronted by the broker
|
||||
**depends on who owns the broker**:
|
||||
|
||||
**A) Host runs the broker (native, or native+container — the common case).**
|
||||
The *host's* engine is authoritative and used as-is. The broker is purely an
|
||||
intermediate step so writes can't collide; the host engine does the read/write.
|
||||
The container's **bundled engine is dormant** — the container uses only its shim
|
||||
to reach the host broker. The engine in the image is not needed for this path.
|
||||
|
||||
**B) Container lands on a host with no mempalace (fresh-host case).**
|
||||
The bundled engine earns its keep — you cannot conjure an engine onto the host
|
||||
without installing one. Either the container runs the broker *itself*
|
||||
(in-container ownership, bundled engine used as-is) or it falls back to degraded
|
||||
direct mode (single writer, bundled engine used directly).
|
||||
|
||||
**Decision: keep shipping the engine in the images** — but for three specific
|
||||
reasons, not because the brokered path needs it:
|
||||
|
||||
1. **Self-containedness** — pi-devbox's promise is "works on any host." A
|
||||
container with no memory unless the host pre-installed mempalace breaks that,
|
||||
especially for the Docker Hub audience.
|
||||
2. **Fresh-host bootstrap** (case B) — no host engine to borrow.
|
||||
3. **Degraded fallback** — the no-broker-reachable path opens the DB locally and
|
||||
needs the engine present.
|
||||
|
||||
In the host-managed common case the bundled engine is just dormant insurance;
|
||||
the shim is the only piece the container actively uses.
|
||||
|
||||
### Version-coherence note
|
||||
|
||||
Because **only the broker's engine ever writes**, its version defines the
|
||||
on-disk format. Host-vs-bundled engine version skew is therefore **harmless in
|
||||
the brokered path** (only one engine ever touches the bytes). Skew only bites in
|
||||
**degraded direct mode**, where the container writes with a possibly-different
|
||||
engine version than the host would. This argues for the broker pinning/owning
|
||||
the authoritative engine version and treating the bundled engine as
|
||||
fallback-only.
|
||||
|
||||
> Partially resolves the "where the broker binary ships" open question below:
|
||||
> the **shim** must ship on both sides; the **engine** must ship on the host
|
||||
> (to run the broker) and stays bundled in the image as fallback/bootstrap
|
||||
> insurance, not as the authoritative writer in the common case.
|
||||
|
||||
## The genuinely hard case
|
||||
|
||||
**Container-only with no SSH bridge configured** (e.g. plain Linux Docker,
|
||||
`HOST_SSH_USER` unset, no `host.docker.internal`). The container cannot start or
|
||||
reach a host broker. Options, none free:
|
||||
|
||||
1. **Require the bridge** for multi-writer container setups, and document it as a
|
||||
precondition. Reasonable: pi-devbox already ships `setup-lan-access.sh` and
|
||||
the bridge is the supported path.
|
||||
2. **Run the broker inside the container**, publishing a Docker port the host can
|
||||
later reach. Works, but inverts ownership and the broker dies with the
|
||||
container — only acceptable if containers are the *sole* writers on that host.
|
||||
3. **Accept degraded mode** (algorithm step 4): a lone container with no peers
|
||||
has no concurrency, so direct access is safe *as long as* nothing else opens
|
||||
the palace concurrently. The host shim also checks `broker.json` before
|
||||
opening directly, so a later host pi will not silently start a second
|
||||
uncoordinated writer.
|
||||
|
||||
**Summary:** fully robust for native-only, native+container, and
|
||||
container-only-with-bridge. The only residual sharp edge is container-only
|
||||
*without* a bridge *and* a future concurrent host writer — intrinsic (no shared
|
||||
coherent lock exists across that boundary), best handled by mandating the bridge
|
||||
rather than pretending file locks work.
|
||||
|
||||
## Co-existence with opencode / opencode-devbox (DEFERRED — context only)
|
||||
|
||||
The palace is shared by more than pi. opencode (native) and opencode-devbox
|
||||
(container) also write to the same `~/.mempalace`. **Assumption to verify:**
|
||||
opencode sessions write to **different wings** than pi sessions (pi uses
|
||||
`wing_pi`, diaries per-agent, etc.), so cross-tool intermixing into the *same*
|
||||
destination may be a non-issue at the application level.
|
||||
|
||||
However, the corruption risk here is at the **SQLite-file level, not the wing
|
||||
level** — two processes writing different wings of the *same* `chroma.sqlite3`
|
||||
concurrently is still a concurrent write to one file. So the broker, once it
|
||||
exists, is the right serialization point for opencode too: opencode's mempalace
|
||||
client would route through the same broker via the same shim mechanism.
|
||||
|
||||
**Decision:** do not design for opencode co-existence yet. Resolve the pi case
|
||||
first; then revisit whether opencode clients adopt the same shim. The residual
|
||||
risk in the interim is native + container *opencode* sessions writing the same
|
||||
palace simultaneously — explicitly deferred ("cross that bridge later").
|
||||
|
||||
## Open questions / TODO before implementation
|
||||
|
||||
- Does the mempalace engine expose an embeddable entrypoint suitable for running
|
||||
inside a long-lived broker, or does the broker wrap the existing MCP server
|
||||
binary and multiplex stdio clients onto it? (Affects whether reads can truly
|
||||
fan out or are also serialized.)
|
||||
- Idle-exit timeout default + whether to expose it via env.
|
||||
- `broker.json` schema + atomic-write + stale-pid-reclaim details.
|
||||
- TCP-path token handling and safe bind interface selection on Linux Docker
|
||||
(`--add-host=host.docker.internal:host-gateway`).
|
||||
- Where the broker binary ships: baked into `Dockerfile.base`? host install via
|
||||
pi-toolkit / mempalace-toolkit? Both, since both sides need the shim and the
|
||||
host needs the broker.
|
||||
- Smoke-test plan: prove single-writer invariant under a deliberate concurrent
|
||||
host+container write storm (should produce zero `.corrupt`/`.drift` snapshots).
|
||||
+39
-9
@@ -12,12 +12,16 @@ set -euo pipefail
|
||||
mkdir -p /tmp/sshcm
|
||||
chmod 700 /tmp/sshcm
|
||||
|
||||
# ── LAN access: generic host-OS-agnostic reachability helper ────────
|
||||
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
|
||||
# reach the host's directly-attached LAN peers by default; this generates a
|
||||
# writable ~/.ssh-local/config that uses the host as an SSH jump. On native
|
||||
# Linux (LAN reachable directly) it is a no-op. Controlled by DEVBOX_LAN_ACCESS
|
||||
# (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the script header.
|
||||
# ── LAN access + writable SSH sidecar: host-OS-agnostic helper ──────
|
||||
# Generates the writable ~/.ssh-local/config on EVERY host OS: a `Host *`
|
||||
# ControlPath redirect into ~/.ssh-local/cm (so `ssh -F` / dssh / dscp work
|
||||
# even when ~/.ssh is bind-mounted read-only) plus `Include ~/.ssh/config`. On
|
||||
# VM-backed hosts (macOS OrbStack / Docker Desktop) it ALSO adds an
|
||||
# SSH-jump-via-host block so the container can reach the host's
|
||||
# directly-attached LAN peers; on native Linux (LAN reachable directly) the
|
||||
# jump block is omitted but the sidecar is still rendered. Controlled by
|
||||
# DEVBOX_LAN_ACCESS (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the
|
||||
# script header.
|
||||
if [ -r /usr/local/lib/pi-devbox/setup-lan-access.sh ]; then
|
||||
bash /usr/local/lib/pi-devbox/setup-lan-access.sh || true
|
||||
fi
|
||||
@@ -86,9 +90,35 @@ if command -v pi &>/dev/null; then
|
||||
|
||||
# Bootstrap settings.json from template if absent (pi rewrites this
|
||||
# file at runtime — lastChangelogVersion, etc — so we can't symlink it).
|
||||
if [ ! -f "$HOME/.pi/agent/settings.json" ] && \
|
||||
[ -f /opt/pi-toolkit/settings.example.json ]; then
|
||||
cp /opt/pi-toolkit/settings.example.json "$HOME/.pi/agent/settings.json"
|
||||
_pi_settings="$HOME/.pi/agent/settings.json"
|
||||
_pi_template=/opt/pi-toolkit/settings.example.json
|
||||
if [ ! -f "$_pi_settings" ] && [ -f "$_pi_template" ]; then
|
||||
cp "$_pi_template" "$_pi_settings"
|
||||
echo "pi settings.json bootstrapped from template"
|
||||
elif [ -f "$_pi_settings" ] && [ -f "$_pi_template" ] && \
|
||||
[ "${PI_SETTINGS_MERGE:-1}" != "0" ] && command -v jq >/dev/null 2>&1; then
|
||||
# Non-destructive merge: a settings.json on a PRESERVED volume never
|
||||
# otherwise sees new template keys (the bootstrap above only fires when
|
||||
# the file is absent), so config added in an image upgrade — e.g. the
|
||||
# observational-memory / pi-fork blocks or a newly-enabled model — never
|
||||
# reaches existing users. Deep-merge with the template FIRST and the
|
||||
# live file SECOND ('.[0] * .[1]') so the user's values always win and
|
||||
# only keys MISSING from the live file are filled in from the template.
|
||||
# Arrays are treated as leaves (the user's array is kept verbatim, so a
|
||||
# model they deliberately removed is not re-added). Only rewrite when the
|
||||
# merge actually changes something, and back up the original first.
|
||||
# Set PI_SETTINGS_MERGE=0 to disable. Invalid JSON on either side → skip,
|
||||
# never clobber.
|
||||
if _pi_merged=$(jq -s '.[0] * .[1]' "$_pi_template" "$_pi_settings" 2>/dev/null); then
|
||||
if [ -n "$_pi_merged" ] && \
|
||||
! printf '%s' "$_pi_merged" | jq -e --slurpfile cur "$_pi_settings" '. == $cur[0]' >/dev/null 2>&1; then
|
||||
cp "$_pi_settings" "${_pi_settings}.bak.$(date +%Y%m%d-%H%M%S)"
|
||||
printf '%s\n' "$_pi_merged" > "$_pi_settings"
|
||||
echo "pi settings.json: merged new template keys from settings.example.json (backup saved)"
|
||||
fi
|
||||
else
|
||||
echo "WARN: pi settings.json merge skipped (jq could not parse template or live file; left untouched)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# pi↔mempalace MCP bridge — single extension symlink.
|
||||
|
||||
@@ -89,9 +89,16 @@ fi
|
||||
# we append with a newline separator to avoid the ';;' parse error
|
||||
# described at the top of this file. Guarded so repeated sourcing
|
||||
# (e.g. `exec bash`) doesn't stack duplicates.
|
||||
#
|
||||
# The guard MUST stay shell-local (NOT exported): if it leaks into child
|
||||
# processes, every nested shell -- crucially each tmux pane, which inherits
|
||||
# the tmux server's env -- skips installing `history -a` and only persists
|
||||
# history on a clean exit. Abrupt termination (docker stop, tmux kill-server,
|
||||
# SIGKILL) then loses that shell's in-memory history. Keeping it unexported
|
||||
# means each new interactive shell re-installs its own per-prompt flush.
|
||||
if [ -z "${DEVBOX_HIST_SET:-}" ]; then
|
||||
PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND$'\n'}history -a"
|
||||
export DEVBOX_HIST_SET=1
|
||||
DEVBOX_HIST_SET=1
|
||||
fi
|
||||
|
||||
# ── Prompt: show [opencode-devbox] tag so it's obvious you're in the container
|
||||
|
||||
Executable
+59
@@ -0,0 +1,59 @@
|
||||
#!/usr/bin/env bash
|
||||
# dot-watch — auto-rerender a graphviz .dot file to PNG on every save.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# pi-studio renders mermaid natively but has no graphviz/DOT renderer.
|
||||
# Its markdown preview DOES render local image links (.png/.jpg/.gif/.webp),
|
||||
# and the editor offers "refresh from disk". This helper closes the loop:
|
||||
# edit a .dot file -> dot-watch regenerates <name>.png -> hit refresh in
|
||||
# Studio to see the update. Uses mtime polling (no inotify dependency,
|
||||
# which isn't in the trixie-slim base).
|
||||
#
|
||||
# USAGE
|
||||
# dot-watch <file.dot> [layout] [dpi]
|
||||
# layout: dot|neato|fdp|circo|twopi (default: dot)
|
||||
# dpi: output resolution (default: 150)
|
||||
# env: DOT_WATCH_INTERVAL=<seconds> poll interval (default: 1)
|
||||
#
|
||||
# EXAMPLES
|
||||
# dot-watch /workspace/graph.dot
|
||||
# dot-watch graph.dot neato 200
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
SRC="${1:?usage: dot-watch <file.dot> [layout] [dpi]}"
|
||||
LAYOUT="${2:-dot}"
|
||||
DPI="${3:-150}"
|
||||
|
||||
[[ -f "$SRC" ]] || { echo "error: no such file: $SRC" >&2; exit 1; }
|
||||
command -v "$LAYOUT" >/dev/null || { echo "error: layout engine '$LAYOUT' not found" >&2; exit 1; }
|
||||
|
||||
OUT="${SRC%.dot}.png"
|
||||
INTERVAL="${DOT_WATCH_INTERVAL:-1}" # seconds between polls
|
||||
ERRLOG="$(mktemp -t dot-watch.XXXXXX.err)"
|
||||
trap 'rm -f "$ERRLOG"' EXIT
|
||||
|
||||
render() {
|
||||
if "$LAYOUT" -Tpng -Gdpi="$DPI" "$SRC" -o "$OUT" 2> "$ERRLOG"; then
|
||||
printf '[%s] rendered -> %s\n' "$(date +%H:%M:%S)" "$OUT"
|
||||
else
|
||||
printf '[%s] DOT error:\n' "$(date +%H:%M:%S)"
|
||||
sed 's/^/ /' "$ERRLOG"
|
||||
fi
|
||||
}
|
||||
|
||||
# portable mtime (GNU stat, fallback to BSD stat)
|
||||
mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null; }
|
||||
|
||||
echo "watching $SRC ($LAYOUT, ${DPI}dpi) -> $OUT [Ctrl-C to stop]"
|
||||
render
|
||||
last="$(mtime "$SRC")"
|
||||
while true; do
|
||||
sleep "$INTERVAL"
|
||||
[[ -f "$SRC" ]] || continue
|
||||
now="$(mtime "$SRC")"
|
||||
if [[ "$now" != "$last" ]]; then
|
||||
last="$now"
|
||||
render
|
||||
fi
|
||||
done
|
||||
@@ -14,7 +14,9 @@
|
||||
# The one thing reachable from a container on every OS is the host itself
|
||||
# (host.docker.internal). So on VM-backed hosts we generate a writable SSH
|
||||
# config that reaches the host and lets the user ProxyJump onward to LAN
|
||||
# peers the host can reach. On native Linux we do nothing.
|
||||
# peers the host can reach. On native Linux we render the same writable
|
||||
# config (for the ControlPath redirect + Include ~/.ssh/config) but emit no
|
||||
# jump block, since LAN peers are reachable directly there.
|
||||
#
|
||||
# We ship the MECHANISM (a generic `host` jump alias + writable config),
|
||||
# never the POLICY: the user's specific target hosts live in their own
|
||||
@@ -30,7 +32,9 @@
|
||||
#
|
||||
# CONTROLS (env)
|
||||
# DEVBOX_LAN_ACCESS = auto (default) | jump | off
|
||||
# auto → set up the jump config only on VM-backed hosts; no-op on Linux.
|
||||
# auto → set up the host jump only on VM-backed hosts. The writable
|
||||
# sidecar config (ControlPath redirect + Include) is always
|
||||
# rendered, on every OS.
|
||||
# jump → always set up (e.g. native Linux with extra_hosts host-gateway).
|
||||
# off → do nothing.
|
||||
# HOST_SSH_USER — the username to SSH into the host as. REQUIRED for the
|
||||
@@ -84,42 +88,72 @@ is_vm_backed() {
|
||||
getent hosts "$HOST_ALIAS_HOSTNAME" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
if [ "$MODE" = "auto" ] && ! is_vm_backed; then
|
||||
# Native Linux host: LAN peers are reachable directly. Nothing to do.
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# From here: MODE=jump, or MODE=auto on a VM-backed host.
|
||||
|
||||
command -v ssh-keygen >/dev/null 2>&1 || exit 0
|
||||
|
||||
# ── Writable socket dir + sidecar (ALWAYS, every host OS) ─────────────
|
||||
# The ControlPath redirect in the generated config needs a writable directory
|
||||
# regardless of host OS or jump mode. ~/.ssh is typically read-only, so the
|
||||
# master socket lives under the writable ~/.ssh-local. We create it and render
|
||||
# the config UNCONDITIONALLY so the redirect (and `Include ~/.ssh/config`) works
|
||||
# even on native Linux — where we set up no host jump but a read-only ~/.ssh
|
||||
# would otherwise still break ControlMaster sockets.
|
||||
mkdir -p "${SSH_LOCAL}/cm" 2>/dev/null || true
|
||||
chmod 700 "${SSH_LOCAL}" "${SSH_LOCAL}/cm" 2>/dev/null || true
|
||||
|
||||
# ── Jump key (generated once; preserved across restarts) ──────────────
|
||||
# ── Decide whether to set up the host jump ────────────────────────────
|
||||
# Jump = reach the container host (host.docker.internal) as an SSH ProxyJump
|
||||
# onward to the host's LAN peers. Needed on VM-backed hosts (macOS / Docker
|
||||
# Desktop) or when forced with DEVBOX_LAN_ACCESS=jump. On native Linux LAN
|
||||
# peers are reachable directly, so NEED_JUMP=0 and we emit no jump block — but
|
||||
# we still render the config for the ControlPath redirect + Include.
|
||||
NEED_JUMP=0
|
||||
if [ "$MODE" = "jump" ] || { [ "$MODE" = "auto" ] && is_vm_backed; }; then
|
||||
NEED_JUMP=1
|
||||
fi
|
||||
|
||||
# ── Jump key (only when a jump is needed; generated once, preserved) ──
|
||||
# Persisted via a named volume on ~/.ssh-local (see compose), so a fresh key
|
||||
# is generated only on the very first start (or if the volume is wiped). When
|
||||
# we DO generate one it must be (re-)authorized on the host, so we flag it and
|
||||
# print a copy-paste authorize line below.
|
||||
KEY_JUST_GENERATED=0
|
||||
if [ ! -f "$KEY" ]; then
|
||||
ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1 || exit 0
|
||||
chmod 600 "$KEY" 2>/dev/null || true
|
||||
KEY_JUST_GENERATED=1
|
||||
if [ "$NEED_JUMP" = "1" ] && command -v ssh-keygen >/dev/null 2>&1 && [ ! -f "$KEY" ]; then
|
||||
if ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1; then
|
||||
chmod 600 "$KEY" 2>/dev/null || true
|
||||
KEY_JUST_GENERATED=1
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Render the writable config ────────────────────────────────────────
|
||||
USER_LINE=""
|
||||
if [ -n "${HOST_SSH_USER:-}" ]; then
|
||||
USER_LINE=" User ${HOST_SSH_USER}"
|
||||
fi
|
||||
|
||||
# Optional host-owned named-peer jump overrides (portable: lives on the host,
|
||||
# not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins.
|
||||
SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf"
|
||||
# Jump-specific blocks (the host alias, host-owned peer overrides, and the
|
||||
# optional RFC1918 catch-all) only make sense when a jump is set up; on native
|
||||
# Linux they are all empty and only the ControlPath redirect + Include remain.
|
||||
JUMP_BLOCK=""
|
||||
LAN_CONF_BLOCK=""
|
||||
if [ -r "$SSH_LAN_CONF" ]; then
|
||||
LAN_CONF_BLOCK=$(cat <<'EOF'
|
||||
AUTOJUMP_BLOCK=""
|
||||
if [ "$NEED_JUMP" = "1" ]; then
|
||||
USER_LINE=""
|
||||
if [ -n "${HOST_SSH_USER:-}" ]; then
|
||||
USER_LINE=" User ${HOST_SSH_USER}"
|
||||
fi
|
||||
JUMP_BLOCK=$(cat <<EOF
|
||||
|
||||
# The container host (OrbStack / Docker Desktop). 'host' and 'mac' are aliases.
|
||||
Host host mac
|
||||
HostName ${HOST_ALIAS_HOSTNAME}
|
||||
${USER_LINE}
|
||||
IdentityFile ~/.ssh-local/devbox_jump_ed25519
|
||||
IdentitiesOnly yes
|
||||
ControlMaster auto
|
||||
ControlPath ~/.ssh-local/cm/%r@%h:%p
|
||||
ControlPersist 4h
|
||||
ServerAliveInterval 30
|
||||
EOF
|
||||
)
|
||||
|
||||
# Optional host-owned named-peer jump overrides (portable: lives on the host,
|
||||
# not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins.
|
||||
SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf"
|
||||
if [ -r "$SSH_LAN_CONF" ]; then
|
||||
LAN_CONF_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# Host-owned named-peer jump overrides (bind-mounted; edit on the host).
|
||||
# Scope reset to match-all so the Include applies to every target host.
|
||||
@@ -127,14 +161,13 @@ Host *
|
||||
Include ~/.config/devbox-shell/ssh-lan.conf
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
fi
|
||||
|
||||
# Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the
|
||||
# host. Matches the typed address, never the resolved HostName, so named hosts
|
||||
# with their own ProxyJump are unaffected. Network-agnostic → roaming-safe.
|
||||
AUTOJUMP_BLOCK=""
|
||||
if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then
|
||||
AUTOJUMP_BLOCK=$(cat <<'EOF'
|
||||
# Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the
|
||||
# host. Matches the typed address, never the resolved HostName, so named hosts
|
||||
# with their own ProxyJump are unaffected. Network-agnostic → roaming-safe.
|
||||
if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then
|
||||
AUTOJUMP_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# RFC1918 auto-jump (DEVBOX_LAN_AUTOJUMP_PRIVATE=1): reach any private IP on
|
||||
# the host's CURRENT LAN via bare `dssh user@<ip>`. Public IPs are unmatched
|
||||
@@ -146,6 +179,7 @@ Host 10.* 192.168.* 172.16.* 172.17.* 172.18.* 172.19.* 172.20.* 172.21.* 172.22
|
||||
ProxyJump host
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
fi
|
||||
|
||||
INCLUDE_BLOCK=""
|
||||
@@ -176,17 +210,7 @@ Host *
|
||||
UserKnownHostsFile ~/.ssh-local/known_hosts
|
||||
StrictHostKeyChecking accept-new
|
||||
ControlPath ~/.ssh-local/cm/%r@%h:%p
|
||||
|
||||
# The container host (OrbStack / Docker Desktop). 'host' and 'mac' are aliases.
|
||||
Host host mac
|
||||
HostName ${HOST_ALIAS_HOSTNAME}
|
||||
${USER_LINE}
|
||||
IdentityFile ~/.ssh-local/devbox_jump_ed25519
|
||||
IdentitiesOnly yes
|
||||
ControlMaster auto
|
||||
ControlPath ~/.ssh-local/cm/%r@%h:%p
|
||||
ControlPersist 4h
|
||||
ServerAliveInterval 30
|
||||
${JUMP_BLOCK}
|
||||
${LAN_CONF_BLOCK}
|
||||
${AUTOJUMP_BLOCK}
|
||||
${INCLUDE_BLOCK}
|
||||
@@ -199,6 +223,7 @@ chmod 600 "$CONFIG" 2>/dev/null || true
|
||||
# host won't recognize. With ~/.ssh-local persisted via a named volume, case
|
||||
# (b) fires only on first-ever start (or after the volume is reset) — so this
|
||||
# is normally a one-time, one-line step per machine, with no file to locate.
|
||||
if [ "$NEED_JUMP" = "1" ]; then
|
||||
PUBKEY_TEXT="$(cat "${KEY}.pub" 2>/dev/null)"
|
||||
if [ -z "${HOST_SSH_USER:-}" ]; then
|
||||
cat <<EOF
|
||||
@@ -221,5 +246,6 @@ elif [ "$KEY_JUST_GENERATED" = "1" ]; then
|
||||
repeat this on container updates — only if that volume is reset.
|
||||
EOF
|
||||
fi
|
||||
fi
|
||||
|
||||
exit 0
|
||||
|
||||
Executable
+43
@@ -0,0 +1,43 @@
|
||||
#!/usr/bin/env bash
|
||||
# check-base-hash.sh — guard the base-rebuild invariant.
|
||||
#
|
||||
# Every floating `ARG *_REF` consumed by Dockerfile.base MUST be folded
|
||||
# into the base_tag hash in the docker-publish workflow. Otherwise a
|
||||
# ref-only change to that dependency does not change the base hash, the
|
||||
# Docker Hub probe finds the old base tag, and the base is NOT rebuilt —
|
||||
# the dependency fix silently fails to land. This is the v1.1.2-class
|
||||
# staleness footgun (then it was mempalace-toolkit; this guard stops the
|
||||
# next one before it ships).
|
||||
#
|
||||
# Runs in CI (base-decide job) and locally: bash scripts/check-base-hash.sh
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
WF=".gitea/workflows/docker-publish.yml"
|
||||
DF="Dockerfile.base"
|
||||
|
||||
# Extract the hash-compute block: the `HASH=$( … ) | sha256sum | cut`
|
||||
# brace-group in the "Compute base tag" step. This lives in a separate
|
||||
# file from the workflow, so scanning $WF here is free of the self-match
|
||||
# hazard an inline workflow step would have.
|
||||
block=$(awk '/HASH=\$\(/{f=1} f{print} f && /cut -c1-12/{exit}' "$WF")
|
||||
if [ -z "$block" ]; then
|
||||
echo "::error::could not locate the HASH=\$( … ) | sha256sum block in $WF"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
refs=$(grep -oE '^ARG [A-Z0-9_]+_REF' "$DF" | awk '{print $2}' | sort -u)
|
||||
fail=0
|
||||
for r in $refs; do
|
||||
lc=$(printf '%s' "$r" | tr '[:upper:]' '[:lower:]')
|
||||
if ! printf '%s' "$block" | grep -q "outputs.$lc"; then
|
||||
echo "::error::Dockerfile.base declares '$r' but it is NOT folded into the base_tag hash in $WF."
|
||||
echo "::error::Add echo \"\${{ needs.resolve-versions.outputs.$lc }}\" inside the HASH=\$( … ) | sha256sum block, or a $r-only change will silently fail to rebuild the base."
|
||||
fail=1
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$fail" = 0 ]; then
|
||||
echo "OK: all Dockerfile.base *_REF args are folded into base_tag (${refs:-none})."
|
||||
fi
|
||||
exit $fail
|
||||
Executable
+303
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env bash
|
||||
# Runtime post-recreate verification for pi-devbox.
|
||||
#
|
||||
# Verifies that after `docker compose up -d --force-recreate`:
|
||||
# - The new image is actually live (pi version matches, when an expected
|
||||
# version is supplied — see the version note below)
|
||||
# - Persisted named volumes survived (~/.pi config, shell history, zoxide,
|
||||
# nvim data, uv cache, ssh-local)
|
||||
# - pi runtime wiring is intact: keybindings symlink, AGENTS.md symlink,
|
||||
# ≥4 extensions, the mempalace.ts bridge, settings.json, and the pi-fork /
|
||||
# pi-observational-memory / (studio variant) pi-studio package registrations
|
||||
# - Shell defaults re-seeded from /etc/skel-devbox
|
||||
# - /tmp/sshcm exists with mode 700 (ssh ControlMaster dir)
|
||||
# - /opt toolkits intact
|
||||
# - Known expected-absences don't regress
|
||||
#
|
||||
# This is repo/maintainer tooling — the runtime peer of smoke-test.sh.
|
||||
# smoke-test.sh runs at BUILD time with `--entrypoint=""`, so it can never see
|
||||
# a recreated container's persisted volumes or the entrypoint's runtime
|
||||
# deploy. This script is its runtime counterpart: it inspects what is actually
|
||||
# live in the container you are sitting in after a recreate.
|
||||
#
|
||||
# It is NOT baked into the published Docker Hub image; run it from a checkout of
|
||||
# the pi-devbox repo (which a maintainer already has for CI builds). A plain
|
||||
# `docker pull` consumer is not the audience and will not have this file.
|
||||
#
|
||||
# Version note: pi's version is resolved from `latest` at CI build time and is
|
||||
# NOT pinned to a concrete value in Dockerfile.variant (ARG PI_VERSION=latest).
|
||||
# So unlike opencode-devbox, this script cannot self-derive an expected version
|
||||
# from the Dockerfile. Pass --expected-version to assert a match; without it the
|
||||
# live pi version is reported as an informational WARN, not a failure.
|
||||
#
|
||||
# Usage: ./scripts/recreate-sanity-check.sh [--expected-version X.Y.Z] [--variant studio|plain]
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 all checks passed
|
||||
# 1 one or more checks failed
|
||||
# 2 usage error
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
EXPECTED_VERSION=""
|
||||
VARIANT=""
|
||||
REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
|
||||
# Parse arguments
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--expected-version)
|
||||
EXPECTED_VERSION="$2"
|
||||
shift 2
|
||||
;;
|
||||
--variant)
|
||||
VARIANT="$2"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
echo "usage: $0 [--expected-version X.Y.Z] [--variant studio|plain]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
FAILED=0
|
||||
pass() { echo " ✓ $1"; }
|
||||
fail() { echo " ✗ $1" >&2; FAILED=$((FAILED + 1)); }
|
||||
warn() { echo " ⚠ $1" >&2; }
|
||||
|
||||
# Auto-detect variant if not provided. The studio variant vendors pi-studio to
|
||||
# /opt/pi-studio; the plain variant does not.
|
||||
if [ -z "$VARIANT" ]; then
|
||||
if [ -d /opt/pi-studio ]; then
|
||||
VARIANT="studio"
|
||||
else
|
||||
VARIANT="plain"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Print header with git context
|
||||
echo "=== Recreate sanity check (variant: $VARIANT) ==="
|
||||
if GIT_TAG=$(git -C "$REPO_DIR" describe --tags 2>/dev/null); then
|
||||
echo " Repo HEAD: $GIT_TAG (version-match only meaningful when image tag matches)"
|
||||
else
|
||||
echo " Repo HEAD: (not a git repo or no tags)"
|
||||
fi
|
||||
echo
|
||||
|
||||
echo "-- pi version --"
|
||||
if ACTUAL_VERSION=$(pi --version 2>&1 | head -1); then
|
||||
if [ -n "$EXPECTED_VERSION" ]; then
|
||||
if [ "$ACTUAL_VERSION" = "$EXPECTED_VERSION" ]; then
|
||||
pass "pi version $ACTUAL_VERSION"
|
||||
else
|
||||
fail "pi version mismatch: expected $EXPECTED_VERSION, got $ACTUAL_VERSION"
|
||||
fi
|
||||
else
|
||||
warn "pi version $ACTUAL_VERSION (no --expected-version given; pi is built from 'latest', cannot self-derive — informational only)"
|
||||
fi
|
||||
else
|
||||
fail "pi --version failed"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- Persisted named volumes (must survive --force-recreate) --"
|
||||
|
||||
# ~/.pi config volume (devbox-pi-config) — holds agent settings, extensions,
|
||||
# keybindings symlink. Must exist and be non-empty after recreate.
|
||||
if [ -d "$HOME/.pi/agent" ] && [ -n "$(ls -A "$HOME/.pi/agent" 2>/dev/null)" ]; then
|
||||
pass "~/.pi/agent exists and is non-empty"
|
||||
else
|
||||
fail "~/.pi/agent missing or empty"
|
||||
fi
|
||||
|
||||
# shell history volume (devbox-shell-history). An empty .bash_history right
|
||||
# after recreate is NORMAL — only the mount point must exist.
|
||||
if [ -d "$HOME/.cache/bash" ]; then
|
||||
pass "~/.cache/bash exists as directory"
|
||||
else
|
||||
fail "~/.cache/bash missing or not a directory"
|
||||
fi
|
||||
|
||||
# remaining persisted volumes — mount points must exist
|
||||
for vol_path in \
|
||||
"$HOME/.local/share/zoxide" \
|
||||
"$HOME/.local/share/nvim" \
|
||||
"$HOME/.local/share/uv" \
|
||||
"$HOME/.ssh-local"; do
|
||||
if [ -d "$vol_path" ]; then
|
||||
pass "$vol_path exists"
|
||||
else
|
||||
fail "$vol_path missing or not a directory"
|
||||
fi
|
||||
done
|
||||
|
||||
# mempalace palace — CONDITIONAL. In this repo's docker-compose.yml the
|
||||
# devbox-palace named volume is commented out; the palace is reached via the
|
||||
# shared /workspace (virtiofs) path instead. So absence of a local palace dir
|
||||
# is NOT a recreate regression here.
|
||||
if [ -f "$HOME/.mempalace/palace/chroma.sqlite3" ]; then
|
||||
SIZE=$(du -h "$HOME/.mempalace/palace/chroma.sqlite3" | cut -f1)
|
||||
if [ -s "$HOME/.mempalace/palace/chroma.sqlite3" ]; then
|
||||
pass "~/.mempalace/palace/chroma.sqlite3 exists ($SIZE)"
|
||||
else
|
||||
fail "~/.mempalace/palace/chroma.sqlite3 exists but is empty"
|
||||
fi
|
||||
else
|
||||
warn "~/.mempalace/palace/chroma.sqlite3 absent — expected unless devbox-palace volume is enabled (palace is shared via /workspace by default)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- pi runtime wiring (deployed by entrypoint-user.sh) --"
|
||||
|
||||
# keybindings symlink (pi-toolkit)
|
||||
if [ -L "$HOME/.pi/agent/keybindings.json" ]; then
|
||||
pass "~/.pi/agent/keybindings.json symlink (pi-toolkit)"
|
||||
else
|
||||
fail "~/.pi/agent/keybindings.json missing or not a symlink"
|
||||
fi
|
||||
|
||||
# global AGENTS.md symlink (pi-toolkit) — global instructions loaded by pi at
|
||||
# every start (directs the agent to read the pi-extensions skill at session start)
|
||||
if [ -L "$HOME/.pi/agent/AGENTS.md" ]; then
|
||||
pass "~/.pi/agent/AGENTS.md symlink (pi-toolkit)"
|
||||
else
|
||||
fail "~/.pi/agent/AGENTS.md missing or not a symlink"
|
||||
fi
|
||||
|
||||
# extensions deployed (pi-extensions) — expect ≥4 *.ts
|
||||
EXT_COUNT=$(ls -1 "$HOME"/.pi/agent/extensions/*.ts 2>/dev/null | wc -l | tr -d ' ')
|
||||
if [ "$EXT_COUNT" -ge 4 ]; then
|
||||
pass "$EXT_COUNT extensions deployed (≥4, pi-extensions)"
|
||||
else
|
||||
fail "only $EXT_COUNT extensions deployed (expected ≥4)"
|
||||
fi
|
||||
|
||||
# mempalace.ts bridge symlink
|
||||
if [ -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
|
||||
pass "~/.pi/agent/extensions/mempalace.ts bridge symlink"
|
||||
else
|
||||
fail "~/.pi/agent/extensions/mempalace.ts missing or not a symlink"
|
||||
fi
|
||||
|
||||
# settings.json bootstrapped
|
||||
if [ -f "$HOME/.pi/agent/settings.json" ]; then
|
||||
pass "~/.pi/agent/settings.json bootstrapped"
|
||||
else
|
||||
fail "~/.pi/agent/settings.json missing"
|
||||
fi
|
||||
|
||||
# settings.json merge: the entrypoint deep-merges new template keys into a
|
||||
# preserved settings.json on every start, so config added in an image upgrade
|
||||
# (e.g. the observational-memory / pi-fork blocks) reaches existing volumes.
|
||||
# Assert those blocks are present and that the file is still valid JSON.
|
||||
if command -v jq >/dev/null 2>&1 && [ -f "$HOME/.pi/agent/settings.json" ]; then
|
||||
if jq -e 'has("observational-memory") and has("pi-fork")' "$HOME/.pi/agent/settings.json" >/dev/null 2>&1; then
|
||||
pass "settings.json has observational-memory + pi-fork blocks (template merge)"
|
||||
else
|
||||
fail "settings.json missing observational-memory and/or pi-fork blocks (template merge did not land)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# pi package registrations (pi install <local-path> → recorded in settings.json)
|
||||
if [ -f "$HOME/.pi/agent/settings.json" ]; then
|
||||
for pkg in pi-fork pi-observational-memory; do
|
||||
if grep -q "$pkg" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
|
||||
pass "$pkg registered in settings.json"
|
||||
else
|
||||
fail "$pkg not registered in settings.json"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$VARIANT" = "studio" ]; then
|
||||
if grep -q "pi-studio" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
|
||||
pass "pi-studio registered in settings.json"
|
||||
else
|
||||
fail "pi-studio not registered in settings.json (studio variant)"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- ssh ControlMaster dir --"
|
||||
if [ -d /tmp/sshcm ] && [ "$(stat -c %a /tmp/sshcm 2>/dev/null)" = "700" ]; then
|
||||
pass "/tmp/sshcm exists with mode 700"
|
||||
else
|
||||
fail "/tmp/sshcm missing or not mode 700"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- Shell defaults re-seeded from /etc/skel-devbox --"
|
||||
if [ -f "$HOME/.bash_aliases" ]; then
|
||||
pass "~/.bash_aliases exists"
|
||||
else
|
||||
fail "~/.bash_aliases missing"
|
||||
fi
|
||||
|
||||
# History flush must survive shell nesting. The DEVBOX_HIST_SET guard must NOT
|
||||
# be exported: if it leaks into child processes, nested shells (esp. tmux
|
||||
# panes) skip installing `history -a` and lose in-memory history on abrupt
|
||||
# termination. Assert a child login shell still wires up the per-prompt flush.
|
||||
if bash -lic 'bash -lic "case \"\$PROMPT_COMMAND\" in *\"history -a\"*) exit 0;; *) exit 1;; esac"' </dev/null >/dev/null 2>&1; then
|
||||
pass "nested shell installs 'history -a' (DEVBOX_HIST_SET not exported)"
|
||||
else
|
||||
fail "nested shell missing 'history -a' — DEVBOX_HIST_SET leaking to children?"
|
||||
fi
|
||||
|
||||
if [ -f "$HOME/.inputrc" ]; then
|
||||
pass "~/.inputrc exists"
|
||||
else
|
||||
fail "~/.inputrc missing"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- cli_utils bind-mount --"
|
||||
if [ -d /workspace/cli_utils ] && [ -d /workspace/cli_utils/.git ]; then
|
||||
pass "/workspace/cli_utils exists with .git subdir"
|
||||
else
|
||||
warn "/workspace/cli_utils missing or .git subdir absent — expected only if cli_utils is bind-mounted"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- Baked /opt toolkits --"
|
||||
for opt_path in /opt/pi-toolkit /opt/pi-extensions /opt/pi-fork /opt/pi-observational-memory /opt/mempalace-toolkit; do
|
||||
if [ -d "$opt_path" ]; then
|
||||
pass "$opt_path exists"
|
||||
else
|
||||
fail "$opt_path missing"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ "$VARIANT" = "studio" ]; then
|
||||
if [ -d /opt/pi-studio ] && [ -f /opt/pi-studio/client/studio-client.js ]; then
|
||||
pass "/opt/pi-studio exists with prebuilt client bundle"
|
||||
else
|
||||
fail "/opt/pi-studio missing or prebuilt client bundle absent (studio variant)"
|
||||
fi
|
||||
fi
|
||||
|
||||
# mempalace MCP entrypoint on PATH
|
||||
if command -v mempalace-mcp >/dev/null 2>&1; then
|
||||
pass "mempalace-mcp on PATH"
|
||||
else
|
||||
fail "mempalace-mcp not on PATH"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "-- Known expected-absences (regressions vs by-design) --"
|
||||
if ! command -v go >/dev/null 2>&1; then
|
||||
warn "go absent — expected unless image built with INSTALL_GO=true"
|
||||
else
|
||||
pass "go is on PATH"
|
||||
fi
|
||||
|
||||
if [ "$VARIANT" = "plain" ] && [ ! -d /opt/pi-studio ]; then
|
||||
warn "/opt/pi-studio absent — expected on the plain (non-studio) variant"
|
||||
fi
|
||||
|
||||
echo
|
||||
if [ "$FAILED" -gt 0 ]; then
|
||||
echo "=== FAILED: $FAILED check(s) ===" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "=== PASSED ==="
|
||||
@@ -113,6 +113,28 @@ else
|
||||
echo " ℹ️ pi-studio not present (non-studio variant) — skipping studio clone checks"
|
||||
fi
|
||||
|
||||
# ── Build provenance (manifest + OCI labels) ─────────────────────────
|
||||
echo ""
|
||||
echo "── Build provenance ──"
|
||||
run "/etc/pi-devbox/build-manifest.json present" \
|
||||
"test -f /etc/pi-devbox/build-manifest.json"
|
||||
run_expect "manifest records pi-extensions component" \
|
||||
"cat /etc/pi-devbox/build-manifest.json" '"pi-extensions"'
|
||||
run_expect "manifest records pi_version" \
|
||||
"cat /etc/pi-devbox/build-manifest.json" '"pi_version"'
|
||||
# Every component must be a resolved commit (or null for pi-studio in the
|
||||
# non-studio variant) — 'unknown' means a clone silently failed to resolve.
|
||||
run "manifest has no unresolved ('unknown') components" \
|
||||
"! grep -q '\"unknown\"' /etc/pi-devbox/build-manifest.json"
|
||||
# OCI labels live in the image config, not the container fs — inspect them
|
||||
# from the host docker rather than via `docker run`.
|
||||
LBL=$(docker inspect --format '{{ index .Config.Labels "se.jordbo.pi-devbox.pi-extensions-ref" }}' "$IMAGE" 2>/dev/null || true)
|
||||
if [ -n "$LBL" ] && [ "$LBL" != "<no value>" ]; then
|
||||
printf " ✅ OCI label se.jordbo.pi-devbox.pi-extensions-ref=%s\n" "$LBL"; PASS=$((PASS+1))
|
||||
else
|
||||
printf " ❌ OCI label se.jordbo.pi-devbox.pi-extensions-ref missing or empty\n"; FAIL=$((FAIL+1))
|
||||
fi
|
||||
|
||||
# ── Runtime deployment (needs entrypoint to run) ──────────────────────
|
||||
echo ""
|
||||
echo "── Runtime deployment ──"
|
||||
|
||||
Reference in New Issue
Block a user