Compare commits

..

14 Commits

Author SHA1 Message Date
Joakim Persson d619a6e2ec fix(entrypoint,smoke): link image-baked skills early to fix smoke race
Publish Docker Image / resolve-versions (push) Successful in 21s
Publish Docker Image / base-decide (push) Successful in 7s
Publish Docker Image / build-base (push) Successful in 33m43s
Publish Docker Image / smoke-studio (push) Successful in 4m5s
Publish Docker Image / smoke (push) Successful in 5m42s
Publish Docker Image / build-variant (push) Successful in 15m55s
Publish Docker Image / promote-base-latest (push) Successful in 7s
Publish Docker Image / build-variant-studio (push) Successful in 17m45s
Publish Docker Image / update-description (push) Successful in 56s
The runtime 'pi-devbox-environment skill linked' smoke assertion failed in
CI run 408 (gating build-variant). Root cause: the skill-linking block ran
AFTER the pi-toolkit/extensions deploy, but the smoke readiness gate only
waits on pi-deploy markers (keybindings.json, mempalace.ts) — which land
before the skill symlink — so the assertion sampled too early.

- entrypoint-user.sh: move the image-baked-skills symlink loop to run early
  (before the pi deploy block), so it completes before any readiness marker.
  Still before the skillset deploy, so foreign-link semantics are unchanged.
- smoke-test.sh: add the skill symlink to the readiness gate as well.

Build-time checks (baked skill, append snippet, merged AGENTS marker) all
passed in 408; only the timing of the runtime check was wrong.
2026-06-23 14:29:52 +02:00
Joakim Persson 2abfee141b feat: image-baked agent skills + pi-devbox-environment skill (v1.2.0)
Publish Docker Image / smoke-studio (push) Failing after 4m5s
Publish Docker Image / build-variant-studio (push) Has been skipped
Publish Docker Image / smoke (push) Failing after 5m46s
Publish Docker Image / build-variant (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / resolve-versions (push) Successful in 35s
Publish Docker Image / base-decide (push) Successful in 23s
Publish Docker Image / build-base (push) Successful in 41m32s
Ship skills inside the image (independent of any mounted skillset repo):
- rootfs/usr/local/share/pi-devbox/skills/<name>/ symlinked into
  ~/.agents/skills/ by entrypoint-user.sh (foreign-link, survives volume
  recreate, never clobbers a skillset/user skill of the same name).
- New pi-devbox-environment skill: persistence model, host/LAN SSH
  reachability, split-DNS mechanisms, interactive-vs-tool-shell alias
  gotcha, tmux 0-index, uv-first Python, pi-studio reachability. Agnostic
  to host OS / hostnames / domains / nameservers (discovered at runtime).
- Dockerfile.variant appends pi-global-AGENTS.append.md onto pi-toolkit's
  pi-global-AGENTS.md (single global slot) so the skill is loaded
  proactively; gated on /usr/local/lib/pi-devbox/. Idempotent.
- smoke-test: baked-skill + append-snippet + merged-marker presence and a
  runtime symlink assertion.
- docs: README 'Agent skills' section, AGENTS.md layout, DOCKER_HUB.md;
  moved studio-tex roadmap to v1.3.0.

pi 0.79.7 -> 0.79.10 (auto-resolved from npm latest at build).
2026-06-23 12:49:13 +02:00
pi c346a106a3 release: v1.1.7 — pi 0.79.8 → 0.79.9; ssh-lan.conf LAN-peer docs
Publish Docker Image / base-decide (push) Successful in 1m0s
Publish Docker Image / resolve-versions (push) Successful in 6s
Publish Docker Image / build-base (push) Successful in 33m15s
Publish Docker Image / smoke (push) Successful in 3m32s
Publish Docker Image / smoke-studio (push) Successful in 3m47s
Publish Docker Image / build-variant-studio (push) Successful in 17m39s
Publish Docker Image / build-variant (push) Successful in 19m13s
Publish Docker Image / promote-base-latest (push) Successful in 9s
Publish Docker Image / update-description (push) Successful in 13s
2026-06-21 23:36:43 +02:00
joakimp 8de0fad776 docs(lan): document ssh-lan.conf for naming LAN peers
The host-owned, bind-mounted ~/.config/devbox-shell/ssh-lan.conf is the
intended place to add `ProxyJump host` overrides for named LAN peers (so
`pi --ssh <peer>` / `dssh <peer>` route through the host), but it was only
documented in .env.example and the setup-lan-access.sh header — never in the
README, where someone hitting "can't reach LAN peers" actually looks.

- README: add a "Naming LAN peers" subsection under the macOS LAN-peers
  troubleshooting block, with a ProxyJump example and the read-only ~/.ssh
  caveat; add a pointer to it from the SSH and ControlMaster section.
- setup-lan-access.sh: correct the INCLUDE_BLOCK comment that suggested adding
  ProxyJump to the read-only ~/.ssh/config; point at ssh-lan.conf instead.
- CHANGELOG: note under Unreleased.

Docs/comment only — no behavior change.
2026-06-21 00:23:29 +02:00
pi ed49b8d97a fix(ci): resolve-versions needs shell: bash for 'set -o pipefail'
Publish Docker Image / smoke (push) Successful in 9m0s
Publish Docker Image / build-variant-studio (push) Successful in 17m41s
Publish Docker Image / build-variant (push) Successful in 19m1s
Publish Docker Image / update-description (push) Successful in 7s
Publish Docker Image / promote-base-latest (push) Successful in 10s
Publish Docker Image / resolve-versions (push) Successful in 6s
Publish Docker Image / base-decide (push) Successful in 11s
Publish Docker Image / build-base (push) Successful in 45m54s
Publish Docker Image / smoke-studio (push) Successful in 3m43s
The default run shell is 'sh -e {0}' (dash on the act runner), which
rejects 'set -o pipefail' ('Illegal option -o pipefail') — failing the
resolve-versions job on line 2 and cascading every dependent job to
skipped (v1.1.6 run 401). The heavy build steps already declare
'shell: bash'; the resolve step did not. Added it.
2026-06-19 18:26:04 +02:00
pi 9eff3f3c48 release: v1.1.6 — build provenance + reproducibility hardening; pi 0.79.7 → 0.79.8
Publish Docker Image / resolve-versions (push) Failing after 52s
Publish Docker Image / base-decide (push) Has been skipped
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke-studio (push) Has been skipped
Publish Docker Image / build-variant (push) Has been skipped
Publish Docker Image / build-variant-studio (push) Has been skipped
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / smoke (push) Has been skipped
Publish Docker Image / update-description (push) Has been skipped
Adds OCI labels + /etc/pi-devbox/build-manifest.json so a published tag is
self-describing and reconstructable after CI logs rotate (manifest is
written from the actual checked-out HEAD of each /opt clone + live
pi --version, not just the intended build-args).

Hardens the build plumbing:
- scripts/check-base-hash.sh guards the base-rebuild invariant: every
  floating ARG *_REF in Dockerfile.base must be folded into the base_tag
  hash, else a ref-only change silently fails to rebuild the base
  (v1.1.2-class staleness footgun). Runs in base-decide and locally.
- resolve-versions now fails loud instead of falling back to a floating
  main/master on a transient API failure — validates each ref is a 40-hex
  SHA (and pi a real semver) and aborts the release otherwise.
- The three gitea companions (pi-toolkit, pi-extensions, mempalace-toolkit)
  gained overridable *_REPO build-args (defaulting to the canonical gitea
  origin) so a relocated/forked build can repoint them without editing the
  Dockerfiles — matching the existing PI_FORK_REPO/PI_OBSMEM_REPO pattern.

README documents the forked/relocated build-arg trick and how to read the
labels + manifest. smoke-test asserts the manifest + labels. pi bumps
0.79.7 → 0.79.8 (auto-resolved at build).
2026-06-19 18:23:11 +02:00
Joakim Persson a0abacaafb fix(ssh): survive read-only ~/.ssh ControlPath; render sidecar on all host OSes
Publish Docker Image / smoke (push) Successful in 3m22s
Publish Docker Image / smoke-studio (push) Successful in 3m42s
Publish Docker Image / build-variant (push) Successful in 15m29s
Publish Docker Image / update-description (push) Successful in 11s
Publish Docker Image / build-variant-studio (push) Successful in 16m49s
Publish Docker Image / promote-base-latest (push) Successful in 14s
Publish Docker Image / resolve-versions (push) Successful in 8s
Publish Docker Image / base-decide (push) Successful in 8s
Publish Docker Image / build-base (push) Successful in 33m44s
Coordinated with the pi-extensions ssh-controlmaster fix (picked up at build via
PI_EXTENSIONS_REF=main), this makes `pi --ssh <host>` and `dssh`/`dscp` robust
to a user ~/.ssh/config whose per-host ControlPath points under the read-only
~/.ssh bind-mount (e.g. `ControlPath ~/.ssh/cm/%r@%h:%p`). A system default can
never override a user's per-host value, so the fix lives in two layers.

- setup-lan-access.sh: always render the writable ~/.ssh-local/config sidecar
  (Host * ControlPath redirect into ~/.ssh-local/cm + Include ~/.ssh/config) on
  EVERY host OS. Previously the script exited early (no-op) on native Linux,
  leaving dssh/dscp broken when ~/.ssh was read-only there too. The host-jump
  block, its key generation, and the authorize hints stay gated on VM-backed
  detection / DEVBOX_LAN_ACCESS=jump (new NEED_JUMP flag).
- Dockerfile.base: document that the /etc/ssh drop-in default cannot override a
  user per-host ControlPath; cross-ref the two handling layers.
- entrypoint-user.sh: correct the now-stale "no-op on native Linux" comment.
- README.md / DOCKER_HUB.md: document read-only-~/.ssh ControlPath handling.

CHANGELOG: v1.1.5 (Fixed + Changed + pi 0.79.6 -> 0.79.7 auto-resolved bump).
2026-06-18 21:59:18 +02:00
Joakim Persson da7d70825e docs(changelog): add v1.1.4 entry (AGENTS.md autoload, settings merge, history fix)
Publish Docker Image / resolve-versions (push) Successful in 6s
Publish Docker Image / base-decide (push) Successful in 11s
Publish Docker Image / build-base (push) Successful in 42m4s
Publish Docker Image / smoke-studio (push) Successful in 3m39s
Publish Docker Image / smoke (push) Successful in 5m20s
Publish Docker Image / build-variant (push) Successful in 18m8s
Publish Docker Image / promote-base-latest (push) Successful in 9s
Publish Docker Image / update-description (push) Successful in 10s
Publish Docker Image / build-variant-studio (push) Successful in 24m41s
2026-06-17 20:52:05 +02:00
Joakim Persson 41c2c2b716 feat(entrypoint): non-destructively merge new template keys into settings.json
The settings.json bootstrap only fires when the file is ABSENT, so a
settings.json on a preserved named volume never picks up config added in a
later image (e.g. the observational-memory / pi-fork blocks, a newly-enabled
model). Users had to hand-merge after every upgrade.

On start, when settings.json already exists, deep-merge the template into it
with 'jq -s ".[0] * .[1]"' (template first, live second) so the user's values
always win and only MISSING keys are filled from the template. Arrays are
leaves (a model the user removed is not re-added). Rewrites only when the
merge changes something, backs up the original first, and skips safely (no
clobber) if either file is invalid JSON. Opt out with PI_SETTINGS_MERGE=0.

Add a recreate-sanity-check assertion that settings.json carries the
observational-memory + pi-fork blocks after recreate.
2026-06-17 20:49:41 +02:00
Joakim Persson 5c08bfc8a8 fix(shell): don't export DEVBOX_HIST_SET so nested shells flush history
The history-flush guard was exported, so it leaked into child processes.
Any nested shell -- crucially each tmux pane (which inherits the tmux
server's env) -- then saw the guard already set and skipped installing
'history -a' in PROMPT_COMMAND. Those shells only persisted history on a
clean exit, so abrupt termination (docker stop, tmux kill-server, SIGKILL)
silently lost their in-memory history. zoxide was less affected (its hook
is installed unguarded and writes immediately).

Make the guard shell-local (drop 'export') so every new interactive shell
re-installs its own per-prompt flush. Add a recreate-sanity-check assertion
that a nested login shell still wires up 'history -a'.

Storage was never the issue: ~/.cache/bash (devbox-shell-history) and
~/.local/share/zoxide (devbox-zoxide) are both persistent named volumes.
2026-06-17 17:22:30 +02:00
Joakim Persson 1371584634 sanity-check: verify global AGENTS.md symlink after recreate
pi-toolkit now symlinks pi-global-AGENTS.md -> ~/.pi/agent/AGENTS.md (pi's
global-instructions file, loaded at every start; directs the agent to read
the pi-extensions skill at session start). Add a recreate-sanity-check
assertion alongside the keybindings symlink check so a future image build
that bakes the new pi-toolkit verifies the wiring landed.
2026-06-17 16:58:18 +02:00
Joakim Persson d902b2d056 v1.1.3: add actual pi 0.79.5 release notes + document GitHub releases URL in AGENTS.md 2026-06-16 23:56:52 +02:00
Joakim Persson c48abf41d1 v1.1.3: pi 0.79.4 → 0.79.5
Publish Docker Image / resolve-versions (push) Successful in 6s
Publish Docker Image / base-decide (push) Successful in 11s
Publish Docker Image / build-base (push) Has been skipped
Publish Docker Image / smoke (push) Successful in 3m6s
Publish Docker Image / smoke-studio (push) Successful in 9m55s
Publish Docker Image / build-variant (push) Successful in 15m18s
Publish Docker Image / promote-base-latest (push) Has been skipped
Publish Docker Image / update-description (push) Successful in 11s
Publish Docker Image / build-variant-studio (push) Successful in 25m41s
2026-06-16 23:54:05 +02:00
pi 777d53354f docs(AGENTS): document GITEA_ACCESS_TOKEN env for general Gitea API access
GITEA_ACCESS_TOKEN + GITEA_HOST (passed from host .env via compose,
primarily for gitea-mcp) are also usable for any direct Gitea API work —
run inspection, tag checks — not just ci-release-watcher. Prefer over a
PAT file when present; host-managed lifecycle, nothing to revoke. Release
checklist step 7 now notes the env-token alternative.
2026-06-15 22:30:36 +02:00
15 changed files with 1104 additions and 102 deletions
+73 -33
View File
@@ -58,6 +58,9 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 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 - name: Compute base tag from Dockerfile.base + dependencies
id: compute id: compute
run: | run: |
@@ -126,53 +129,72 @@ jobs:
steps: steps:
- name: Resolve pi version + companion refs - name: Resolve pi version + companion refs
id: resolve id: resolve
shell: bash
run: | run: |
set -eu set -euo pipefail
# Query npm registry directly; catthehacker/ubuntu:act-latest's npm AUTH_HEADER="Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}"
# 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') # 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" 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" \ 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" \ OBSMEM_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
"https://api.github.com/repos/elpapi42/pi-observational-memory/commits/master" || echo "master") "https://api.github.com/repos/elpapi42/pi-observational-memory/commits/master" || true)
[ -n "$FORK_REF" ] || FORK_REF=master require_sha PI_OBSMEM_REF "$OBSMEM_REF"
[ -n "$OBSMEM_REF" ] || OBSMEM_REF=master
echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT" echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT"
echo "obsmem_ref=${OBSMEM_REF}" >> "$GITHUB_OUTPUT" echo "obsmem_ref=${OBSMEM_REF}" >> "$GITHUB_OUTPUT"
# Also resolve pi-toolkit / pi-extensions main HEADs to SHAs so a
# workflow_dispatch re-run produces byte-identical images when # pi-toolkit / pi-extensions (Gitea) → commit SHAs. Gitea API
# those repos haven't moved (and a clean diff in build-arg strings # requires auth even for public-repo commit listing.
# when they have, defeating the registry buildcache footgun). TOOLKIT_REF=$(curl -sf -H "$AUTH_HEADER" \
# Gitea API requires auth even for public-repo commit listing.
TOOLKIT_REF=$(curl -sf -H "Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}" \
"https://gitea.jordbo.se/api/v1/repos/joakimp/pi-toolkit/commits?limit=1&sha=main" \ "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") | jq -r '.[0].sha // empty' 2>/dev/null || true)
EXTENSIONS_REF=$(curl -sf -H "Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}" \ 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" \ "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") | jq -r '.[0].sha // empty' 2>/dev/null || true)
[ -n "$TOOLKIT_REF" ] || TOOLKIT_REF=main require_sha PI_EXTENSIONS_REF "$EXTENSIONS_REF"
[ -n "$EXTENSIONS_REF" ] || EXTENSIONS_REF=main
echo "toolkit_ref=${TOOLKIT_REF}" >> "$GITHUB_OUTPUT" echo "toolkit_ref=${TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
echo "extensions_ref=${EXTENSIONS_REF}" >> "$GITHUB_OUTPUT" echo "extensions_ref=${EXTENSIONS_REF}" >> "$GITHUB_OUTPUT"
# Resolve mempalace-toolkit main HEAD to a SHA. UNLIKE the others,
# mempalace-toolkit is cloned in Dockerfile.base, so this SHA is # mempalace-toolkit (Gitea) → commit SHA. UNLIKE the others this
# ALSO folded into the base-decide hash to force a base rebuild # is cloned in Dockerfile.base, so the SAME SHA is ALSO folded
# when the toolkit moves (without it, a toolkit-only fix silently # into the base-decide hash (see that job) to force a base rebuild
# fails to land unless Dockerfile.base itself changes). # when the toolkit moves — otherwise a toolkit-only fix silently
MEMPALACE_TOOLKIT_REF=$(curl -sf -H "Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}" \ # 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" \ "https://gitea.jordbo.se/api/v1/repos/joakimp/mempalace-toolkit/commits?limit=1&sha=main" \
| jq -r '.[0].sha // "main"' 2>/dev/null || echo "main") | jq -r '.[0].sha // empty' 2>/dev/null || true)
[ -n "$MEMPALACE_TOOLKIT_REF" ] || MEMPALACE_TOOLKIT_REF=main require_sha MEMPALACE_TOOLKIT_REF "$MEMPALACE_TOOLKIT_REF"
echo "mempalace_toolkit_ref=${MEMPALACE_TOOLKIT_REF}" >> "$GITHUB_OUTPUT" echo "mempalace_toolkit_ref=${MEMPALACE_TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
# Resolve pi-studio (omaclaren/pi-studio) main HEAD to a SHA for
# the :latest-studio variant — same cache-busting rationale. # pi-studio (omaclaren/pi-studio) → commit SHA for :latest-studio.
STUDIO_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \ STUDIO_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
"https://api.github.com/repos/omaclaren/pi-studio/commits/main" || echo "main") "https://api.github.com/repos/omaclaren/pi-studio/commits/main" || true)
[ -n "$STUDIO_REF" ] || STUDIO_REF=main require_sha PI_STUDIO_REF "$STUDIO_REF"
echo "studio_ref=${STUDIO_REF}" >> "$GITHUB_OUTPUT" echo "studio_ref=${STUDIO_REF}" >> "$GITHUB_OUTPUT"
echo "Resolved PI_VERSION=${PI_VERSION}" echo "Resolved PI_VERSION=${PI_VERSION}"
echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}" echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}"
echo "Resolved PI_TOOLKIT_REF=${TOOLKIT_REF}, PI_EXTENSIONS_REF=${EXTENSIONS_REF}" echo "Resolved PI_TOOLKIT_REF=${TOOLKIT_REF}, PI_EXTENSIONS_REF=${EXTENSIONS_REF}"
@@ -299,6 +321,9 @@ jobs:
PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }} PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }}
PI_TOOLKIT_REF=${{ needs.resolve-versions.outputs.toolkit_ref }} PI_TOOLKIT_REF=${{ needs.resolve-versions.outputs.toolkit_ref }}
PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }} PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }}
MEMPALACE_TOOLKIT_REF=${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
RELEASE_TAG=smoke
SOURCE_REVISION=${{ github.sha }}
- name: Smoke test (amd64) - name: Smoke test (amd64)
env: env:
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }} EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
@@ -355,6 +380,9 @@ jobs:
PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }} PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }}
INSTALL_STUDIO=true INSTALL_STUDIO=true
PI_STUDIO_REF=${{ needs.resolve-versions.outputs.studio_ref }} 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) - name: Smoke test studio (amd64)
env: env:
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }} EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
@@ -406,10 +434,12 @@ jobs:
OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }} OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }}
TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }} TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }}
EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }} EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }}
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
run: | run: |
set -euo pipefail set -euo pipefail
TAG_FLAGS=() TAG_FLAGS=()
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}" 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). # 3-attempt retry (see build-base step for rationale).
for attempt in 1 2 3; do for attempt in 1 2 3; do
echo "==> Build+push attempt ${attempt}/3" echo "==> Build+push attempt ${attempt}/3"
@@ -423,6 +453,10 @@ jobs:
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \ --build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
--build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \ --build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \ --build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
--build-arg "RELEASE_TAG=${RELEASE_TAG}" \
--build-arg "BUILD_DATE=${BUILD_DATE}" \
--build-arg "SOURCE_REVISION=${GITHUB_SHA:-}" \
"${TAG_FLAGS[@]}" \ "${TAG_FLAGS[@]}" \
.; then .; then
echo "==> Attempt ${attempt} succeeded" echo "==> Attempt ${attempt} succeeded"
@@ -487,10 +521,12 @@ jobs:
TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }} TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }}
EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }} EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }}
STUDIO_REF: ${{ needs.resolve-versions.outputs.studio_ref }} STUDIO_REF: ${{ needs.resolve-versions.outputs.studio_ref }}
MEMPALACE_TOOLKIT_REF: ${{ needs.resolve-versions.outputs.mempalace_toolkit_ref }}
run: | run: |
set -euo pipefail set -euo pipefail
TAG_FLAGS=() TAG_FLAGS=()
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}" 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). # 3-attempt retry (see build-base step for rationale).
for attempt in 1 2 3; do for attempt in 1 2 3; do
echo "==> Build+push attempt ${attempt}/3" echo "==> Build+push attempt ${attempt}/3"
@@ -504,8 +540,12 @@ jobs:
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \ --build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
--build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \ --build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \ --build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
--build-arg "MEMPALACE_TOOLKIT_REF=${MEMPALACE_TOOLKIT_REF}" \
--build-arg "INSTALL_STUDIO=true" \ --build-arg "INSTALL_STUDIO=true" \
--build-arg "PI_STUDIO_REF=${STUDIO_REF}" \ --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[@]}" \ "${TAG_FLAGS[@]}" \
.; then .; then
echo "==> Attempt ${attempt} succeeded" echo "==> Attempt ${attempt} succeeded"
+34 -6
View File
@@ -14,15 +14,23 @@ re-brand of opencode-devbox's `pi-only` variant.
- `Dockerfile.variant``FROM base-<hash>`, adds pi + companions - `Dockerfile.variant``FROM base-<hash>`, adds pi + companions
(`pi-toolkit`, `pi-extensions`, `pi-fork`, `pi-observational-memory`) (`pi-toolkit`, `pi-extensions`, `pi-fork`, `pi-observational-memory`)
and, when `INSTALL_STUDIO=true`, vendors `pi-studio` to `/opt/pi-studio` and, when `INSTALL_STUDIO=true`, vendors `pi-studio` to `/opt/pi-studio`
(`-studio` variant). (`-studio` variant). Also appends the pi-devbox managed block from
`pi-global-AGENTS.append.md` onto pi-toolkit's `pi-global-AGENTS.md` (the
single global instruction slot pi loads) so containers proactively load the
baked `pi-devbox-environment` skill. Idempotent via a marker grep.
- `entrypoint.sh` — UID/GID alignment as root, then drops to `developer`. - `entrypoint.sh` — UID/GID alignment as root, then drops to `developer`.
- `entrypoint-user.sh` — per-container start: SSH ControlMaster socket - `entrypoint-user.sh` — per-container start: SSH ControlMaster socket
dir, LAN-access setup, MemPalace init, pi-toolkit + pi-extensions dir, LAN-access setup, MemPalace init, pi-toolkit + pi-extensions
deploy, mempalace-bridge symlink, fork/recall + pi-studio pi-install, deploy, mempalace-bridge symlink, fork/recall + pi-studio pi-install,
optional `studio-expose` bridge (when `STUDIO_EXPOSE=1`), skillset optional `studio-expose` bridge (when `STUDIO_EXPOSE=1`), image-baked
deploy. skills symlink-in, skillset deploy.
- `rootfs/` — files baked into the image (bash aliases, inputrc, - `rootfs/` — files baked into the image (bash aliases, inputrc,
setup-lan-access.sh, `studio-expose` helper). setup-lan-access.sh, `studio-expose` helper). Also
`usr/local/share/pi-devbox/skills/<name>/SKILL.md` — image-baked agent
skills (e.g. `pi-devbox-environment`) symlinked into `~/.agents/skills/` by
the entrypoint, available with or without a mounted skillset — plus
`usr/local/share/pi-devbox/pi-global-AGENTS.append.md` (the global-AGENTS
pointer concatenated in `Dockerfile.variant`).
- `scripts/smoke-test.sh` — sanity checks run by CI before pushing to Hub. - `scripts/smoke-test.sh` — sanity checks run by CI before pushing to Hub.
- `.gitea/workflows/docker-publish.yml` — two-phase CI (base-decide → - `.gitea/workflows/docker-publish.yml` — two-phase CI (base-decide →
build-base → smoke → build-variant → promote-base-latest → build-base → smoke → build-variant → promote-base-latest →
@@ -33,7 +41,8 @@ re-brand of opencode-devbox's `pi-only` variant.
## Versioning scheme ## Versioning scheme
- Tags follow semver. **v1.0.0** is the first decoupled release; future - Tags follow semver. **v1.0.0** is the first decoupled release; future
minor bumps add variants (`-studio`, `-studio-tex`); patch bumps follow minor bumps add variants (`-studio`, `-studio-tex`) or significant base
additions (e.g. v1.2.0 image-baked agent skills); patch bumps follow
pi npm version updates and small fixes. pi npm version updates and small fixes.
- Docker Hub tags: `joakimp/pi-devbox:vX.Y.Z` + `joakimp/pi-devbox:latest` - Docker Hub tags: `joakimp/pi-devbox:vX.Y.Z` + `joakimp/pi-devbox:latest`
+ (since v1.1.0) `joakimp/pi-devbox:vX.Y.Z-studio` + + (since v1.1.0) `joakimp/pi-devbox:vX.Y.Z-studio` +
@@ -45,6 +54,8 @@ re-brand of opencode-devbox's `pi-only` variant.
1. Confirm `pi --version` resolves from npm to the expected version 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`). (`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. 2. Update `CHANGELOG.md` Unreleased → vX.Y.Z section.
3. Verify `docker compose up` works locally with the current `latest` image 3. Verify `docker compose up` works locally with the current `latest` image
if you're upgrading users from a previous version. Then run the if you're upgrading users from a previous version. Then run the
@@ -61,7 +72,24 @@ re-brand of opencode-devbox's `pi-only` variant.
6. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus 6. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus
base-latest if the base was rebuilt this run). base-latest if the base was rebuilt this run).
7. **Revoke any short-lived Gitea PAT** used during the release at 7. **Revoke any short-lived Gitea PAT** used during the release at
`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) ## Cache-hit footgun (must-know)
+275 -2
View File
@@ -11,7 +11,280 @@ Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
--- ---
## Unreleased ## v1.2.0 — 2026-06-22
Minor release: **image-baked agent skills** — a new base mechanism that ships
skills inside the image (independent of any mounted skillset repo) — plus the
first such skill, `pi-devbox-environment`, and pi `0.79.9``0.79.10`
(auto-resolved from npm `latest` at build).
### Added
- **Image-baked agent skills.** Skills under
`/usr/local/share/pi-devbox/skills/<name>/` are now symlinked into
`~/.agents/skills/` by `entrypoint-user.sh` on every start, making them
available **with or without** a mounted `skillset` repo. The symlink points
at the image path (so it survives volume recreate, unlike anything baked
under a home dir a named volume would shadow) and is created only when
absent, so a same-named skillset skill or user override is never clobbered.
The skillset deploy classifies these as foreign-links and its `--prune-stale`
pass leaves them untouched.
- **`pi-devbox-environment` skill** (the first image-baked skill). Teaches
agents the container-shaped facts that are easy to get wrong: the
persistence/ephemerality tier model (what survives `down -v` / image
update), host + LAN SSH reachability and ControlMaster, split-horizon DNS
*mechanisms*, the interactive-vs-tool-shell alias gotcha (`dssh`/`dscp`/
`cat``bat` don't exist in the non-interactive bash tool), the tmux 0-index
constraint, uv-first Python, and pi-studio reachability. Deliberately
environment-agnostic — host OS, hostnames, internal domains, and nameservers
are discovered at runtime, never hardcoded.
- **Proactive skill awareness via the global `AGENTS.md`.** `Dockerfile.variant`
appends a short, gated pointer (`pi-global-AGENTS.append.md`) onto
pi-toolkit's `pi-global-AGENTS.md` — the single global instruction slot pi
loads at startup — so containers load the `pi-devbox-environment` skill
proactively rather than only on description match. The pointer fires only
inside a pi-devbox container (checks for `/usr/local/lib/pi-devbox/`).
Build-time append is idempotent via a marker grep; runtime is unaffected
(the file is root-owned and re-symlinked by pi-toolkit each boot).
- **Smoke-test coverage** for the new mechanism: build-time presence of the
baked skill + append snippet + the merged marker in `pi-global-AGENTS.md`,
and a runtime assertion that `~/.agents/skills/pi-devbox-environment` is
linked after the entrypoint runs.
### Bumped: pi 0.79.9 → 0.79.10
Resolved from npm `latest` at build (v1.1.7 shipped `0.79.9`). See the
[pi changelog](https://github.com/earendil-works/pi/blob/main/CHANGELOG.md)
for the upstream `0.79.10` notes.
## v1.1.7 — 2026-06-21
Patch release: pi `0.79.8``0.79.9` (auto-resolved at build), plus the
`ssh-lan.conf` LAN-peer documentation that landed on `main` after v1.1.6.
Companion refs are auto-resolved to SHAs at build as before.
### Bumped: pi 0.79.8 → 0.79.9
Notable upstream changes (from [pi releases](https://github.com/earendil-works/pi/releases/tag/v0.79.9)):
- **Chat-template thinking compatibility** — OpenAI-compatible custom
providers can map pi thinking levels into `chat_template_kwargs`, enabling
vLLM/Hugging Face chat-template models (e.g. DeepSeek) to use
provider-native thinking controls.
- **GLM-5.2 provider improvements** — corrected Fireworks OpenAI-compatible
routing and OpenRouter `xhigh` thinking support, improving `/model`
behaviour and high-effort reasoning for GLM-5.2.
- **Fixes** — same-directory session switches now reuse imported extension
modules (fresh instances + lifecycle events preserved); deep session
branches no longer take quadratic time to build context; Markdown
streaming code-fence rendering no longer flickers on partial closing
fences; fuzzy `edit` matches preserve untouched line blocks instead of
rewriting the whole file; `/model` hides Copilot models unavailable to the
account and ranks exact provider-prefixed matches first.
### Docs: document `~/.config/devbox-shell/ssh-lan.conf` for naming LAN peers
The host-owned, bind-mounted `~/.config/devbox-shell/ssh-lan.conf` is the
intended place to add `ProxyJump host` overrides for **named** LAN peers (so
`pi --ssh <peer>` / `dssh <peer>` route through the host), but it was only
mentioned in `.env.example` and the `setup-lan-access.sh` header — never in the
README. Added a "Naming LAN peers" subsection to the README troubleshooting
block (plus a pointer from the SSH/ControlMaster section), and corrected the
stale `setup-lan-access.sh` comment that suggested editing the read-only
`~/.ssh/config` instead of `ssh-lan.conf`.
## 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 ## v1.1.2 — 2026-06-15
@@ -278,7 +551,7 @@ dependencies.
### Future work ### Future work
- v1.1.0: `:latest-studio` variant (adds [pi-studio](https://github.com/omaclaren/pi-studio)). - v1.1.0: `:latest-studio` variant (adds [pi-studio](https://github.com/omaclaren/pi-studio)).
- v1.2.0: `:latest-studio-tex` variant (adds texlive-xetex for PDF export). - v1.3.0: `:latest-studio-tex` variant (adds texlive-xetex for PDF export).
## v0.79.0 — 2026-06-08 ## v0.79.0 — 2026-06-08
+2 -1
View File
@@ -51,6 +51,7 @@ Full setup guide — authentication for each provider (Anthropic, OpenAI, Gemini
- **[pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions)** — 7 user-facing extensions: `ext-toggle`, `mcp-loader`, `todo`, `ssh-controlmaster`, `notify`, `git-checkpoint`, `confirm-destructive` - **[pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions)** — 7 user-facing extensions: `ext-toggle`, `mcp-loader`, `todo`, `ssh-controlmaster`, `notify`, `git-checkpoint`, `confirm-destructive`
- **`fork`** ([pi-fork](https://github.com/elpapi42/pi-fork)) and **`recall`** ([pi-observational-memory](https://github.com/elpapi42/pi-observational-memory)) tools - **`fork`** ([pi-fork](https://github.com/elpapi42/pi-fork)) and **`recall`** ([pi-observational-memory](https://github.com/elpapi42/pi-observational-memory)) tools
- **mempalace bridge** — MCP extension auto-symlinked so pi reads/writes the host-mounted palace - **mempalace bridge** — MCP extension auto-symlinked so pi reads/writes the host-mounted palace
- **image-baked agent skills** — skills under `/usr/local/share/pi-devbox/skills/` (e.g. `pi-devbox-environment`, which teaches agents the container's persistence/networking/DNS/tmux/REPL specifics) are symlinked into `~/.agents/skills/` on start, available with or without a mounted skillset repo
The entrypoint deploys/registers all of these on first container start. Re-running is idempotent and preserves user edits. The entrypoint deploys/registers all of these on first container start. Re-running is idempotent and preserves user edits.
@@ -97,7 +98,7 @@ The entrypoint deploys/registers all of these on first container start. Re-runni
### SSH and networking ### 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`). - 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 ## Versioning
+20 -1
View File
@@ -130,6 +130,15 @@ RUN printf '%s\n' \
# `Include /etc/ssh/ssh_config.d/*.conf` *before* the `Host *` block, # `Include /etc/ssh/ssh_config.d/*.conf` *before* the `Host *` block,
# so user config can override these defaults if desired. # 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 # ControlPersist=10m means the master socket sticks around 10 min after
# the last session closes, so consecutive ssh calls in a workflow reuse # the last session closes, so consecutive ssh calls in a workflow reuse
# the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm # the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm
@@ -342,6 +351,11 @@ RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
# ── mempalace-toolkit — bash wrappers for session/docs mining ──────── # ── mempalace-toolkit — bash wrappers for session/docs mining ────────
ARG INSTALL_MEMPALACE_TOOLKIT=true ARG INSTALL_MEMPALACE_TOOLKIT=true
ARG MEMPALACE_TOOLKIT_REF=main 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 # 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 # 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 # base-decide hash so the base rebuilds when the toolkit moves. `git clone
@@ -351,7 +365,7 @@ ARG MEMPALACE_TOOLKIT_REF=main
RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \ RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \
rm -rf /opt/mempalace-toolkit && mkdir -p /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 init -q && \
git -C /opt/mempalace-toolkit remote add origin https://gitea.jordbo.se/joakimp/mempalace-toolkit.git && \ git -C /opt/mempalace-toolkit remote add origin "${MEMPALACE_TOOLKIT_REPO}" && \
ok=0; for i in 1 2 3 4 5; do \ ok=0; for i in 1 2 3 4 5; do \
if git -C /opt/mempalace-toolkit fetch --depth 1 origin "${MEMPALACE_TOOLKIT_REF}" && \ 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; \ git -C /opt/mempalace-toolkit checkout -q FETCH_HEAD; then ok=1; break; fi; \
@@ -467,6 +481,11 @@ COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
# ── Entrypoint ──────────────────────────────────────────────────────── # ── Entrypoint ────────────────────────────────────────────────────────
COPY rootfs/usr/local/lib/pi-devbox/ /usr/local/lib/pi-devbox/ COPY rootfs/usr/local/lib/pi-devbox/ /usr/local/lib/pi-devbox/
# Image-baked skills + the global-AGENTS append snippet. Under /usr/local so a
# named volume over a home dir can't shadow them; linked into ~/.agents/skills
# by entrypoint-user.sh, and the snippet is concatenated onto the global
# AGENTS.md in Dockerfile.variant (after pi-toolkit, which owns that file).
COPY rootfs/usr/local/share/pi-devbox/ /usr/local/share/pi-devbox/
COPY rootfs/usr/local/bin/studio-expose /usr/local/bin/studio-expose COPY rootfs/usr/local/bin/studio-expose /usr/local/bin/studio-expose
COPY rootfs/usr/local/bin/dot-watch /usr/local/bin/dot-watch COPY rootfs/usr/local/bin/dot-watch /usr/local/bin/dot-watch
COPY entrypoint.sh /usr/local/bin/entrypoint.sh COPY entrypoint.sh /usr/local/bin/entrypoint.sh
+82 -2
View File
@@ -41,6 +41,12 @@ ARG USER_NAME=developer
ARG PI_VERSION=latest ARG PI_VERSION=latest
ARG PI_TOOLKIT_REF=main ARG PI_TOOLKIT_REF=main
ARG PI_EXTENSIONS_REF=main ARG PI_EXTENSIONS_REF=main
# Repo URLs default to the canonical gitea origin but are overridable so a
# 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 # pi-fork (fork tool) + pi-observational-memory (recall tool) live on GitHub
# under elpapi42. CI resolves these to commit SHAs to defeat the same # under elpapi42. CI resolves these to commit SHAs to defeat the same
# cache-hit footgun that affects PI_VERSION. # 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} ; \ NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
fi && \ fi && \
pi --version && \ pi --version && \
git_fetch_ref "https://gitea.jordbo.se/joakimp/pi-toolkit.git" "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \ git_fetch_ref "${PI_TOOLKIT_REPO}" "${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_EXTENSIONS_REPO}" "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
git_fetch_ref "${PI_FORK_REPO}" "${PI_FORK_REF}" /opt/pi-fork && \ git_fetch_ref "${PI_FORK_REPO}" "${PI_FORK_REF}" /opt/pi-fork && \
git_fetch_ref "${PI_OBSMEM_REPO}" "${PI_OBSMEM_REF}" /opt/pi-observational-memory && \ git_fetch_ref "${PI_OBSMEM_REPO}" "${PI_OBSMEM_REF}" /opt/pi-observational-memory && \
(cd /opt/pi-fork && npm install --omit=dev --no-audit --no-fund) && \ (cd /opt/pi-fork && npm install --omit=dev --no-audit --no-fund) && \
@@ -88,6 +94,24 @@ RUN set -e && \
echo "pi-fork at $(cd /opt/pi-fork && git rev-parse --short HEAD)" && \ echo "pi-fork at $(cd /opt/pi-fork && git rev-parse --short HEAD)" && \
echo "pi-observational-memory at $(cd /opt/pi-observational-memory && git rev-parse --short HEAD)" echo "pi-observational-memory at $(cd /opt/pi-observational-memory && git rev-parse --short HEAD)"
# ── pi-devbox awareness: append our pointer to the global AGENTS.md ──
# pi loads a SINGLE global instruction file (~/.pi/agent/AGENTS.md), which
# pi-toolkit's install.sh re-symlinks to /opt/pi-toolkit/pi-global-AGENTS.md on
# every container start. There is no second global slot, and that file is
# root-owned (not writable by the runtime user), so we compose at BUILD time:
# append the pi-devbox managed block to pi-toolkit's file here, after the clone.
# Idempotent via a marker grep so a rebuilt layer never double-appends. This
# makes every container proactively aware of the pi-devbox-environment skill;
# the snippet itself is gated (only fires when /usr/local/lib/pi-devbox exists).
RUN if [ -f /opt/pi-toolkit/pi-global-AGENTS.md ] && \
! grep -q 'pi-devbox:managed-block' /opt/pi-toolkit/pi-global-AGENTS.md; then \
printf '\n' >> /opt/pi-toolkit/pi-global-AGENTS.md && \
cat /usr/local/share/pi-devbox/pi-global-AGENTS.append.md >> /opt/pi-toolkit/pi-global-AGENTS.md && \
echo "appended pi-devbox block to pi-global-AGENTS.md" ; \
else \
echo "pi-devbox block already present or pi-global-AGENTS.md missing (skipped)" ; \
fi
# ── Optional: pi-studio (:latest-studio variant) ───────────────────── # ── Optional: pi-studio (:latest-studio variant) ─────────────────────
# pi-studio (omaclaren/pi-studio) is a pi-package + theme providing a # pi-studio (omaclaren/pi-studio) is a pi-package + theme providing a
# two-pane browser workspace: prompt/response editor, KaTeX/Mermaid live # two-pane browser workspace: prompt/response editor, KaTeX/Mermaid live
@@ -154,4 +178,60 @@ RUN if [ "${INSTALL_GO}" = "true" ]; then \
ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt; \ ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt; \
fi 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. # WORKDIR / ENTRYPOINT / CMD inherited from base.
+140
View File
@@ -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 - A LAN-access helper that auto-configures ssh jump-via-host on
VM-backed hosts (OrbStack / Docker Desktop on macOS) so the container VM-backed hosts (OrbStack / Docker Desktop on macOS) so the container
can reach the host's directly-attached LAN peers. 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 ## Quickstart
@@ -439,6 +442,38 @@ session/docs mining; the 29 MCP tools (search, kg-query, drawer-add,
diary-write, etc.) are wired into pi automatically by the pi-extensions diary-write, etc.) are wired into pi automatically by the pi-extensions
mempalace bridge. mempalace bridge.
## Agent skills
pi discovers skills under `~/.agents/skills/`. Two delivery paths feed that
directory, and they compose:
- **Image-baked skills (always present).** Skills shipped *inside* the image
live under `/usr/local/share/pi-devbox/skills/` and are symlinked into
`~/.agents/skills/` by `entrypoint-user.sh` on every start. They need no
external mount, survive volume recreate (the source is an image path, not a
home dir a named volume would shadow), and are created only when absent so a
same-named skillset skill or user override is never clobbered. The bundled
**`pi-devbox-environment`** skill is delivered this way — it teaches agents
the container's persistence model, host/LAN SSH reachability, split-DNS
mechanisms, the interactive-vs-tool-shell alias gotcha (`dssh`/`dscp`),
tmux 0-indexing, uv-first Python, and pi-studio reachability, all as
*mechanisms* (deployment-specific hostnames/domains/nameservers are
discovered at runtime, never hardcoded).
- **Skillset repo (optional).** If a `skillset` repo is mounted (at
`$HOME/skillset` or `/workspace/skillset`, or via `SKILLSET_CONTAINER_PATH`),
`deploy-skills.sh` symlinks its skills in too. Image-baked skills are
classified as foreign-links by its `--prune-stale` pass and left untouched.
To make agents *proactively* load a baked skill at session start (rather than
only on description match), the image appends a short, gated pointer to the
global `AGENTS.md` at build time (see `pi-global-AGENTS.append.md`). The
pointer fires only inside a pi-devbox container (it checks for
`/usr/local/lib/pi-devbox/`).
To add another image-baked skill: drop a `SKILL.md` under
`rootfs/usr/local/share/pi-devbox/skills/<name>/`; the `COPY` in
`Dockerfile.base` and the entrypoint symlink loop pick it up automatically.
## SSH and ControlMaster ## SSH and ControlMaster
The base image preconfigures `Host *` ssh defaults: The base image preconfigures `Host *` ssh defaults:
@@ -461,6 +496,27 @@ User-level overrides in `~/.ssh/config` win because Debian's
`/etc/ssh/ssh_config` includes `/etc/ssh/ssh_config.d/*.conf` before `/etc/ssh/ssh_config` includes `/etc/ssh/ssh_config.d/*.conf` before
the `Host *` block. the `Host *` block.
### 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). To name LAN peers that should
jump via the host, add `ProxyJump host` overrides in the host-owned
`~/.config/devbox-shell/ssh-lan.conf` (see
[Naming LAN peers](#naming-lan-peers)) rather than the read-only
`~/.ssh/config`.
## tmux and 0-indexed sessions ## tmux and 0-indexed sessions
The image installs `/etc/tmux.conf` with: The image installs `/etc/tmux.conf` with:
@@ -517,6 +573,68 @@ pi-coding-agent@latest` (the build-arg string would otherwise be
byte-identical across releases and the layer would silently reuse the byte-identical across releases and the layer would silently reuse the
previous version's bytes). 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 ## Troubleshooting
### Image grew unexpectedly ### Image grew unexpectedly
@@ -533,6 +651,28 @@ auto-runs on container start and writes `~/.ssh-local/config` with a
ssh-jump-via-host configuration. Set `DEVBOX_LAN_ACCESS=jump` and ssh-jump-via-host configuration. Set `DEVBOX_LAN_ACCESS=jump` and
`HOST_SSH_USER=<your-mac-user>` in `.env` if auto-detection fails. `HOST_SSH_USER=<your-mac-user>` in `.env` if auto-detection fails.
#### Naming LAN peers
`DEVBOX_LAN_ACCESS` / `HOST_SSH_USER` only set up the *jump* to the host. To
make a **named** peer route through it — so `pi --ssh alpserv-2`,
`dssh alpserv-2`, etc. resolve the ProxyJump — add a `ProxyJump host` override
for it in the host-owned, bind-mounted `~/.config/devbox-shell/ssh-lan.conf`
(**not** `~/.ssh/config`, which is mounted read-only):
```
Host pve pve-2 alpserv-2 lagret
ProxyJump host
```
`HostName` / `User` / `IdentityFile` are inherited from the matching block in
your real `~/.ssh/config` (first-value-wins, so only `ProxyJump` is taken from
here). This file is `Include`d *before* `~/.ssh/config` and read fresh on every
connection — newly added peers work immediately, no container or session
restart needed — and the peer names stay out of the published image (they're a
fact about your specific LAN, not the image). Alternatively, set
`DEVBOX_LAN_AUTOJUMP_PRIVATE=1` to ProxyJump *any* RFC1918 address through the
host without naming peers (see `.env.example`).
### Smoke-testing a local build ### Smoke-testing a local build
```bash ```bash
+65 -9
View File
@@ -12,12 +12,16 @@ set -euo pipefail
mkdir -p /tmp/sshcm mkdir -p /tmp/sshcm
chmod 700 /tmp/sshcm chmod 700 /tmp/sshcm
# ── LAN access: generic host-OS-agnostic reachability helper ──────── # ── LAN access + writable SSH sidecar: host-OS-agnostic helper ──────
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't # Generates the writable ~/.ssh-local/config on EVERY host OS: a `Host *`
# reach the host's directly-attached LAN peers by default; this generates a # ControlPath redirect into ~/.ssh-local/cm (so `ssh -F` / dssh / dscp work
# writable ~/.ssh-local/config that uses the host as an SSH jump. On native # even when ~/.ssh is bind-mounted read-only) plus `Include ~/.ssh/config`. On
# Linux (LAN reachable directly) it is a no-op. Controlled by DEVBOX_LAN_ACCESS # VM-backed hosts (macOS OrbStack / Docker Desktop) it ALSO adds an
# (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the script header. # 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 if [ -r /usr/local/lib/pi-devbox/setup-lan-access.sh ]; then
bash /usr/local/lib/pi-devbox/setup-lan-access.sh || true bash /usr/local/lib/pi-devbox/setup-lan-access.sh || true
fi fi
@@ -36,6 +40,32 @@ if [ -d "$SKEL_DIR" ]; then
done done
fi fi
# ── Image-baked skills: link into ~/.agents/skills ───────────────────
# Skills shipped IN the image (under /usr/local/share/pi-devbox/skills/) are
# made available regardless of whether a skillset repo is mounted. Done EARLY
# — before the pi-toolkit/extensions deploy below — so the symlinks exist by
# the time anything gates on "container ready": the smoke-test readiness probe
# waits on pi-deploy markers (keybindings.json, mempalace.ts) that only land
# AFTER this point, so linking here closes a sample-too-early race that failed
# the runtime skill-link assertion. Pointing at the image path (/usr/local/...)
# keeps the skill fresh from the image and surviving volume recreate (unlike
# anything baked under a home dir, which a named volume would shadow). Created
# only when absent, so a same-named skillset skill (deployed later, at the end
# of this script) or a user override is never clobbered; the skillset deploy
# classifies these as foreign-links and its --prune-stale pass leaves them
# alone (only dangling symlinks are pruned).
DEVBOX_SKILLS_SRC=/usr/local/share/pi-devbox/skills
if [ -d "$DEVBOX_SKILLS_SRC" ]; then
mkdir -p "$HOME/.agents/skills"
for _sk in "$DEVBOX_SKILLS_SRC"/*/; do
[ -d "$_sk" ] || continue
_skname=$(basename "$_sk")
if [ ! -e "$HOME/.agents/skills/$_skname" ]; then
ln -s "${_sk%/}" "$HOME/.agents/skills/$_skname"
fi
done
fi
# ── MemPalace: initialize palace for the workspace if mempalace is installed # ── MemPalace: initialize palace for the workspace if mempalace is installed
# Creates the palace directory structure on first run. Idempotent — skips # Creates the palace directory structure on first run. Idempotent — skips
# if palace already exists, so upgrades from older versions preserve # if palace already exists, so upgrades from older versions preserve
@@ -86,9 +116,35 @@ if command -v pi &>/dev/null; then
# Bootstrap settings.json from template if absent (pi rewrites this # Bootstrap settings.json from template if absent (pi rewrites this
# file at runtime — lastChangelogVersion, etc — so we can't symlink it). # file at runtime — lastChangelogVersion, etc — so we can't symlink it).
if [ ! -f "$HOME/.pi/agent/settings.json" ] && \ _pi_settings="$HOME/.pi/agent/settings.json"
[ -f /opt/pi-toolkit/settings.example.json ]; then _pi_template=/opt/pi-toolkit/settings.example.json
cp /opt/pi-toolkit/settings.example.json "$HOME/.pi/agent/settings.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 fi
# pi↔mempalace MCP bridge — single extension symlink. # pi↔mempalace MCP bridge — single extension symlink.
+8 -1
View File
@@ -89,9 +89,16 @@ fi
# we append with a newline separator to avoid the ';;' parse error # we append with a newline separator to avoid the ';;' parse error
# described at the top of this file. Guarded so repeated sourcing # described at the top of this file. Guarded so repeated sourcing
# (e.g. `exec bash`) doesn't stack duplicates. # (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 if [ -z "${DEVBOX_HIST_SET:-}" ]; then
PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND$'\n'}history -a" PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND$'\n'}history -a"
export DEVBOX_HIST_SET=1 DEVBOX_HIST_SET=1
fi fi
# ── Prompt: show [opencode-devbox] tag so it's obvious you're in the container # ── Prompt: show [opencode-devbox] tag so it's obvious you're in the container
@@ -14,7 +14,9 @@
# The one thing reachable from a container on every OS is the host itself # 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 # (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 # 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), # We ship the MECHANISM (a generic `host` jump alias + writable config),
# never the POLICY: the user's specific target hosts live in their own # never the POLICY: the user's specific target hosts live in their own
@@ -30,7 +32,9 @@
# #
# CONTROLS (env) # CONTROLS (env)
# DEVBOX_LAN_ACCESS = auto (default) | jump | off # 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). # jump → always set up (e.g. native Linux with extra_hosts host-gateway).
# off → do nothing. # off → do nothing.
# HOST_SSH_USER — the username to SSH into the host as. REQUIRED for the # HOST_SSH_USER — the username to SSH into the host as. REQUIRED for the
@@ -84,41 +88,71 @@ is_vm_backed() {
getent hosts "$HOST_ALIAS_HOSTNAME" >/dev/null 2>&1 getent hosts "$HOST_ALIAS_HOSTNAME" >/dev/null 2>&1
} }
if [ "$MODE" = "auto" ] && ! is_vm_backed; then # ── Writable socket dir + sidecar (ALWAYS, every host OS) ─────────────
# Native Linux host: LAN peers are reachable directly. Nothing to do. # The ControlPath redirect in the generated config needs a writable directory
exit 0 # regardless of host OS or jump mode. ~/.ssh is typically read-only, so the
fi # master socket lives under the writable ~/.ssh-local. We create it and render
# the config UNCONDITIONALLY so the redirect (and `Include ~/.ssh/config`) works
# From here: MODE=jump, or MODE=auto on a VM-backed host. # even on native Linux — where we set up no host jump but a read-only ~/.ssh
# would otherwise still break ControlMaster sockets.
command -v ssh-keygen >/dev/null 2>&1 || exit 0
mkdir -p "${SSH_LOCAL}/cm" 2>/dev/null || true mkdir -p "${SSH_LOCAL}/cm" 2>/dev/null || true
chmod 700 "${SSH_LOCAL}" "${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 # 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 # 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 # we DO generate one it must be (re-)authorized on the host, so we flag it and
# print a copy-paste authorize line below. # print a copy-paste authorize line below.
KEY_JUST_GENERATED=0 KEY_JUST_GENERATED=0
if [ ! -f "$KEY" ]; then if [ "$NEED_JUMP" = "1" ] && command -v ssh-keygen >/dev/null 2>&1 && [ ! -f "$KEY" ]; then
ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1 || exit 0 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 chmod 600 "$KEY" 2>/dev/null || true
KEY_JUST_GENERATED=1 KEY_JUST_GENERATED=1
fi
fi fi
# ── Render the writable config ──────────────────────────────────────── # ── Render the writable config ────────────────────────────────────────
USER_LINE="" # Jump-specific blocks (the host alias, host-owned peer overrides, and the
if [ -n "${HOST_SSH_USER:-}" ]; then # optional RFC1918 catch-all) only make sense when a jump is set up; on native
USER_LINE=" User ${HOST_SSH_USER}" # Linux they are all empty and only the ControlPath redirect + Include remain.
fi JUMP_BLOCK=""
# 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"
LAN_CONF_BLOCK="" LAN_CONF_BLOCK=""
if [ -r "$SSH_LAN_CONF" ]; then 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' LAN_CONF_BLOCK=$(cat <<'EOF'
# Host-owned named-peer jump overrides (bind-mounted; edit on the host). # Host-owned named-peer jump overrides (bind-mounted; edit on the host).
@@ -127,13 +161,12 @@ Host *
Include ~/.config/devbox-shell/ssh-lan.conf Include ~/.config/devbox-shell/ssh-lan.conf
EOF EOF
) )
fi fi
# Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the # Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the
# host. Matches the typed address, never the resolved HostName, so named hosts # host. Matches the typed address, never the resolved HostName, so named hosts
# with their own ProxyJump are unaffected. Network-agnostic → roaming-safe. # with their own ProxyJump are unaffected. Network-agnostic → roaming-safe.
AUTOJUMP_BLOCK="" if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then
if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then
AUTOJUMP_BLOCK=$(cat <<'EOF' AUTOJUMP_BLOCK=$(cat <<'EOF'
# RFC1918 auto-jump (DEVBOX_LAN_AUTOJUMP_PRIVATE=1): reach any private IP on # RFC1918 auto-jump (DEVBOX_LAN_AUTOJUMP_PRIVATE=1): reach any private IP on
@@ -146,6 +179,7 @@ Host 10.* 192.168.* 172.16.* 172.17.* 172.18.* 172.19.* 172.20.* 172.21.* 172.22
ProxyJump host ProxyJump host
EOF EOF
) )
fi
fi fi
INCLUDE_BLOCK="" INCLUDE_BLOCK=""
@@ -154,7 +188,9 @@ if [ -r "${HOME}/.ssh/config" ]; then
# Your own target hosts. Scope reset to match-all so this Include applies to # Your own target hosts. Scope reset to match-all so this Include applies to
# every target (an Include is otherwise scoped to the enclosing Host block). # every target (an Include is otherwise scoped to the enclosing Host block).
# Add 'ProxyJump host' to LAN entries here (or in ssh-lan.conf above). # To make a LAN peer jump via the host, add 'ProxyJump host' to its entry in
# the host-owned ~/.config/devbox-shell/ssh-lan.conf (Included above) — NOT
# here in ~/.ssh/config, which is typically bind-mounted read-only.
Host * Host *
Include ~/.ssh/config Include ~/.ssh/config
EOF EOF
@@ -176,17 +212,7 @@ Host *
UserKnownHostsFile ~/.ssh-local/known_hosts UserKnownHostsFile ~/.ssh-local/known_hosts
StrictHostKeyChecking accept-new StrictHostKeyChecking accept-new
ControlPath ~/.ssh-local/cm/%r@%h:%p ControlPath ~/.ssh-local/cm/%r@%h:%p
${JUMP_BLOCK}
# 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
${LAN_CONF_BLOCK} ${LAN_CONF_BLOCK}
${AUTOJUMP_BLOCK} ${AUTOJUMP_BLOCK}
${INCLUDE_BLOCK} ${INCLUDE_BLOCK}
@@ -199,6 +225,7 @@ chmod 600 "$CONFIG" 2>/dev/null || true
# host won't recognize. With ~/.ssh-local persisted via a named volume, case # 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 # (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. # 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)" PUBKEY_TEXT="$(cat "${KEY}.pub" 2>/dev/null)"
if [ -z "${HOST_SSH_USER:-}" ]; then if [ -z "${HOST_SSH_USER:-}" ]; then
cat <<EOF cat <<EOF
@@ -221,5 +248,6 @@ elif [ "$KEY_JUST_GENERATED" = "1" ]; then
repeat this on container updates — only if that volume is reset. repeat this on container updates — only if that volume is reset.
EOF EOF
fi fi
fi
exit 0 exit 0
@@ -0,0 +1,20 @@
<!-- pi-devbox:managed-block — appended to the global AGENTS.md at image build
time (Dockerfile.variant), after pi-toolkit is cloned. Keep this short:
it is a pointer, the depth lives in the skill. -->
## Running inside pi-devbox
If the directory `/usr/local/lib/pi-devbox/` exists (or your shell prompt is
prefixed `[devbox]`, or `~/.ssh-local/config` is present), you are in a
**pi-devbox container** — a Docker environment whose persistence, networking,
DNS, host/LAN reachability, tmux, and Python/REPL behaviour differ from a normal
workstation. Before any task that touches **reaching the host or its LAN, SSH,
DNS/name resolution, what survives container recreate, running Python/REPLs,
tmux, or pi-studio**, read `~/.agents/skills/pi-devbox-environment/SKILL.md`.
Key reflex from that skill: **the deployment specifics are not universal** — the
host OS, hostnames, internal domains, and nameservers vary per instance and must
be discovered at runtime, never assumed. And interactive shell aliases
(`dssh`, `dscp`, `cat``bat`) do **not** exist in your non-interactive bash
tool, so spell out the underlying command (e.g.
`ssh -F "$HOME/.ssh-local/config" mac …`).
@@ -0,0 +1,207 @@
---
name: pi-devbox-environment
description: >-
Operate correctly inside a pi-devbox container. Load when running inside
pi-devbox (detection: the directory `/usr/local/lib/pi-devbox/` exists, the
shell prompt is prefixed `[devbox]`, or `~/.ssh-local/config` is present) and
the task touches any of: reaching the Docker host or its LAN, SSH, DNS name
resolution, what survives container recreate (persistence vs ephemerality),
running Python or other REPLs, tmux, or the pi-studio browser UI. Covers the
persistence model, the interactive-vs-tool-shell alias gotcha
(dssh/dscp/cat=bat exist only in interactive bash), host + LAN SSH
reachability and ControlMaster, split-horizon DNS mechanisms, the tmux
0-index constraint, uv-first Python, and pi-studio reachability. This skill
teaches MECHANISMS only — concrete hostnames, usernames, internal domains,
nameservers, and even the host OS vary per deployment and MUST be discovered
at runtime, never assumed or hardcoded.
---
# pi-devbox environment
You are (or may be) running inside **pi-devbox**: a Docker container that ships
pi, MemPalace, and a curated tool stack, with the host source tree mounted at
`/workspace`. This skill is about the *container-shaped* facts that change how
you should act — things that are easy to get wrong because they differ from a
normal workstation shell.
> **Golden rule: this environment is a template, not a fixed deployment.**
> The host could be macOS, Windows, or Linux. There may or may not be LAN
> peers, a VPN, split-DNS, a skillset mount, or the `-studio` variant. Detect
> and verify the specifics live (commands below) — do **not** assume any
> particular hostname, domain, nameserver, or OS. Where this skill shows
> example values they are illustrative placeholders.
## 0. Am I in pi-devbox, and what's true *here*?
Cheap detection signals (any one is sufficient):
```sh
[ -d /usr/local/lib/pi-devbox ] && echo "pi-devbox image"
[ -r "$HOME/.ssh-local/config" ] && echo "LAN/host SSH sidecar present"
case "$PS1" in *'[devbox]'*) echo "interactive devbox shell";; esac
```
Then orient before acting:
```sh
cat /etc/os-release | head -2 # container distro (usually Debian)
ls -la /usr/local/lib/pi-devbox/ # which devbox helpers exist
sed -n '/^Host /,$p' ~/.ssh-local/config 2>/dev/null # host/LAN reachability, if any
mount | grep -E ' /workspace | /home/\S+/\.ssh ' # what's bind-mounted
```
## 1. Persistence vs ephemerality — know before you write
The container has **three storage tiers with very different lifetimes**. Pick
the right one or work is silently lost on the next recreate/update.
| Tier | Examples | Survives `down`? | Survives `down -v`? | Survives image update / `--force-recreate`? |
|---|---|---|---|---|
| **Host bind-mount** | `/workspace`, usually `~/.ssh` (ro), often `~/.mempalace` | yes | yes (lives on host) | yes |
| **Named volume** | `~/.pi`, `~/.ssh-local`, `~/.cache/bash`, `~/.local/share/{uv,nvim,zoxide}` | yes | **no** | yes |
| **Writable container layer** | anything else: `sudo apt install …`, `rustup`/`ghc`/`R` toolchains, files in `/tmp`, `/opt` edits | yes | **no** | **no** |
Practical consequences:
- **Durable work goes in `/workspace`** (it's the host filesystem, UID-aligned —
what you write appears with the user's normal ownership on the host).
- **Runtime-installed system packages and language toolchains are ephemeral.**
If a task needs them reproducibly, it belongs in the image (Dockerfile) or a
project manifest, not an ad-hoc `apt install`. Tell the user when you install
something that won't survive.
- **`~/.pi` is a named volume**, so things baked into the *image* under
`/home/<user>/...` are **shadowed** by the volume on existing containers and
only seen on a fresh volume. Image-owned content that must always be live
belongs under an image path like `/usr/local/...` or `/opt/...` and is linked
in by the entrypoint — not dropped into a home directory that a volume covers.
## 2. Interactive shell vs. your tool shell (a real footgun)
The conveniences below are defined in `~/.bash_aliases` and **only exist in an
interactive login shell.** Your `bash` *tool* runs non-interactively, so these
are "command not found" there — you must spell out the underlying command.
| Interactive alias | Non-interactive equivalent to actually run |
|---|---|
| `dssh <host>` | `ssh -F "$HOME/.ssh-local/config" <host>` |
| `dscp …` | `scp -F "$HOME/.ssh-local/config" …` |
| `cat file` (→ `bat`) | `cat file` works, but output differs; use `command cat` for raw |
| `ll`, `la` (→ `eza`/`ls`) | `ls -lh`, `ls -lha` |
If a command "works in my terminal but not when the agent runs it," this alias
gap is the first thing to suspect.
## 3. Reaching the Docker host and its LAN over SSH
When the host is VM-backed (e.g. OrbStack / Docker Desktop on macOS) the
entrypoint's `setup-lan-access.sh` writes a **writable SSH sidecar** at
`~/.ssh-local/config`. It always provides:
- A `Host *` block redirecting `ControlPath` into the writable `~/.ssh-local/cm`
(because `~/.ssh` is typically bind-mounted **read-only**, so a master socket
can't be created under it), plus `Include ~/.ssh/config`.
- Aliases **`host` / `mac`** → `host.docker.internal` (user comes from
`HOST_SSH_USER`) — i.e. SSH back into the Docker host.
- On VM-backed hosts only: an **SSH-jump-via-host** block so the container can
reach the host's directly-attached LAN peers (`ProxyJump host`). On a native
Linux host the LAN is usually reachable directly and this jump block is
omitted — **so don't assume a jump path exists; read the sidecar.**
Use it (remember §2 — spell it out in tool bash):
```sh
ssh -F "$HOME/.ssh-local/config" mac 'hostname; whoami' # reach the host
ssh -F "$HOME/.ssh-local/config" <lan-peer> '…' # reach a LAN peer (if configured)
```
Two related mechanisms (don't reinvent them):
- **ControlMaster multiplexing** is preconfigured (`/tmp/sshcm/`) to survive
CGNAT per-destination flow caps on residential ISPs. If `~/.ssh/config` pins
a `ControlPath` under the read-only `~/.ssh`, override with
`-o ControlPath=none` (or use the sidecar, which already redirects it).
- **`pi --ssh <host>`** rewires pi's own read/write/edit/bash tools to run on a
remote host; it has its own writable-socket fallback. See the `pi-extensions`
skill for that path.
## 4. DNS / name resolution — environment-specific, verify live
How a name resolves here is **not universal** and depends on the host's
networking. The container's own resolver is just `/etc/resolv.conf`, but the
*host* (which you reach via §3, and whose DNS the container may inherit) can use
**split-horizon DNS** to send certain internal domains to specific nameservers
while everything else goes to a default resolver/VPN gateway. The mechanism is
OS-specific and **may not be present at all**:
- **macOS host:** per-domain files in `/etc/resolver/<domain>`, each listing
`nameserver` lines. Reading them (over `ssh … mac`) is a fine way to learn the
real split-DNS map — *for that one machine.*
- **Linux host:** typically `systemd-resolved` split DNS (per-link `Domains=`
routing) or `/etc/resolv.conf` `search`/`nameserver`.
- **Windows host:** the NRPT (Name Resolution Policy Table) plays the per-suffix
role; WSL2 inherits host resolution via mirrored networking + DNS tunneling.
Operating rules:
1. **Never hardcode a domain→nameserver mapping or a specific nameserver IP**
it is per-deployment and changes between users and even VPN states.
2. **Verify by reading the live config**, e.g. `cat /etc/resolv.conf` in the
container, or `ssh … mac 'cat /etc/resolver/* 2>/dev/null'` on a macOS host.
3. **Reachability needs both DNS *and* a route.** A name resolving to an
internal address is useless if packets to that subnet don't have a path
(e.g. via the VPN or the §3 jump). Check both when something "resolves but
won't connect."
4. If you discover deployment-specific facts (a domain, a nameserver, a
reachable peer), prefer recording them in MemPalace over baking them into
code or this skill.
## 5. tmux is 0-indexed — don't change it
The image ships `/etc/tmux.conf` with `base-index 0` / `pane-base-index 0`
because **pi-studio hard-codes its tmux send target to `<session>:0.0`.** If you
(or a user `~/.tmux.conf`) set `base-index 1`, pi-studio fails with "can't find
window: 0". Leave the indexing alone in this environment.
## 6. Python and other languages: uv-first, toolchains are ephemeral
- A system `python3` exists, but **prefer `uv`** for REPLs and project envs —
it's installed and its store (`~/.local/share/uv`) is a persisted volume.
- Throwaway REPL: `uv run --with ipython ipython`
- Project env: `cd /workspace/proj && uv init && uv add <pkgs> && uv run …`
(the `pyproject.toml` + `uv.lock` travel with the repo — the durable choice).
- Other language toolchains (Rust via rustup, R, GHC, Clojure, Go) are
**runtime opt-ins on the ephemeral layer** unless baked into the image — they
do not survive `down -v` or an image update. Flag this when installing.
## 7. pi-studio reachability (only in the `-studio` variant)
Present only if `/opt/pi-studio` exists / the `studio_*` tools are in your tool
list. pi-studio **binds to `127.0.0.1` inside the container** with no host-bind
flag, so a plain `docker -p` publish can't reach it. Two supported paths:
- **Host networking** (`network_mode: host`): container loopback == host
loopback; open the tokenized URL on the host. (Changes
`host.docker.internal` semantics — weigh against §3 LAN jump.)
- **`studio-expose` bridge** (`STUDIO_EXPOSE=1` or run `studio-expose &`): a
`socat` relay from the container's external interface to its loopback, so a
published `127.0.0.1:PORT` + `ssh -L PORT:127.0.0.1:PORT host` reaches it.
The real auth token comes from the `/studio` slash command (`/studio --status`
to reprint), **not** from `studio-expose`. For Graphviz, use `dot-watch`
PNG (Studio renders Mermaid natively and previews PNG, but not SVG/DOT).
## 8. MemPalace is the shared brain
MemPalace data is usually a **host bind-mount**, so a pi on the host and a pi in
this container share one palace (SQLite WAL: many readers, one writer). Use it
to persist the deployment-specific facts this skill deliberately refuses to
hardcode. Details are in the `mempalace` skill.
## Checklist before acting in this environment
- [ ] Writing durable output? → `/workspace`, not the ephemeral layer.
- [ ] Using `dssh`/`dscp`/`ll` in the bash tool? → spell out the real command.
- [ ] Assuming a hostname / domain / nameserver / host OS? → stop, detect it.
- [ ] "Resolves but won't connect"? → check route *and* DNS (§3 + §4).
- [ ] `apt`/toolchain install? → tell the user it's ephemeral unless imaged.
- [ ] Touching tmux indexing? → don't (§5).
+43
View File
@@ -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
+32 -2
View File
@@ -6,8 +6,8 @@
# version is supplied — see the version note below) # version is supplied — see the version note below)
# - Persisted named volumes survived (~/.pi config, shell history, zoxide, # - Persisted named volumes survived (~/.pi config, shell history, zoxide,
# nvim data, uv cache, ssh-local) # nvim data, uv cache, ssh-local)
# - pi runtime wiring is intact: keybindings symlink, ≥4 extensions, the # - pi runtime wiring is intact: keybindings symlink, AGENTS.md symlink,
# mempalace.ts bridge, settings.json, and the pi-fork / # ≥4 extensions, the mempalace.ts bridge, settings.json, and the pi-fork /
# pi-observational-memory / (studio variant) pi-studio package registrations # pi-observational-memory / (studio variant) pi-studio package registrations
# - Shell defaults re-seeded from /etc/skel-devbox # - Shell defaults re-seeded from /etc/skel-devbox
# - /tmp/sshcm exists with mode 700 (ssh ControlMaster dir) # - /tmp/sshcm exists with mode 700 (ssh ControlMaster dir)
@@ -157,6 +157,14 @@ else
fail "~/.pi/agent/keybindings.json missing or not a symlink" fail "~/.pi/agent/keybindings.json missing or not a symlink"
fi 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 # extensions deployed (pi-extensions) — expect ≥4 *.ts
EXT_COUNT=$(ls -1 "$HOME"/.pi/agent/extensions/*.ts 2>/dev/null | wc -l | tr -d ' ') EXT_COUNT=$(ls -1 "$HOME"/.pi/agent/extensions/*.ts 2>/dev/null | wc -l | tr -d ' ')
if [ "$EXT_COUNT" -ge 4 ]; then if [ "$EXT_COUNT" -ge 4 ]; then
@@ -179,6 +187,18 @@ else
fail "~/.pi/agent/settings.json missing" fail "~/.pi/agent/settings.json missing"
fi 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) # pi package registrations (pi install <local-path> → recorded in settings.json)
if [ -f "$HOME/.pi/agent/settings.json" ]; then if [ -f "$HOME/.pi/agent/settings.json" ]; then
for pkg in pi-fork pi-observational-memory; do for pkg in pi-fork pi-observational-memory; do
@@ -214,6 +234,16 @@ else
fail "~/.bash_aliases missing" fail "~/.bash_aliases missing"
fi 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 if [ -f "$HOME/.inputrc" ]; then
pass "~/.inputrc exists" pass "~/.inputrc exists"
else else
+30
View File
@@ -80,6 +80,12 @@ run "yq" "yq --version"
run "tldr (tealdeer)" "tldr --version" run "tldr (tealdeer)" "tldr --version"
run "socat" "socat -V" run "socat" "socat -V"
run "studio-expose helper" "test -x /usr/local/bin/studio-expose" run "studio-expose helper" "test -x /usr/local/bin/studio-expose"
run "image-baked pi-devbox-environment skill" \
"test -f /usr/local/share/pi-devbox/skills/pi-devbox-environment/SKILL.md"
run "global-AGENTS append snippet present" \
"test -f /usr/local/share/pi-devbox/pi-global-AGENTS.append.md"
run "pi-devbox block merged into pi-global-AGENTS.md" \
"grep -q 'pi-devbox:managed-block' /opt/pi-toolkit/pi-global-AGENTS.md"
# ── tmux 0-indexing (required for pi-studio variants) ───────────────── # ── tmux 0-indexing (required for pi-studio variants) ─────────────────
echo "" echo ""
@@ -113,6 +119,28 @@ else
echo " ️ pi-studio not present (non-studio variant) — skipping studio clone checks" echo " ️ pi-studio not present (non-studio variant) — skipping studio clone checks"
fi 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) ────────────────────── # ── Runtime deployment (needs entrypoint to run) ──────────────────────
echo "" echo ""
echo "── Runtime deployment ──" echo "── Runtime deployment ──"
@@ -134,6 +162,7 @@ for i in $(seq 1 45); do
if docker exec "$CID" sh -c ' if docker exec "$CID" sh -c '
test -L /home/developer/.pi/agent/keybindings.json && \ test -L /home/developer/.pi/agent/keybindings.json && \
test -L /home/developer/.pi/agent/extensions/mempalace.ts && \ test -L /home/developer/.pi/agent/extensions/mempalace.ts && \
test -L /home/developer/.agents/skills/pi-devbox-environment && \
count=$(ls -1 /home/developer/.pi/agent/extensions/*.ts 2>/dev/null | wc -l) && \ count=$(ls -1 /home/developer/.pi/agent/extensions/*.ts 2>/dev/null | wc -l) && \
[ "$count" -ge 4 ] [ "$count" -ge 4 ]
' >/dev/null 2>&1; then ' >/dev/null 2>&1; then
@@ -155,6 +184,7 @@ exec_test "keybindings.json (pi-toolkit)" 'test -L $HOME/.pi/agent/keybi
exec_test "extensions ≥ 4 (pi-extensions)" 'count=$(ls -1 $HOME/.pi/agent/extensions/*.ts 2>/dev/null | wc -l); [ $count -ge 4 ] && echo "$count extensions"' exec_test "extensions ≥ 4 (pi-extensions)" 'count=$(ls -1 $HOME/.pi/agent/extensions/*.ts 2>/dev/null | wc -l); [ $count -ge 4 ] && echo "$count extensions"'
exec_test "mempalace.ts bridge" 'test -L $HOME/.pi/agent/extensions/mempalace.ts && echo ok' exec_test "mempalace.ts bridge" 'test -L $HOME/.pi/agent/extensions/mempalace.ts && echo ok'
exec_test "settings.json bootstrapped" 'test -f $HOME/.pi/agent/settings.json && echo ok' exec_test "settings.json bootstrapped" 'test -f $HOME/.pi/agent/settings.json && echo ok'
exec_test "pi-devbox-environment skill linked" 'test -L $HOME/.agents/skills/pi-devbox-environment && test -f $HOME/.agents/skills/pi-devbox-environment/SKILL.md && echo ok'
# pi-fork + pi-observational-memory are registered by entrypoint-user.sh via # pi-fork + pi-observational-memory are registered by entrypoint-user.sh via
# `pi install /opt/<pkg>`, which runs slightly after the keybindings marker. # `pi install /opt/<pkg>`, which runs slightly after the keybindings marker.