Compare commits
35 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f91dff6090 | |||
| 9ebb0643c7 | |||
| 7d8ee4cea1 | |||
| a78e59fb5b | |||
| cf5c60a342 | |||
| edd6be1737 | |||
| efd254f4e6 | |||
| 8b69b3625b | |||
| b55b44e7b6 | |||
| c1154f1fa6 | |||
| 36afd3c716 | |||
| 2ab03aaa6f | |||
| 2e86e5a3f3 | |||
| 45f4488764 | |||
| 3bfbafad9e | |||
| d9a538c405 | |||
| 08bb0c520e | |||
| e996b01542 | |||
| 03629cdac7 | |||
| 1d1283f942 | |||
| c139be326f | |||
| 1587a84579 | |||
| 32df96f0ea | |||
| 2d397663d5 | |||
| e6a21f36f1 | |||
| 9b305c9f7e | |||
| 5d9208c547 | |||
| 34cae2a1d2 | |||
| dff3092338 | |||
| c7f7f97754 | |||
| b6cc2c748b | |||
| ae6253ab23 | |||
| da21206e6e | |||
| 973c2efd5c | |||
| 5d472bd41f |
@@ -9,6 +9,27 @@ WORKSPACE_PATH=~/projects
|
||||
# Path to SSH keys on host
|
||||
SSH_KEY_PATH=~/.ssh
|
||||
|
||||
# ── LAN access from the container (host-OS-agnostic) ─────────────────
|
||||
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
|
||||
# reach the host's directly-attached LAN peers by default. The entrypoint
|
||||
# then sets up the host as an SSH jump (use the `dssh` alias). Reach the host
|
||||
# with `dssh host`; for named LAN peers put `ProxyJump host` overrides in a
|
||||
# host-owned ~/.config/devbox-shell/ssh-lan.conf (bind-mounted in) rather than
|
||||
# editing ~/.ssh/config. On native Linux Docker the LAN is reachable directly
|
||||
# and this is a no-op.
|
||||
# See the opencode-devbox README for the full walkthrough.
|
||||
#
|
||||
# DEVBOX_LAN_ACCESS: auto (default) | jump | off
|
||||
# DEVBOX_LAN_ACCESS=auto
|
||||
# HOST_SSH_USER: your username on the host (required for the jump). On first
|
||||
# start the entrypoint prints the public key to authorize on the host.
|
||||
# HOST_SSH_USER=
|
||||
# DEVBOX_HOST_ALIAS: host hostname to reach (default host.docker.internal).
|
||||
# DEVBOX_HOST_ALIAS=host.docker.internal
|
||||
# DEVBOX_LAN_AUTOJUMP_PRIVATE: 1 = ProxyJump any private (RFC1918) IP through
|
||||
# the host, so bare `dssh user@<ip>` works on whatever LAN you're roaming on.
|
||||
# DEVBOX_LAN_AUTOJUMP_PRIVATE=0
|
||||
|
||||
# ── Git Configuration ────────────────────────────────────────────────
|
||||
GIT_USER_NAME=
|
||||
GIT_USER_EMAIL=
|
||||
|
||||
@@ -1,9 +1,38 @@
|
||||
name: Publish Docker Image
|
||||
|
||||
# Two-phase split-base build pipeline for pi-devbox.
|
||||
# Adapted from opencode-devbox/.gitea/workflows/docker-publish-split.yml
|
||||
# (commit before v1.16.2). pi-devbox v1.0.0 introduces a self-contained
|
||||
# build chain — base + variant Dockerfiles in this repo — so this
|
||||
# workflow no longer depends on opencode-devbox CI.
|
||||
#
|
||||
# Pipeline shape:
|
||||
# 1. base-decide compute base hash from Dockerfile.base + rootfs/
|
||||
# + entrypoints; probe Docker Hub for existing tag.
|
||||
# 2. resolve-versions resolve pi @ npm 'latest', pi-fork/pi-obsmem refs
|
||||
# to commit SHAs (defeats registry-buildcache
|
||||
# cache-hit footgun on byte-identical build args).
|
||||
# 3. build-base only if probe missed; multi-arch push of base-<hash>.
|
||||
# 4. smoke amd64-only build of the variant FROMing the base
|
||||
# tag; runs scripts/smoke-test.sh.
|
||||
# 5. build-variant multi-arch push of latest + vX.Y.Z tags.
|
||||
# 6. promote-base-latest re-tag base-<hash> → base-latest with `crane copy`.
|
||||
# 7. update-description patch Docker Hub description.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: 'Release tag to publish (e.g. v1.0.0). Used only for workflow_dispatch runs.'
|
||||
required: false
|
||||
default: ''
|
||||
promote_latest:
|
||||
description: 'Update latest aliases (default true for tag-push, false for manual test runs)'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -12,16 +41,216 @@ concurrency:
|
||||
env:
|
||||
BUILDKIT_PROGRESS: plain
|
||||
IMAGE: ${{ vars.DOCKERHUB_USERNAME }}/pi-devbox
|
||||
RELEASE_TAG: ${{ github.ref_type == 'tag' && github.ref_name || inputs.release_tag }}
|
||||
PROMOTE_LATEST: ${{ github.ref_type == 'tag' && 'true' || inputs.promote_latest }}
|
||||
|
||||
jobs:
|
||||
# ── Phase 1: decide whether base needs rebuilding ──────────────────
|
||||
base-decide:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
outputs:
|
||||
base_tag: ${{ steps.compute.outputs.base_tag }}
|
||||
need_build: ${{ steps.probe.outputs.need_build }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Compute base tag from Dockerfile.base + dependencies
|
||||
id: compute
|
||||
run: |
|
||||
# Hash inputs that determine the base image's contents.
|
||||
# Order is fixed via `find -print0 | sort -z` for reproducibility.
|
||||
# Junk filters: __pycache__/*.pyc and macOS metadata are gitignored
|
||||
# locally but still picked up by `find rootfs -type f` on a clean CI
|
||||
# checkout. Exclude them defensively.
|
||||
HASH=$(
|
||||
{
|
||||
cat Dockerfile.base
|
||||
find rootfs -type f \
|
||||
! -path '*/__pycache__/*' \
|
||||
! -name '*.pyc' \
|
||||
! -name '.DS_Store' \
|
||||
! -name '._*' \
|
||||
-print0 2>/dev/null | sort -z | xargs -0 cat 2>/dev/null
|
||||
cat entrypoint.sh entrypoint-user.sh
|
||||
} | sha256sum | cut -c1-12
|
||||
)
|
||||
BASE_TAG="base-${HASH}"
|
||||
echo "base_tag=${BASE_TAG}" >> "$GITHUB_OUTPUT"
|
||||
echo "Computed base tag: ${BASE_TAG}"
|
||||
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
|
||||
- name: Probe Docker Hub for existing base tag
|
||||
id: probe
|
||||
run: |
|
||||
set +e
|
||||
docker manifest inspect "${IMAGE}:${{ steps.compute.outputs.base_tag }}" \
|
||||
> /dev/null 2>&1
|
||||
PROBE_RC=$?
|
||||
set -e
|
||||
if [ "${PROBE_RC}" = "0" ]; then
|
||||
echo "need_build=false" >> "$GITHUB_OUTPUT"
|
||||
echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} exists — skipping rebuild."
|
||||
else
|
||||
echo "need_build=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Base tag ${IMAGE}:${{ steps.compute.outputs.base_tag }} missing — will build."
|
||||
fi
|
||||
|
||||
# ── Phase 1b: resolve floating versions to concrete refs ────────────
|
||||
# Without this, when PI_VERSION defaults to 'latest', the build-arg string
|
||||
# is byte-identical across builds → identical layer hash → registry
|
||||
# buildcache silently reuses the layer from whatever pi version was
|
||||
# current when the cache was first populated. Same class of bug as
|
||||
# pi-devbox v0.74.0..v0.75.5 (fixed in v0.75.5b 2026-05-23).
|
||||
resolve-versions:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
outputs:
|
||||
pi_version: ${{ steps.resolve.outputs.pi_version }}
|
||||
fork_ref: ${{ steps.resolve.outputs.fork_ref }}
|
||||
obsmem_ref: ${{ steps.resolve.outputs.obsmem_ref }}
|
||||
toolkit_ref: ${{ steps.resolve.outputs.toolkit_ref }}
|
||||
extensions_ref: ${{ steps.resolve.outputs.extensions_ref }}
|
||||
studio_ref: ${{ steps.resolve.outputs.studio_ref }}
|
||||
steps:
|
||||
- name: Resolve pi version + companion refs
|
||||
id: resolve
|
||||
run: |
|
||||
set -eu
|
||||
# Query npm registry directly; catthehacker/ubuntu:act-latest's npm
|
||||
# is not reliably on PATH in act_runner job containers.
|
||||
PI_VERSION=$(curl -sf "https://registry.npmjs.org/@earendil-works%2Fpi-coding-agent/latest" | jq -r '.version')
|
||||
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.
|
||||
FORK_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||
"https://api.github.com/repos/elpapi42/pi-fork/commits/master" || echo "master")
|
||||
OBSMEM_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||
"https://api.github.com/repos/elpapi42/pi-observational-memory/commits/master" || echo "master")
|
||||
[ -n "$FORK_REF" ] || FORK_REF=master
|
||||
[ -n "$OBSMEM_REF" ] || OBSMEM_REF=master
|
||||
echo "fork_ref=${FORK_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "obsmem_ref=${OBSMEM_REF}" >> "$GITHUB_OUTPUT"
|
||||
# Also resolve pi-toolkit / pi-extensions main HEADs to SHAs so a
|
||||
# workflow_dispatch re-run produces byte-identical images when
|
||||
# those repos haven't moved (and a clean diff in build-arg strings
|
||||
# when they have, defeating the registry buildcache footgun).
|
||||
# Gitea API requires auth even for public-repo commit listing.
|
||||
TOOLKIT_REF=$(curl -sf -H "Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}" \
|
||||
"https://gitea.jordbo.se/api/v1/repos/joakimp/pi-toolkit/commits?limit=1&sha=main" \
|
||||
| jq -r '.[0].sha // "main"' 2>/dev/null || echo "main")
|
||||
EXTENSIONS_REF=$(curl -sf -H "Authorization: token ${GITEA_BUILD_TOKEN:-${GITHUB_TOKEN:-}}" \
|
||||
"https://gitea.jordbo.se/api/v1/repos/joakimp/pi-extensions/commits?limit=1&sha=main" \
|
||||
| jq -r '.[0].sha // "main"' 2>/dev/null || echo "main")
|
||||
[ -n "$TOOLKIT_REF" ] || TOOLKIT_REF=main
|
||||
[ -n "$EXTENSIONS_REF" ] || EXTENSIONS_REF=main
|
||||
echo "toolkit_ref=${TOOLKIT_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "extensions_ref=${EXTENSIONS_REF}" >> "$GITHUB_OUTPUT"
|
||||
# Resolve pi-studio (omaclaren/pi-studio) main HEAD to a SHA for
|
||||
# the :latest-studio variant — same cache-busting rationale.
|
||||
STUDIO_REF=$(curl -sf -H "Accept: application/vnd.github.sha" \
|
||||
"https://api.github.com/repos/omaclaren/pi-studio/commits/main" || echo "main")
|
||||
[ -n "$STUDIO_REF" ] || STUDIO_REF=main
|
||||
echo "studio_ref=${STUDIO_REF}" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved PI_VERSION=${PI_VERSION}"
|
||||
echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_REF}"
|
||||
echo "Resolved PI_TOOLKIT_REF=${TOOLKIT_REF}, PI_EXTENSIONS_REF=${EXTENSIONS_REF}"
|
||||
echo "Resolved PI_STUDIO_REF=${STUDIO_REF}"
|
||||
|
||||
# ── Phase 2: build & push base (multi-arch), only when needed ──────
|
||||
build-base:
|
||||
needs: [base-decide]
|
||||
if: needs.base-decide.outputs.need_build == 'true'
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
set -x
|
||||
df -h / || true
|
||||
rm -rf \
|
||||
/opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
|
||||
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
/usr/local/share/chromium /usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
apt-get clean || true
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
df -h / || true
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v3
|
||||
with:
|
||||
platforms: arm64
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
with:
|
||||
driver-opts: network=host
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build and push base (multi-arch) — with retry
|
||||
shell: bash
|
||||
env:
|
||||
BASE_TAG_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 3-attempt retry around `docker buildx build --push` for transient
|
||||
# registry-1.docker.io blips. Does NOT mask deterministic failures.
|
||||
# Registry cache disabled: buildkit cache-export hits HTTP 400 from
|
||||
# Hub CDN since ~2026-05-23. Image push itself works; we pay full
|
||||
# base build on Dockerfile.base change, but the base tag is content-
|
||||
# addressed so unchanged bases short-circuit at the probe step.
|
||||
for attempt in 1 2 3; do
|
||||
echo "==> Build+push attempt ${attempt}/3"
|
||||
if docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file Dockerfile.base \
|
||||
--push \
|
||||
--tag "${BASE_TAG_FULL}" \
|
||||
.; then
|
||||
echo "==> Attempt ${attempt} succeeded"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" -lt 3 ]]; then
|
||||
backoff=$(( attempt * 15 ))
|
||||
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||||
sleep "${backoff}"
|
||||
fi
|
||||
done
|
||||
echo "==> All 3 build+push attempts failed"
|
||||
exit 1
|
||||
|
||||
# ── Phase 3: amd64 smoke (gates the multi-arch publish) ─────────────
|
||||
smoke:
|
||||
needs: [base-decide, build-base, resolve-versions]
|
||||
if: |
|
||||
always() &&
|
||||
needs.base-decide.result == 'success' &&
|
||||
needs.resolve-versions.result == 'success' &&
|
||||
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
- run: |
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
|
||||
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
@@ -29,24 +258,92 @@ jobs:
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
with: {driver-opts: network=host}
|
||||
|
||||
- name: Build (amd64, load to local daemon)
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build amd64 variant for smoke
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: pi-devbox:smoke
|
||||
|
||||
- name: Smoke test
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
|
||||
PI_FORK_REF=${{ needs.resolve-versions.outputs.fork_ref }}
|
||||
PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
PI_TOOLKIT_REF=${{ needs.resolve-versions.outputs.toolkit_ref }}
|
||||
PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }}
|
||||
- name: Smoke test (amd64)
|
||||
env:
|
||||
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
run: bash scripts/smoke-test.sh pi-devbox:smoke
|
||||
|
||||
publish:
|
||||
needs: smoke
|
||||
# ── Phase 3b: amd64 smoke for the studio variant ────────────────────
|
||||
# Additive + independent of the core `smoke` job: gates ONLY
|
||||
# build-variant-studio, never the core build-variant. A studio build or
|
||||
# smoke failure therefore cannot block the :latest / :vX.Y.Z release.
|
||||
smoke-studio:
|
||||
needs: [base-decide, build-base, resolve-versions]
|
||||
if: |
|
||||
always() &&
|
||||
needs.base-decide.result == 'success' &&
|
||||
needs.resolve-versions.result == 'success' &&
|
||||
(needs.build-base.result == 'success' || needs.build-base.result == 'skipped')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Force IPv4 for Docker Hub
|
||||
run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
- name: Reclaim runner disk
|
||||
run: |
|
||||
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
|
||||
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
/usr/local/share/chromium /usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
with: {driver-opts: network=host}
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Build amd64 studio variant for smoke
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile.variant
|
||||
platforms: linux/amd64
|
||||
push: false
|
||||
load: true
|
||||
tags: pi-devbox:smoke-studio
|
||||
build-args: |
|
||||
BASE_IMAGE=${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
PI_VERSION=${{ needs.resolve-versions.outputs.pi_version }}
|
||||
PI_FORK_REF=${{ needs.resolve-versions.outputs.fork_ref }}
|
||||
PI_OBSMEM_REF=${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
PI_TOOLKIT_REF=${{ needs.resolve-versions.outputs.toolkit_ref }}
|
||||
PI_EXTENSIONS_REF=${{ needs.resolve-versions.outputs.extensions_ref }}
|
||||
INSTALL_STUDIO=true
|
||||
PI_STUDIO_REF=${{ needs.resolve-versions.outputs.studio_ref }}
|
||||
- name: Smoke test studio (amd64)
|
||||
env:
|
||||
EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
run: bash scripts/smoke-test.sh pi-devbox:smoke-studio
|
||||
|
||||
# ── Phase 4: multi-arch publish ─────────────────────────────────────
|
||||
build-variant:
|
||||
needs: [base-decide, smoke, resolve-versions]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
@@ -61,7 +358,6 @@ jobs:
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
with: {platforms: arm64}
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
@@ -70,42 +366,238 @@ jobs:
|
||||
with:
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Compute tags
|
||||
- name: Compute version-specific tags
|
||||
id: tags
|
||||
run: |
|
||||
VERSION="${{ github.ref_name }}"
|
||||
VERSION="${{ env.RELEASE_TAG }}"
|
||||
{ echo "tags<<EOF"
|
||||
echo "${IMAGE}:${VERSION}"
|
||||
echo "${IMAGE}:latest"
|
||||
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
|
||||
echo "${IMAGE}:latest"
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push variant (with retry)
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ steps.tags.outputs.tags }}
|
||||
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
FORK_REF: ${{ needs.resolve-versions.outputs.fork_ref }}
|
||||
OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }}
|
||||
EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_FLAGS=()
|
||||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||||
# 3-attempt retry (see build-base step for rationale).
|
||||
for attempt in 1 2 3; do
|
||||
echo "==> Build+push attempt ${attempt}/3"
|
||||
if docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file Dockerfile.variant \
|
||||
--push \
|
||||
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
|
||||
--build-arg "PI_VERSION=${PI_VERSION}" \
|
||||
--build-arg "PI_FORK_REF=${FORK_REF}" \
|
||||
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
|
||||
--build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \
|
||||
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
|
||||
"${TAG_FLAGS[@]}" \
|
||||
.; then
|
||||
echo "==> Attempt ${attempt} succeeded"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" -lt 3 ]]; then
|
||||
backoff=$(( attempt * 15 ))
|
||||
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||||
sleep "${backoff}"
|
||||
fi
|
||||
done
|
||||
echo "==> All 3 build+push attempts failed"
|
||||
exit 1
|
||||
|
||||
- name: Build and push (amd64 + arm64)
|
||||
uses: docker/build-push-action@v7
|
||||
# ── Phase 4b: multi-arch publish of the studio variant ───────────────
|
||||
# Additive: publishes :vX.Y.Z-studio (+ :latest-studio on release). Gated
|
||||
# on its own smoke-studio, NOT on the core build-variant, so it can ship
|
||||
# or fail independently of the core release.
|
||||
build-variant-studio:
|
||||
needs: [base-decide, smoke-studio, resolve-versions]
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- run: echo 'precedence ::ffff:0:0/96 100' >> /etc/gai.conf
|
||||
- run: |
|
||||
rm -rf /opt/hostedtoolcache /opt/microsoft /opt/az /opt/ghc \
|
||||
/usr/local/.ghcup /usr/share/dotnet /usr/share/swift \
|
||||
/usr/local/lib/android /usr/local/share/powershell \
|
||||
/usr/local/share/chromium /usr/local/share/boost \
|
||||
/usr/lib/jvm 2>/dev/null || true
|
||||
docker system prune -af --volumes || true
|
||||
docker builder prune -af || true
|
||||
- uses: docker/setup-qemu-action@v3
|
||||
with: {platforms: arm64}
|
||||
- uses: docker/setup-buildx-action@v4
|
||||
with: {driver-opts: network=host}
|
||||
- uses: docker/login-action@v3
|
||||
with:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ steps.tags.outputs.tags }}
|
||||
cache-from: type=registry,ref=${{ env.IMAGE }}:buildcache
|
||||
cache-to: type=registry,ref=${{ env.IMAGE }}:buildcache,mode=max
|
||||
username: ${{ vars.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
- name: Compute studio version-specific tags
|
||||
id: tags
|
||||
run: |
|
||||
VERSION="${{ env.RELEASE_TAG }}"
|
||||
{ echo "tags<<EOF"
|
||||
echo "${IMAGE}:${VERSION}-studio"
|
||||
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
|
||||
echo "${IMAGE}:latest-studio"
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Build and push studio variant (with retry)
|
||||
shell: bash
|
||||
env:
|
||||
TAGS: ${{ steps.tags.outputs.tags }}
|
||||
BASE_IMAGE_FULL: ${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }}
|
||||
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
FORK_REF: ${{ needs.resolve-versions.outputs.fork_ref }}
|
||||
OBSMEM_REF: ${{ needs.resolve-versions.outputs.obsmem_ref }}
|
||||
TOOLKIT_REF: ${{ needs.resolve-versions.outputs.toolkit_ref }}
|
||||
EXTENSIONS_REF: ${{ needs.resolve-versions.outputs.extensions_ref }}
|
||||
STUDIO_REF: ${{ needs.resolve-versions.outputs.studio_ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TAG_FLAGS=()
|
||||
while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}"
|
||||
# 3-attempt retry (see build-base step for rationale).
|
||||
for attempt in 1 2 3; do
|
||||
echo "==> Build+push attempt ${attempt}/3"
|
||||
if docker buildx build \
|
||||
--platform linux/amd64,linux/arm64 \
|
||||
--file Dockerfile.variant \
|
||||
--push \
|
||||
--build-arg "BASE_IMAGE=${BASE_IMAGE_FULL}" \
|
||||
--build-arg "PI_VERSION=${PI_VERSION}" \
|
||||
--build-arg "PI_FORK_REF=${FORK_REF}" \
|
||||
--build-arg "PI_OBSMEM_REF=${OBSMEM_REF}" \
|
||||
--build-arg "PI_TOOLKIT_REF=${TOOLKIT_REF}" \
|
||||
--build-arg "PI_EXTENSIONS_REF=${EXTENSIONS_REF}" \
|
||||
--build-arg "INSTALL_STUDIO=true" \
|
||||
--build-arg "PI_STUDIO_REF=${STUDIO_REF}" \
|
||||
"${TAG_FLAGS[@]}" \
|
||||
.; then
|
||||
echo "==> Attempt ${attempt} succeeded"
|
||||
exit 0
|
||||
fi
|
||||
if [[ "${attempt}" -lt 3 ]]; then
|
||||
backoff=$(( attempt * 15 ))
|
||||
echo "==> Attempt ${attempt} failed, sleeping ${backoff}s before retry"
|
||||
sleep "${backoff}"
|
||||
fi
|
||||
done
|
||||
echo "==> All 3 build+push attempts failed"
|
||||
exit 1
|
||||
|
||||
# ── Phase 5: promote base-<hash> → base-latest (manifest copy only) ─
|
||||
promote-base-latest:
|
||||
needs:
|
||||
- base-decide
|
||||
- build-variant
|
||||
# Skip on cache-hit base builds: when need_build=false, base-latest
|
||||
# already points at the same digest as base-<hash>, so the retag is
|
||||
# a tautology and any transient failure of it is purely cosmetic.
|
||||
# Manual workflow_dispatch with promote_latest=true overrides this
|
||||
# gate as an escape hatch (e.g., if base-latest got hand-deleted).
|
||||
if: |
|
||||
always() &&
|
||||
needs.build-variant.result == 'success' &&
|
||||
(inputs.promote_latest == 'true' ||
|
||||
(github.ref_type == 'tag' && needs.base-decide.outputs.need_build == 'true'))
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
# Direct pinned install instead of imjasonh/setup-crane@v0.4. The
|
||||
# action's bootstrap script periodically rate-limits on
|
||||
# api.github.com/.../releases/latest. Pinning removes the runtime
|
||||
# dependency on GitHub API entirely.
|
||||
- name: Install crane (pinned)
|
||||
env:
|
||||
CRANE_VERSION: v0.21.6
|
||||
run: |
|
||||
set -eux
|
||||
curl -fsSL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin crane
|
||||
crane version
|
||||
- name: Login (crane)
|
||||
run: |
|
||||
crane auth login docker.io \
|
||||
-u ${{ vars.DOCKERHUB_USERNAME }} \
|
||||
-p "${{ secrets.DOCKERHUB_TOKEN }}"
|
||||
- name: Re-tag base-<hash> as base-latest
|
||||
run: |
|
||||
crane copy \
|
||||
${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} \
|
||||
${{ env.IMAGE }}:base-latest
|
||||
|
||||
# ── Phase 6: update Hub description (only on real release runs) ────
|
||||
update-description:
|
||||
needs: publish
|
||||
needs: [build-variant, resolve-versions]
|
||||
if: |
|
||||
always() &&
|
||||
needs.build-variant.result == 'success' &&
|
||||
(github.ref_type == 'tag' || inputs.promote_latest == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Update Docker Hub description
|
||||
env:
|
||||
PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }}
|
||||
run: |
|
||||
PAYLOAD=$(jq -n --rawfile desc DOCKER_HUB.md '{"full_description": $desc}')
|
||||
TOKEN=$(curl -s -X POST "https://hub.docker.com/v2/auth/token" \
|
||||
# Substitute {{PI_VERSION}} placeholders in DOCKER_HUB.md so the
|
||||
# Hub page always shows which pi version is in :latest. The
|
||||
# placeholder lives in DOCKER_HUB.md (committed); CI fills it
|
||||
# at publish time using the same resolved version that was
|
||||
# baked into the variant image. No drift between page and image.
|
||||
if [ -z "${PI_VERSION}" ]; then
|
||||
echo "::error::PI_VERSION env var is empty. Likely cause: the"
|
||||
echo "::error::update-description job is missing 'resolve-versions'"
|
||||
echo "::error::in its needs: list, so needs.resolve-versions.outputs.pi_version"
|
||||
echo "::error::resolves to an empty string instead of the actual version."
|
||||
exit 1
|
||||
fi
|
||||
cp DOCKER_HUB.md /tmp/hub-full.md
|
||||
sed -i "s/{{PI_VERSION}}/${PI_VERSION}/g" /tmp/hub-full.md
|
||||
if grep -q '{{PI_VERSION}}' /tmp/hub-full.md; then
|
||||
echo "::error::DOCKER_HUB.md still contains unsubstituted {{PI_VERSION}} markers"
|
||||
exit 1
|
||||
fi
|
||||
TOKEN=$(curl -s -X POST https://hub.docker.com/v2/auth/token \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "{\"username\":\"${{ vars.DOCKERHUB_USERNAME }}\",\"password\":\"${{ secrets.DOCKERHUB_TOKEN }}\"}" \
|
||||
| jq -r '.token')
|
||||
curl -s -X PATCH "https://hub.docker.com/v2/repositories/${{ vars.DOCKERHUB_USERNAME }}/pi-devbox/" \
|
||||
-H "Authorization: Bearer ${TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d "${PAYLOAD}" | jq -r '.full_description | if . then "✅ description updated (\(. | length) chars)" else "❌ update failed" end'
|
||||
-d '{"identifier":"${{ vars.DOCKERHUB_USERNAME }}","secret":"${{ secrets.DOCKERHUB_TOKEN }}"}' \
|
||||
| jq -r .access_token)
|
||||
if [ "$TOKEN" = "null" ] || [ -z "$TOKEN" ]; then
|
||||
echo "::error::Failed to authenticate with Docker Hub API"
|
||||
exit 1
|
||||
fi
|
||||
HTTP_CODE=$(jq -n \
|
||||
--rawfile full /tmp/hub-full.md \
|
||||
--arg short "Linux container with the pi coding-agent, MemPalace, and curated dev tooling." \
|
||||
'{"full_description": $full, "description": $short}' | \
|
||||
curl -s -o /tmp/hub-response.txt -w "%{http_code}" -X PATCH \
|
||||
"https://hub.docker.com/v2/repositories/${{ vars.DOCKERHUB_USERNAME }}/pi-devbox/" \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d @-)
|
||||
if [ "$HTTP_CODE" != "200" ]; then
|
||||
echo "Response body:"
|
||||
cat /tmp/hub-response.txt
|
||||
echo "::error::Docker Hub description update failed with HTTP $HTTP_CODE"
|
||||
exit 1
|
||||
fi
|
||||
echo "Description updated (pi version: ${PI_VERSION})."
|
||||
|
||||
@@ -1,37 +1,142 @@
|
||||
# AGENTS.md — pi-devbox
|
||||
|
||||
Container image that adds pi coding-agent on top of the opencode-devbox base image.
|
||||
Self-contained Docker image for the **pi coding-agent**. Decoupled from
|
||||
opencode-devbox at v1.0.0 (2026-06-09); previously pi-devbox was a thin
|
||||
re-brand of opencode-devbox's `pi-only` variant.
|
||||
|
||||
## Repository layout
|
||||
|
||||
- `Dockerfile` — single-stage build, `FROM opencode-devbox:base-latest`, installs pi + companion repos
|
||||
- `docker-compose.yml` — compose file for local use
|
||||
- `.env.example` — environment variable template
|
||||
- `scripts/smoke-test.sh` — sanity checks run by CI before pushing to Docker Hub
|
||||
- `.gitea/workflows/docker-publish.yml` — CI pipeline: smoke amd64 → multi-arch push → update Hub description
|
||||
- `Dockerfile.base` — multi-arch base layer with system packages,
|
||||
GitHub-binary tools (fzf, eza, zoxide, neovim, bat, gosu, gitleaks,
|
||||
git-lfs, uv, gitea-mcp, tealdeer), AWS CLI v2, mempalace + toolkit,
|
||||
Node.js, Python toolchain, locales, ssh ControlMaster defaults, and
|
||||
`/etc/tmux.conf` with 0-indexed sessions.
|
||||
- `Dockerfile.variant` — `FROM base-<hash>`, adds pi + companions
|
||||
(`pi-toolkit`, `pi-extensions`, `pi-fork`, `pi-observational-memory`)
|
||||
and, when `INSTALL_STUDIO=true`, vendors `pi-studio` to `/opt/pi-studio`
|
||||
(`-studio` variant).
|
||||
- `entrypoint.sh` — UID/GID alignment as root, then drops to `developer`.
|
||||
- `entrypoint-user.sh` — per-container start: SSH ControlMaster socket
|
||||
dir, LAN-access setup, MemPalace init, pi-toolkit + pi-extensions
|
||||
deploy, mempalace-bridge symlink, fork/recall + pi-studio pi-install,
|
||||
optional `studio-expose` bridge (when `STUDIO_EXPOSE=1`), skillset
|
||||
deploy.
|
||||
- `rootfs/` — files baked into the image (bash aliases, inputrc,
|
||||
setup-lan-access.sh, `studio-expose` helper).
|
||||
- `scripts/smoke-test.sh` — sanity checks run by CI before pushing to Hub.
|
||||
- `.gitea/workflows/docker-publish.yml` — two-phase CI (base-decide →
|
||||
build-base → smoke → build-variant → promote-base-latest →
|
||||
update-description). The `-studio` variant adds independent
|
||||
`smoke-studio` + `build-variant-studio` jobs that gate only the
|
||||
`-studio` tags (never the core `:latest` release).
|
||||
|
||||
## Versioning scheme
|
||||
|
||||
- Tags follow the pi npm version: `v{pi_version}[letter]`
|
||||
- Bump `PI_VERSION` build-arg default in `Dockerfile` when cutting a new release
|
||||
- Docker Hub: `joakimp/pi-devbox:vX.Y.Z` + `joakimp/pi-devbox:latest`
|
||||
- Tags follow semver. **v1.0.0** is the first decoupled release; future
|
||||
minor bumps add variants (`-studio`, `-studio-tex`); patch bumps follow
|
||||
pi npm version updates and small fixes.
|
||||
- Docker Hub tags: `joakimp/pi-devbox:vX.Y.Z` + `joakimp/pi-devbox:latest`
|
||||
+ (since v1.1.0) `joakimp/pi-devbox:vX.Y.Z-studio` +
|
||||
`joakimp/pi-devbox:latest-studio`.
|
||||
Internal tags: `joakimp/pi-devbox:base-<hash>` (content-addressed) +
|
||||
`joakimp/pi-devbox:base-latest` (alias of most recent base).
|
||||
|
||||
## Release-day checklist
|
||||
|
||||
1. Bump `PI_VERSION` in `Dockerfile` (or leave as `latest` to pick up current)
|
||||
2. Update `CHANGELOG.md`: promote `Unreleased` → `vX.Y.Z — YYYY-MM-DD`
|
||||
3. Add fresh `## Unreleased` section
|
||||
4. Commit, tag `vX.Y.Z`, push tag → CI fires automatically
|
||||
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`).
|
||||
2. Update `CHANGELOG.md` Unreleased → vX.Y.Z section.
|
||||
3. Verify `docker compose up` works locally with the current `latest` image
|
||||
if you're upgrading users from a previous version.
|
||||
4. Push tag: `git tag vX.Y.Z && git push origin vX.Y.Z`.
|
||||
5. Watch CI: smoke job builds amd64 only and asserts size + extensions +
|
||||
pi version + new-base-tooling presence. Variant build is multi-arch
|
||||
(amd64 + arm64) only after smoke passes.
|
||||
6. Verify the Hub tags appear (latest + vX.Y.Z, the `-studio` pair, plus
|
||||
base-latest if the base was rebuilt this run).
|
||||
7. **Revoke any short-lived Gitea PAT** used during the release at
|
||||
`gitea.jordbo.se/user/settings/applications`.
|
||||
|
||||
## Key facts
|
||||
## Cache-hit footgun (must-know)
|
||||
|
||||
- **Base image**: `joakimp/opencode-devbox:base-latest` — rebuilt whenever opencode-devbox cuts a new base
|
||||
- **pi binary**: baked at `/usr/bin/pi` (system npm prefix); `NPM_CONFIG_PREFIX=/home/developer/.pi/npm-global` at runtime so user-installed pi/packages land on the named volume
|
||||
- **Companion repos**: pi-toolkit and pi-extensions cloned to `/opt/` at build time; `entrypoint-user.sh` (inherited from base) deploys symlinks to `~/.pi/agent/` on container start
|
||||
- **MemPalace**: fully operational — inherited from base image; bridge extension deployed by entrypoint
|
||||
`PI_VERSION` defaults to `latest` in `Dockerfile.variant` but **CI must
|
||||
resolve it to a concrete version string** before passing as a build-arg.
|
||||
Otherwise the build-arg string is byte-identical across releases →
|
||||
identical layer hash → registry buildcache silently reuses the old
|
||||
layer. `resolve-versions` job in the workflow handles this.
|
||||
|
||||
## Conventions
|
||||
Discovered in pi-devbox 2026-05-23 (every release v0.74.0..v0.75.5
|
||||
shipped the same image bytes); preventatively fixed for `PI_VERSION` +
|
||||
`PI_FORK_REF` + `PI_OBSMEM_REF`.
|
||||
|
||||
- Do NOT call `mempalace-toolkit/install.sh` in the Dockerfile — the base entrypoint handles it
|
||||
- `NPM_CONFIG_PREFIX=/usr` must be set per-RUN for any build-time `npm install -g` to keep baked binaries off the volume-shadowed path
|
||||
- The smoke test threshold is 2200 MB — update if the image legitimately grows past it
|
||||
## Smoke-test gate
|
||||
|
||||
`scripts/smoke-test.sh` runs amd64-only against a freshly-built variant
|
||||
image. Verifies binaries, repo clones, runtime deployment (waits for
|
||||
keybindings + mempalace bridge + ≥4 extensions before sampling — fixes
|
||||
the parallel-build-load race documented in opencode-devbox c6f9d11
|
||||
2026-06-08), and image size threshold (3500 MB; revisit after a few
|
||||
releases as actuals settle).
|
||||
|
||||
If smoke fails on size threshold but build is otherwise fine: bump
|
||||
`SIZE_THRESHOLD_MB` in scripts/smoke-test.sh in a follow-up commit and
|
||||
re-run. The threshold exists to catch *runaway* growth (an accidental
|
||||
texlive bake-in, a forgotten chrome dependency), not to block ordinary
|
||||
upstream bumps.
|
||||
|
||||
## Build pipeline notes
|
||||
|
||||
- **Two-phase**: base + variant. Base is rebuilt only when
|
||||
`Dockerfile.base`, `rootfs/`, or `entrypoint*.sh` change (CI computes
|
||||
a content hash and probes Hub for an existing `base-<hash>` tag).
|
||||
- **`base-latest` alias** is promoted from `base-<hash>` via `crane copy`
|
||||
(manifest copy, no rebuild) only when the base actually changed.
|
||||
- **`docker buildx build --push` retry**: 3 attempts with backoff for
|
||||
transient Hub blips. Deterministic failures fail all 3 and the job
|
||||
fails as expected.
|
||||
- **Registry buildcache disabled**: buildkit's cache-export hits HTTP 400
|
||||
on Hub CDN since ~2026-05-23. Image push works fine; we pay the full
|
||||
base build on Dockerfile.base change, but base tags are content-
|
||||
addressed so unchanged bases short-circuit at the probe step.
|
||||
|
||||
## Decoupling history (briefly)
|
||||
|
||||
Pre-v1.0.0 pi-devbox was `FROM joakimp/pi-devbox:base-pi-only`, where
|
||||
`base-pi-only` was a tag built by **opencode-devbox CI** (with
|
||||
`INSTALL_OPENCODE=false` in their variant Dockerfile) and pushed under
|
||||
the pi-devbox repo as an internal building-block tag. This setup
|
||||
required rebuilding opencode-devbox before pi-devbox could be tagged
|
||||
and meant pi-devbox docs needed cross-referencing into opencode-devbox.
|
||||
|
||||
v1.0.0 brings pi install logic into this repo, drops the cross-repo
|
||||
dependency, and the `base-pi-only*` tags from opencode-devbox become
|
||||
deprecated artifacts (to be removed in opencode-devbox v2.0.0).
|
||||
|
||||
## What we DON'T install (and why)
|
||||
|
||||
- **No texlive** (~600 MB–1 GB). Users who need PDF export from pandoc
|
||||
or pi-studio can install on demand: `sudo apt-get install texlive-xetex
|
||||
texlive-latex-recommended`. The planned `:latest-studio-tex` variant
|
||||
will bake this in.
|
||||
- **pi-studio** ships in the `:latest-studio` variant (since v1.1.0),
|
||||
vendored to `/opt/pi-studio` and registered at container start via
|
||||
`pi install /opt/pi-studio` (see Dockerfile.variant `INSTALL_STUDIO`).
|
||||
The default `:latest` image stays studio-free. Note: pi-studio binds
|
||||
`127.0.0.1` inside the container, so browser access needs host
|
||||
networking or the bundled `studio-expose` bridge (socat; auto-starts
|
||||
when `STUDIO_EXPOSE=1`) — see README "Using pi-studio".
|
||||
- **No Julia/R/GHCi/Clojure runtimes**. Use `uv run --with X` for
|
||||
Python REPLs; `apt install` other-language runtimes ad-hoc per
|
||||
container if needed.
|
||||
|
||||
## Backward compatibility
|
||||
|
||||
- The host `~/.mempalace` bind-mount path is unchanged.
|
||||
- Volume names (`devbox-pi-config`, `devbox-ssh-local`,
|
||||
`devbox-shell-history`, `devbox-zoxide`, `devbox-nvim-data`,
|
||||
`devbox-uv`; optional `devbox-palace`, `devbox-chroma-cache`) are
|
||||
unchanged.
|
||||
- `~/.pi/agent/` layout inside the container is unchanged; existing
|
||||
named volumes work without recreation.
|
||||
- The `:latest` and `vX.Y.Z` Hub tags continue to point at a "base + pi"
|
||||
image. Same tag, same shape, just built differently.
|
||||
|
||||
+396
-1
@@ -2,12 +2,407 @@
|
||||
|
||||
All notable changes to the pi-devbox container image.
|
||||
|
||||
Tags follow the pi npm version: `v{pi_version}[letter]` — bare tag for the first build on a new pi release, letter suffix (`b`, `c`, …) for container-level rebuilds on the same version.
|
||||
From v1.0.0 onward, tags follow semver:
|
||||
- **major** — architectural changes (v1.0.0 = decoupled from opencode-devbox)
|
||||
- **minor** — new variants, significant base additions
|
||||
- **patch** — pi version bumps, smaller fixes
|
||||
|
||||
Pre-v1.0.0 tags followed the pi npm version (`v{pi_version}[letter]`).
|
||||
|
||||
---
|
||||
|
||||
## Unreleased
|
||||
|
||||
## v1.1.0 — 2026-06-10
|
||||
|
||||
### Added — `:latest-studio` variant
|
||||
|
||||
- **New `-studio` image variant** bundling
|
||||
[pi-studio](https://github.com/omaclaren/pi-studio) — a two-pane
|
||||
browser workspace (prompt/response editor, live KaTeX/Mermaid preview,
|
||||
tmux-backed literate REPLs for Shell/Python/IPython/Julia/R/GHCi/Clojure)
|
||||
plus the `/studio` slash command and `studio_repl_send` /
|
||||
`studio_export_*` agent tools. Published as `:latest-studio` and
|
||||
`:vX.Y.Z-studio` (multi-arch).
|
||||
- pi-studio is **vendored to `/opt/pi-studio`** at build time (gated by
|
||||
`INSTALL_STUDIO=true`, ref pinned via CI-resolved `PI_STUDIO_REF`) and
|
||||
registered on container start by `entrypoint-user.sh` via
|
||||
`pi install /opt/pi-studio` — the same pattern as pi-fork /
|
||||
pi-observational-memory. No build step: pi-studio ships its browser
|
||||
bundle prebuilt in git. The non-studio `:latest` image is unchanged.
|
||||
- CI gains independent `smoke-studio` + `build-variant-studio` jobs that
|
||||
gate **only** the studio tags, so a studio build/smoke failure can
|
||||
never block the core `:latest` / `:vX.Y.Z` release.
|
||||
- `STUDIO_PORT=8765` baked as an advisory default.
|
||||
- **`studio-expose` helper + `socat` (base).** Because pi-studio binds the
|
||||
container's loopback, a published Docker port can't reach it. The new
|
||||
`studio-expose` helper (socat, added to the base) bridges the container's
|
||||
loopback to its egress interface on the same port; set `STUDIO_EXPOSE=1`
|
||||
in compose to auto-start it on boot (default off — Studio stays
|
||||
loopback-only otherwise). `socat` is in the base for all variants.
|
||||
- **README "Using pi-studio" section.** Documents the container access
|
||||
reality: pi-studio hard-binds `127.0.0.1` inside the container
|
||||
(`.listen(port,"127.0.0.1")`, no `--host` flag), so a plain `-p`
|
||||
publish does not reach it. Documents the two working paths — host
|
||||
networking (recommended on OrbStack) and a loopback bridge for bridge
|
||||
networking — plus the remote `ssh -L` forward and the **mosh caveat**
|
||||
(mosh cannot forward ports; run a parallel `ssh -L` alongside it).
|
||||
|
||||
## v1.0.1 — 2026-06-10
|
||||
|
||||
Patch release. Works around an upstream MemPalace bug that broke pi at
|
||||
first prompt against the Anthropic Claude API.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **`mempalace_diary_write` schema rejected by Anthropic API.** Mempalace
|
||||
3.3.x and 3.4.0 advertise `diary_write`'s `input_schema` with a
|
||||
top-level `anyOf: [{required:[entry]}, {required:[content]}]` to
|
||||
express "either `entry` or `content` must be supplied". Anthropic's
|
||||
tools API rejects top-level `anyOf` / `oneOf` / `allOf` outright, so
|
||||
pi failed to register tools at session start with
|
||||
`tools.<n>.custom.input_schema: input_schema does not support oneOf,
|
||||
allOf, or anyOf at the top level`. `Dockerfile.base` now patches the
|
||||
installed `mcp_server.py` after `uv tool install` to drop the `anyOf`
|
||||
block and require `["agent_name", "entry"]` instead. The mempalace
|
||||
handler still accepts `content` server-side as a kwarg alias, so
|
||||
callers using either name keep working. Tracked upstream:
|
||||
[issue #1728](https://github.com/MemPalace/mempalace/issues/1728),
|
||||
[PR #1735](https://github.com/MemPalace/mempalace/pull/1735).
|
||||
The workaround is idempotent + self-deactivating and will be removed
|
||||
once a fixed mempalace release lands on PyPI.
|
||||
|
||||
### Changed
|
||||
|
||||
- **Mempalace pinned to 3.4.0** via `MEMPALACE_VERSION` build arg.
|
||||
Future bumps must be a reviewable diff rather than an implicit pull
|
||||
of `latest` (the broken 3.3.x/3.4.0 schema slipping in unannounced
|
||||
is what caused this release).
|
||||
|
||||
## v1.0.0 — 2026-06-09
|
||||
|
||||
**Decoupled from opencode-devbox.** pi-devbox is now self-contained:
|
||||
own `Dockerfile.base` + `Dockerfile.variant`, own CI pipeline, own
|
||||
release cadence. Previously v0.79.0 and earlier were thin re-brands of
|
||||
the `pi-only` variant built by opencode-devbox CI.
|
||||
|
||||
### Architectural
|
||||
|
||||
- **Self-contained build chain.** `Dockerfile.base` produces
|
||||
`joakimp/pi-devbox:base-<hash>` (content-addressed); `Dockerfile.variant`
|
||||
FROMs the base and adds the pi install. Replaces the prior 5-line
|
||||
`Dockerfile` shim that FROMed `joakimp/pi-devbox:base-pi-only` (an
|
||||
opencode-devbox CI artifact).
|
||||
- **No more publish-ordering coupling.** pi-devbox releases no longer
|
||||
require rebuilding opencode-devbox first.
|
||||
- **Adapted from opencode-devbox** at the time of decoupling — the
|
||||
apt set, ssh ControlMaster setup, MemPalace integration, entrypoint
|
||||
UID/GID dance, and CI pipeline shape are all derived from there. See
|
||||
Acknowledgements in README.md.
|
||||
- **CI workflow** rewritten as two-phase split-base build pipeline
|
||||
(mirrors opencode-devbox's `docker-publish-split.yml` shape, simplified
|
||||
to a single variant). Includes `crane`-based `base-latest` promotion,
|
||||
registry-buildcache footgun guard via concrete `PI_VERSION` resolution,
|
||||
and the c6f9d11 smoke-test gate (waits for keybindings + mempalace.ts
|
||||
+ ≥4 *.ts before sampling).
|
||||
|
||||
### Added (base image)
|
||||
|
||||
- **pandoc** — universal Markdown↔HTML/Org/RST/etc. conversion. ~200 MB.
|
||||
- **graphviz** — `dot` rendering for diagram pipelines. ~10 MB.
|
||||
- **imagemagick** — image conversion (invoked as `magick`, not `convert`,
|
||||
in v7+). ~50 MB.
|
||||
- **yq** — YAML-aware companion to jq.
|
||||
- **tldr (tealdeer)** — Rust port of tldr-pages, ~5 MB static binary.
|
||||
Replaced the Node `tldr` global (which was ~140 MB).
|
||||
- **`/etc/tmux.conf`** with `set -g base-index 0` + `set -g
|
||||
pane-base-index 0`. Required for the planned `:latest-studio`
|
||||
variant; pi-studio hard-codes its tmux send target to `:0.0`. User-
|
||||
level `~/.tmux.conf` overrides still win.
|
||||
|
||||
### Added (smoke test)
|
||||
|
||||
- Asserts pandoc, graphviz, imagemagick, yq, and tldr are present.
|
||||
- Asserts `/etc/tmux.conf` has the 0-indexed config baked.
|
||||
- Asserts `/tmp/sshcm/` directory created mode 700 by entrypoint.
|
||||
- Image-size measurement now sums `docker history` layer sizes (the
|
||||
prior `image inspect --format='{{.Size}}'` approach returned only
|
||||
the variant-unique layer when the base was content-addressed and
|
||||
shared, understating the user-facing image size by 2+ GB).
|
||||
- Size threshold raised to 3500 MB (was 2850) to cover the new base
|
||||
additions plus +200 MB safety margin. Tighten in a follow-up release
|
||||
once amd64 actuals settle.
|
||||
|
||||
### Image size
|
||||
|
||||
Local arm64 build of `pi-devbox-test:latest` (this branch's content):
|
||||
3.20 GB. Up ~390 MB from the prior pi-only-equivalent (~2.81 GB) due
|
||||
to pandoc, graphviz, imagemagick, yq, and minor expansion in pi npm
|
||||
dependencies.
|
||||
|
||||
### Migration notes
|
||||
|
||||
- Existing volumes (`devbox-pi-config`, `devbox-bash-history`,
|
||||
`devbox-nvim-data`, `devbox-uv-tools`, `devbox-chroma-cache`) are
|
||||
unchanged in name and structure. `docker compose pull && docker
|
||||
compose up -d --force-recreate` is a clean upgrade path.
|
||||
- The `:latest` and `vX.Y.Z` Hub tags continue to point at a "base +
|
||||
pi" image. Same shape, just built differently.
|
||||
- `:base-pi-only` and `:base-pi-only-vX.Y.Z` tags from prior releases
|
||||
remain on Hub for now; will be deprecated when opencode-devbox
|
||||
retires the pi paths in its next major release.
|
||||
|
||||
### Future work
|
||||
|
||||
- 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).
|
||||
|
||||
## v0.79.0 — 2026-06-08
|
||||
|
||||
First build on pi **`0.79.0`** (upstream `@earendil-works/pi-coding-agent` bump
|
||||
from `0.78.1`). Built `FROM` the freshly republished
|
||||
`joakimp/pi-devbox:base-pi-only` from opencode-devbox `v1.16.2`, which carries
|
||||
pi `0.79.0` (and picks up opencode `1.16.2` in the sibling opencode-bearing
|
||||
variants, though this pi-only image has no opencode).
|
||||
|
||||
### Bumped: pi 0.78.1 → 0.79.0
|
||||
|
||||
Resolved from the tag and asserted by the smoke base-freshness guard
|
||||
(`EXPECTED_PI_VERSION`). Highlights from the upstream `CHANGELOG.md`:
|
||||
|
||||
- **Project trust for local inputs** — pi now asks before loading project-local
|
||||
settings, resources, instructions, and packages, with saved decisions and
|
||||
`--approve` / `--no-approve` controls for non-interactive modes, plus a
|
||||
`project_trust` extension event so global/CLI extensions can decide or defer.
|
||||
- **Cache-hit visibility in the footer** — the interactive footer shows the
|
||||
latest prompt cache hit rate (`CH`).
|
||||
- **Richer SDK/RPC extension surfaces** — public exports now include RPC
|
||||
extension UI request/response types and package asset path helpers.
|
||||
- Plus a large batch of TUI and provider fixes (Kitty keyboard fallback,
|
||||
prompt-history cursor placement, large-JSONL session reads, custom-provider
|
||||
routing).
|
||||
|
||||
### Smoke size threshold 2750 → 2850 MB
|
||||
|
||||
Tracks opencode-devbox's `pi-only` variant, which was raised to 2850 MB in
|
||||
`v1.16.2` for headroom against the pi `0.79.0` bump (and routine apt drift).
|
||||
Kept in lockstep so this image's guard matches its source-of-truth variant.
|
||||
|
||||
## v0.78.1 — 2026-06-04
|
||||
|
||||
First build on pi **`0.78.1`** (upstream `@earendil-works/pi-coding-agent` bump
|
||||
from `0.78.0`). Built `FROM` the freshly republished
|
||||
`joakimp/pi-devbox:base-pi-only` from opencode-devbox `v1.15.13e`, which carries
|
||||
pi `0.78.1` plus the LAN-jump key-persistence work and the `devbox-ssh-local`
|
||||
volume ownership fix. Adds compose/env documentation in this repo.
|
||||
|
||||
### Added: persist the LAN-jump key + one-line authorize hint
|
||||
|
||||
- **compose:** persist `~/.ssh-local` via a new `devbox-ssh-local` named volume
|
||||
so the generated LAN-jump key survives `docker compose up --force-recreate`.
|
||||
You authorize the key on the host **once per machine** instead of after every
|
||||
container update.
|
||||
- **Inherited from base:** `setup-lan-access.sh` now prints a copy-paste
|
||||
`echo '…' >> ~/.ssh/authorized_keys` line when it generates a new key
|
||||
(published via opencode-devbox's `base-pi-only`). No helper file to locate.
|
||||
|
||||
### Docs: document optional host-owned config in the compose + env templates
|
||||
|
||||
- **compose:** added a commented-out `~/.config/devbox-shell` bind mount with a
|
||||
note — the image's `~/.bash_aliases` sources
|
||||
`~/.config/devbox-shell/bash_aliases` if present, and `setup-lan-access.sh`
|
||||
reads `~/.config/devbox-shell/ssh-lan.conf` for named-peer `ProxyJump host`
|
||||
overrides (reach LAN peers by name via `dssh <peer>`).
|
||||
- **.env.example:** documented `DEVBOX_HOST_ALIAS` (host hostname to reach,
|
||||
default `host.docker.internal`) so getting-started is self-contained.
|
||||
|
||||
Template/example comments only; no behavior change.
|
||||
|
||||
## v0.78.0c — 2026-06-04
|
||||
|
||||
### Fixed / Added (inherited from the base via `FROM`)
|
||||
|
||||
LAN-access improvements made in opencode-devbox's `setup-lan-access.sh` (baked
|
||||
into the `base-pi-only` image, published by opencode-devbox v1.15.13d) flow
|
||||
through to pi-devbox automatically — no pi-devbox source change. Built `FROM`
|
||||
the rebuilt `joakimp/pi-devbox:base-pi-only` (digest `83b45335…`):
|
||||
|
||||
- **Fixed:** the generated `~/.ssh-local/config` had `Include ~/.ssh/config`
|
||||
scoped to the `host`/`mac` block, so `dssh <peer>` by name was ignored.
|
||||
- **Fixed:** read-only `~/.ssh/cm` ControlPath broke multiplexed hosts
|
||||
(`pmx-jh`, `proxmox*`, …); master sockets now use the writable sidecar.
|
||||
- **Added:** host-owned `~/.config/devbox-shell/ssh-lan.conf` for named-peer
|
||||
`ProxyJump host` overrides (Included before `~/.ssh/config`).
|
||||
- **Added:** `DEVBOX_LAN_AUTOJUMP_PRIVATE=1` — ProxyJump any RFC1918 IP through
|
||||
the host for roaming laptops.
|
||||
|
||||
## v0.78.0b — 2026-06-03
|
||||
|
||||
Container-level rebuild on pi `0.78.0` (unchanged): re-brands the pi-only build
|
||||
as a thin `FROM joakimp/pi-devbox:base-pi-only`, inheriting fork/recall and
|
||||
host-OS-agnostic LAN access. Letter-suffix release (pi version unchanged).
|
||||
|
||||
### Changed: refactored to re-brand the opencode-devbox `pi-only` variant
|
||||
|
||||
pi-devbox no longer installs pi itself. The `Dockerfile` is now a thin
|
||||
`FROM joakimp/pi-devbox:base-pi-only` (overridable via the `BASE_IMAGE`
|
||||
arg), inheriting pi + pi-toolkit + pi-extensions and all base tooling from the
|
||||
single source of truth. This eliminates the install-logic duplication that
|
||||
used to drift against `opencode-devbox/Dockerfile.variant`.
|
||||
|
||||
The pi-only artifact is **built** by opencode-devbox's CI (from
|
||||
`opencode-devbox/Dockerfile.variant` with `INSTALL_OPENCODE=false`) but is
|
||||
**published into this repo** as the internal building-block tag
|
||||
`joakimp/pi-devbox:base-pi-only` (+ `base-pi-only-vX.Y.Z`, where `vX.Y.Z` is
|
||||
the opencode-devbox release version). This supersedes the brief approach of
|
||||
publishing it as `opencode-devbox:latest-pi-only` — an "opencode-devbox" tag
|
||||
with no opencode in it confused users. `base-pi-only` is internal; end users
|
||||
pull `joakimp/pi-devbox:latest` or a `vX.Y.Z` tag.
|
||||
|
||||
The pi-only build uses `INSTALL_OPENCODE=false`, so this image
|
||||
stays lean and pi-focused — it does **not** carry opencode, and remains
|
||||
distinct from `opencode-devbox:latest-with-pi` (which has both).
|
||||
|
||||
### Added (inherited from the pi-only variant)
|
||||
|
||||
- **`fork` tool** (pi-fork) and **`recall` tool** (pi-observational-memory),
|
||||
baked into `/opt` with `node_modules` and registered at runtime.
|
||||
- **Host-OS-agnostic LAN access**: on VM-backed hosts (macOS OrbStack /
|
||||
Docker Desktop) the entrypoint sets up the host as an SSH jump to reach LAN
|
||||
peers (`dssh` alias; `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER` env). No-op on
|
||||
native Linux. See the opencode-devbox README for details.
|
||||
|
||||
### Consequences / notes
|
||||
|
||||
- **Publish ordering**: release opencode-devbox first so `base-pi-only`
|
||||
carries the target pi version, *then* tag this repo. The smoke test asserts
|
||||
`pi --version` matches the tag and fails loudly if the base is stale.
|
||||
- CI no longer passes `PI_VERSION` as a build-arg (the Dockerfile installs
|
||||
nothing); it still resolves the tag version to feed the smoke base-freshness
|
||||
guard. Smoke size threshold 2200 → 2750 MB (now tracks the pi-only variant).
|
||||
|
||||
_pi version unchanged at `0.78.0` (still latest)._
|
||||
|
||||
## v0.78.0 — 2026-05-29
|
||||
|
||||
pi `0.77.0` → `0.78.0` bump (first container build on the pi 0.78 line, published upstream 2026-05-29). Built against `joakimp/opencode-devbox:base-latest` (unchanged from the v0.77.0 build).
|
||||
|
||||
### Bumped: pi 0.77.0 → 0.78.0
|
||||
|
||||
**New Features**
|
||||
- **Named startup sessions** — `--name` / `-n` sets the session display name before startup across interactive, print, JSON, and RPC modes.
|
||||
- **Clickable file tool paths** — built-in file tool titles render OSC 8 `file://` hyperlinks when the terminal supports them, including supported tmux clients.
|
||||
|
||||
**Added**
|
||||
- Exported `convertToPng` for extension authors.
|
||||
- Exported `parseArgs` and type `Args` for extension authors.
|
||||
- Added a resume command hint when exiting interactive sessions.
|
||||
- Added custom Amazon Bedrock request header support.
|
||||
|
||||
**Fixed**
|
||||
- Fixed early interactive input typed before the prompt loop starts so it is buffered instead of dropped.
|
||||
- Fixed OpenRouter Moonshot Kimi K2.6 requests to use `system` instead of unsupported `developer` messages.
|
||||
- Fixed OSC 8 hyperlinks to pass through tmux when the client supports them.
|
||||
- Fixed ANSI text wrapping to avoid stack overflows on very long wrapped lines.
|
||||
- Fixed OpenAI Codex Responses SSE streams to abort response body reads after terminal events.
|
||||
|
||||
## v0.77.0 — 2026-05-29
|
||||
|
||||
pi `0.76.0` → `0.77.0` bump (first container build on the pi 0.77 line, published upstream 2026-05-28). Built against `joakimp/opencode-devbox:base-latest` (unchanged from the v0.76.0 build — same SSH-CM, gitleaks, git-crypt baked in).
|
||||
|
||||
### Bumped: pi 0.76.0 → 0.77.0
|
||||
|
||||
Notable upstream changes (from pi's CHANGELOG):
|
||||
|
||||
- **Claude Opus 4.8 support** — Anthropic Opus 4.8 model metadata + adaptive-thinking coverage updated.
|
||||
- **Selective tool disablement** — `--exclude-tools` / `-xt` disables specific built-in, extension, or custom tools while leaving the rest available.
|
||||
- **Headless Codex subscription login** — `/login` can use device-code auth for ChatGPT Plus/Pro Codex subscriptions; browser login remains the default.
|
||||
- **Streaming-aware extension input** — `InputEvent.streamingBehavior` lets extensions distinguish idle prompts from mid-stream steers and queued follow-ups.
|
||||
- **Bugfixes** — startup timing output excludes `createAgentSessionRuntime` work; OpenRouter DeepSeek V4 `xhigh` reasoning preserves OpenRouter's native effort; SIGTERM/SIGHUP exits run extension `session_shutdown` cleanup; keyboard protocol negotiation ignores delayed terminal responses (no false Kitty detection); Windows MSYS2 ucrt64 startup crash fixed via napi-rs 3.x clipboard addon; API-key/header config resolution treats plain strings as literals with `$ENV_VAR` / `${ENV_VAR}` interpolation and `$!` escaping; session disposal aborts in-flight agent/compaction/branch-summary/retry/bash work; `pi.getAllTools()` exposes per-tool `promptGuidelines`; OpenAI Codex Responses replay after switching from Anthropic extended-thinking sessions; Anthropic-compatible replay supports `allowEmptySignature` for providers returning empty thinking signatures; OpenAI/OpenRouter GPT-5.5 Pro thinking levels limited to supported efforts; OpenCode Go Kimi K2.6 thinking-off requests; Xiaomi Token Plan model metadata cleaned of unsupported variants; follow-up messages queued by `agent_end` extension handlers drain before idle; system prompt tool-selection guidance avoids unavailable file-exploration tools; fenced `diff` highlighting restored.
|
||||
|
||||
Workflow continues to derive `PI_VERSION` from the git tag (`v0.77.0` → `0.77.0`) and pass it as a build-arg per the v0.75.5b cache-hit fix; smoke test asserts `pi --version` matches.
|
||||
|
||||
### Inheritance from base
|
||||
|
||||
No base change in `joakimp/opencode-devbox:base-latest` since v0.76.0 — the v1.15.12 opencode-devbox release also reused the unchanged base. SSH ControlMaster on a writable socket path, gitleaks, and git-crypt continue to ride along from the base.
|
||||
|
||||
### CI
|
||||
|
||||
This is the second pi-devbox release exercising the cache-export-disabled workflow (after v0.76.0's clean publish on run #340) and the first to also exercise the 3-attempt retry wrapper added in 2d39766 along the publish path.
|
||||
|
||||
## v0.76.0 — 2026-05-28
|
||||
|
||||
pi `0.75.5` → `0.76.0` bump (first minor-version release on pi 0.76 line, published upstream 2026-05-27 20:03 UTC). Built against a fresh `joakimp/opencode-devbox:base-latest` which now bakes in SSH ControlMaster on a writable socket path, plus gitleaks and git-crypt — see the inherited-from-base notes below for details on each.
|
||||
|
||||
### Bumped: pi 0.75.5 → 0.76.0
|
||||
|
||||
Notable upstream changes (from pi's CHANGELOG):
|
||||
|
||||
- **Explicit session IDs for automation** — `--session-id <id>` lets scripts create or resume an exact project-local session.
|
||||
- **RPC bash output can stay out of model context** — RPC clients can pass `excludeFromContext` to `bash` for commands whose output should not be sent with the next prompt.
|
||||
- **More predictable provider retries and timeouts** — Codex WebSocket/SSE waits are bounded; `retry.provider.maxRetries` controls provider retries instead of hidden SDK defaults; SDK retries default to 0; quota/billing 429s are no longer retried behind Pi's retry handling.
|
||||
- **Better terminal editing across environments** — Apple Terminal Shift+Enter detection on macOS, Windows Terminal OSC 8 hyperlink support, JetBrains truecolor with disabled OSC 8, Unicode-aware word navigation and deletion.
|
||||
- **Bugfixes** — `pi update` bypasses npm/pnpm/Bun minimum-release-age gates; user-authored ordered-list markers preserved in transcripts; image attachment token estimates aligned with tool-result images; Codex Responses cache-affinity header fixed (`session-id` not `session_id`); OpenRouter/Poolside context-overflow detection; managed npm extension updates avoid peer-dependency conflicts; RpcClient handles unexpected child exits cleanly.
|
||||
|
||||
Workflow continues to derive `PI_VERSION` from the git tag (`v0.76.0` → `0.76.0`) and pass it as a build-arg, per the v0.75.5b cache-hit fix; smoke test asserts `pi --version` matches.
|
||||
|
||||
### Workflow change: registry cache-export disabled
|
||||
|
||||
- **`.gitea/workflows/docker-publish.yml`** — `cache-from`/`cache-to` removed from the `publish` step. buildkit's `mode=max` cache-export to `registry-1.docker.io` reproducibly returns HTTP 400 on the resumable-upload PUT, surfacing ~2026-05-23. Diagnosed during opencode-devbox v1.15.12's manual host-side publish: image push works fine, only `--cache-to` fails. See opencode-devbox CHANGELOG v1.15.12 `Unreleased` for the full root-cause analysis. The pi-devbox Dockerfile is single-stage with a tiny diff (npm install pi only) on top of `base-latest`, so builds are fast even without cache (~30-60s expected).
|
||||
|
||||
### Inherited from opencode-devbox base: SSH ControlMaster on a writable socket path
|
||||
|
||||
No Dockerfile change here — just a note that this release picks up the system-wide SSH ControlMaster default (`/etc/ssh/ssh_config.d/00-devbox-controlmaster.conf` → `ControlPath /tmp/sshcm/%r@%h:%p`, `ControlMaster auto`, `ControlPersist 10m`). This unblocks `ssh` and `pi --ssh user@host` from inside the container when `~/.ssh` is bind-mounted read-only from the host (the standard pi-devbox compose layout) — previously, OpenSSH's default `ControlPath` under `~/.ssh/cm/` was unwritable, so multiplexing failed with `unix_listener: cannot bind ... Read-only file system` and ssh fell back to fresh TCP connections, which on residential CGNAT manifested as banner-exchange timeouts. The fix is purely additive (per-container `/tmp/sshcm` dir, mode 700, created by entrypoint) and user `~/.ssh/config` per-host overrides still win because Debian's stock `ssh_config` sources `ssh_config.d/*.conf` before its own `Host *` block. See opencode-devbox CHANGELOG `v1.15.12` for the base-side details.
|
||||
|
||||
### Inherited from opencode-devbox base: gitleaks + git-crypt
|
||||
|
||||
No Dockerfile change here — just a note that this release includes `gitleaks` (newly added to the base) and `git-crypt` (was always installed via apt; just wasn't called out). Both are useful inside the container for repos that use a gitleaks pre-commit hook or git-crypt-encrypted canonical config and don't want host-side dependencies. See opencode-devbox CHANGELOG `v1.15.12` for the base-side details.
|
||||
|
||||
## v0.75.5b — 2026-05-23
|
||||
|
||||
Recovery release fixing a **silent cache-hit regression** discovered in the v0.75.5 image. All four releases v0.74.0 through v0.75.5 had been shipping the same image bytes because the Dockerfile's `npm install -g @earendil-works/pi-coding-agent` (bare, when `PI_VERSION=latest`) produces an identical layer-hash across builds. Combined with the registry buildcache, Docker reused the layer from whatever pi version was current when the cache was first populated.
|
||||
|
||||
Verification: `docker manifest inspect joakimp/pi-devbox:vX.Y.Z` showed identical SHA256 digests on both `linux/amd64` and `linux/arm64` for v0.74.0, v0.75.3, v0.75.4, v0.75.5. Users on `:latest` were getting whatever pi version was baked into the v0.74.0 build (probably 0.74.0 itself).
|
||||
|
||||
- **Workflow fix:** Both `smoke` and `publish` jobs now derive `PI_VERSION` from `github.ref_name` (e.g. `v0.75.5b` → `0.75.5`) and pass it as a build-arg. The Dockerfile's existing `if PI_VERSION=latest` branch never fires in CI now — always takes the `@${PI_VERSION}` branch — so the layer-hash includes the version and cache invalidates correctly.
|
||||
- **Smoke test:** New `run_expect` helper asserts `pi --version` output contains `EXPECTED_PI_VERSION` (passed from the resolve step). Would have caught this regression on v0.75.3 if it had existed.
|
||||
- **Dockerfile:** Comment added above `ARG PI_VERSION=latest` documenting the cache-hit footgun and pointing at the workflow's resolve step + AGENTS.md gotcha.
|
||||
- **AGENTS.md:** New convention bullet explaining the cache-hit class of bug and noting the latent same-bug in opencode-devbox's `with-pi` variants (currently masked by OPENCODE_VERSION bumps).
|
||||
|
||||
No image-side changes vs v0.75.5 *intent* — this build will produce the actual pi 0.75.5 image content that v0.75.5 was supposed to ship.
|
||||
|
||||
## v0.75.5 — 2026-05-23
|
||||
|
||||
pi `0.75.4` → `0.75.5` bump (one upstream patch release, two days after v0.75.4).
|
||||
|
||||
Notable upstream changes (from pi's CHANGELOG):
|
||||
|
||||
- Cleaner read tool output (collapsed cards show only the read line; Ctrl+O expands).
|
||||
- Faster file tools on Windows (async fs ops during streaming, image resize off the main TUI thread).
|
||||
- More reliable package updates (`pi update` reconciles git-pinned refs without losing settings).
|
||||
- Custom Anthropic-compatible adaptive thinking via `compat.forceAdaptiveThinking`.
|
||||
- Several bash/read tool card display fixes; macOS Bun clipboard sidecar resolution; per-session OpenCode-Zen routing headers; Amazon Bedrock token cap fix.
|
||||
|
||||
Plus a new pi 0.74.2 rescue release advising Node 20 users to upgrade Node before going to newer Pi versions — the devbox base image runs newer Node so this doesn't affect us, but worth noting for users running pi outside the devbox.
|
||||
|
||||
- **Bump:** pi `@earendil-works/pi-coding-agent@0.75.5` baked at `/usr/bin/pi` (via `PI_VERSION=latest` resolving to 0.75.5 at build time — no Dockerfile change needed).
|
||||
- No image-side changes from v0.75.4 beyond the pi npm version. Built on `joakimp/opencode-devbox:base-latest` which itself is unchanged (cache-hit on `base-35ee5fe7861a` since v1.14.50b).
|
||||
|
||||
## v0.75.4 — 2026-05-21
|
||||
|
||||
pi `0.75.3` → `0.75.4` bump (one upstream patch release). Plus the AGENTS.md documentation-drift sweep clause that landed on `main` between v0.75.3 and now.
|
||||
|
||||
- **Bump:** pi `@earendil-works/pi-coding-agent@0.75.4` baked at `/usr/bin/pi` (via `PI_VERSION=latest` resolving to 0.75.4 at build time — no Dockerfile change needed).
|
||||
- **AGENTS.md:** documentation drift sweep as explicit pre-commit workflow step (commit `ae6253a`). Companion clause added across the wider repo set the same day.
|
||||
- No image-side changes beyond the pi npm version. Built on `joakimp/opencode-devbox:base-latest` which itself is unchanged (cache-hit on `base-35ee5fe7861a` since v1.14.50b).
|
||||
|
||||
## v0.75.3 — 2026-05-18
|
||||
|
||||
pi `0.74.0` → `0.75.3` bump (one upstream minor + three patch releases since the initial pi-devbox release on 2026-05-14).
|
||||
|
||||
- **Bump:** pi `@earendil-works/pi-coding-agent@0.75.3` baked at `/usr/bin/pi` (via `PI_VERSION=latest` resolving to 0.75.3 at build time).
|
||||
- No image-side changes from the v0.74.0 baseline beyond the pi npm version. The pi-toolkit + pi-extensions clones, mempalace bridge symlink, and `NPM_CONFIG_PREFIX` named-volume setup all unchanged.
|
||||
|
||||
## v0.74.0 — 2026-05-14
|
||||
|
||||
Initial release.
|
||||
|
||||
+158
-1
@@ -1 +1,158 @@
|
||||
pi coding-agent container — built on opencode-devbox base. Includes pi, pi-toolkit, pi-extensions, mempalace, AWS CLI, neovim, and full dev toolchain. See https://gitea.jordbo.se/joakimp/pi-devbox for full docs.
|
||||
# pi-devbox
|
||||
|
||||
A self-contained Docker container for the [pi coding-agent](https://github.com/earendil-works/pi) — pi + companion repos + MemPalace + a curated set of dev tooling, ready to run.
|
||||
|
||||
> **Current `:latest` ships pi `{{PI_VERSION}}`** (resolved at build time; see [Versioning](#versioning)).
|
||||
|
||||
## Image variants
|
||||
|
||||
| Tag | Architectures | Size (compressed) | What you get |
|
||||
|---|---|---|---|
|
||||
| `joakimp/pi-devbox:latest` | amd64, arm64 | ~1.1 GB | Self-contained: base + pi `{{PI_VERSION}}` + companions |
|
||||
| `joakimp/pi-devbox:vX.Y.Z` | amd64, arm64 | same | Pinned semver release |
|
||||
| `joakimp/pi-devbox:latest-studio` | amd64, arm64 | ~1.15 GB | `latest` + [pi-studio](https://github.com/omaclaren/pi-studio): browser prompt editor, KaTeX/Mermaid preview, tmux-backed literate REPLs |
|
||||
| `joakimp/pi-devbox:vX.Y.Z-studio` | amd64, arm64 | same | Pinned semver studio release |
|
||||
| `joakimp/pi-devbox:base-latest` | amd64, arm64 | ~1.0 GB | Base layer alias (internal building block; pull `:latest` instead) |
|
||||
| `joakimp/pi-devbox:base-<hash>` | amd64, arm64 | ~1.0 GB | Content-addressed base; immutable. Stable parent for variant rebuilds. |
|
||||
|
||||
> **pi-studio (`-studio` tags):** launch with `/studio --no-browser --port 8765` inside a pi session. The server binds `127.0.0.1` **inside the container**, so reach it via host networking or a loopback bridge (and `ssh -L` for a remote host; mosh needs a parallel `ssh -L`). Full recipe: [README → Using pi-studio](https://gitea.jordbo.se/joakimp/pi-devbox#using-pi-studio--studio-variant).
|
||||
|
||||
## Quick start
|
||||
|
||||
One-shot, no persistence:
|
||||
|
||||
```bash
|
||||
docker run -it --rm \
|
||||
-v "$PWD":/workspace \
|
||||
-v "$HOME/.ssh":/home/developer/.ssh:ro \
|
||||
-e ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
|
||||
joakimp/pi-devbox:latest pi
|
||||
```
|
||||
|
||||
For a fully-configured environment with persistent settings, MemPalace memory, neovim plugins, and shell history surviving container recreation, use docker-compose. **You don't need to clone the repo** — just grab two template files:
|
||||
|
||||
```bash
|
||||
mkdir -p ~/pi-devbox && cd ~/pi-devbox
|
||||
curl -O https://gitea.jordbo.se/joakimp/pi-devbox/raw/branch/main/docker-compose.yml
|
||||
curl -fsSL https://gitea.jordbo.se/joakimp/pi-devbox/raw/branch/main/.env.example -o .env
|
||||
# Edit .env — set WORKSPACE_PATH, an LLM API key (ANTHROPIC_API_KEY,
|
||||
# OPENAI_API_KEY, GEMINI_API_KEY, or AWS_*), and your git identity.
|
||||
docker compose run --rm devbox pi
|
||||
```
|
||||
|
||||
Full setup guide — authentication for each provider (Anthropic, OpenAI, Gemini, AWS Bedrock SSO + static), persistence model, configuration reference, build args, troubleshooting: **<https://gitea.jordbo.se/joakimp/pi-devbox#readme>**
|
||||
|
||||
## What's inside
|
||||
|
||||
### pi and companions
|
||||
|
||||
- **pi `{{PI_VERSION}}`** ([`@earendil-works/pi-coding-agent`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)) — installed at `/usr/bin/pi`
|
||||
- **[pi-toolkit](https://gitea.jordbo.se/joakimp/pi-toolkit)** — keybindings (mosh/tmux-friendly Shift+Enter, Ctrl+J, Alt+J newline bindings), AWS env loader, settings template
|
||||
- **[pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions)** — 7 user-facing extensions: `ext-toggle`, `mcp-loader`, `todo`, `ssh-controlmaster`, `notify`, `git-checkpoint`, `confirm-destructive`
|
||||
- **`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
|
||||
|
||||
The entrypoint deploys/registers all of these on first container start. Re-running is idempotent and preserves user edits.
|
||||
|
||||
### MemPalace (persistent agent memory)
|
||||
|
||||
- **MemPalace** + MCP server — semantic search over conversation history, knowledge graph, diary; queryable via 29 `mempalace_*` tools inside pi
|
||||
- ChromaDB ONNX embedding model pre-warmed at build time (`all-MiniLM-L6-v2`)
|
||||
- Bind-mount your host's `~/.mempalace` and the host-pi and container-pi share one brain
|
||||
|
||||
### Document and image tooling
|
||||
|
||||
- **pandoc** — universal Markdown↔HTML/Org/RST/etc. conversion. Useful well beyond pi: agent-driven doc exports, format conversion, etc.
|
||||
- **graphviz** (`dot`) — diagram rendering pipelines
|
||||
- **imagemagick** (`magick`) — image conversion / resizing
|
||||
|
||||
### Modern CLI tooling
|
||||
|
||||
- **Editor**: neovim (LazyVim defaults), tmux (configured for 0-indexed sessions)
|
||||
- **Search/nav**: ripgrep, fd, fzf, zoxide
|
||||
- **Display**: bat, eza, htop, tree
|
||||
- **Data**: jq, yq
|
||||
- **Help**: tldr (tealdeer — Rust port; run `tldr --update` once to populate cache)
|
||||
- **Git**: git-lfs, git-crypt, gitleaks (for pre-commit secret scanning)
|
||||
- **Build**: gcc, g++, make, patch
|
||||
- **Misc**: gosu, age, rsync, less
|
||||
|
||||
### Language toolchains
|
||||
|
||||
- **Python**: system Python 3 + **uv** (preferred) for fast Python package management. Run any Python REPL/notebook stack on demand without bloating the image:
|
||||
```bash
|
||||
uv run --with ipython ipython
|
||||
uv run --with jupyterlab jupyter lab --no-browser --port 8888
|
||||
uv run --with marimo marimo edit
|
||||
```
|
||||
- **Node.js** v22 + npm (used by pi itself)
|
||||
- **Rust** — `rustup-init` is on PATH; install toolchains on demand
|
||||
- **Go** — opt-in via `--build-arg INSTALL_GO=true` if rebuilding from source
|
||||
|
||||
### Cloud + secrets
|
||||
|
||||
- **AWS CLI v2** — for SSO + Bedrock auth (pi's preferred LLM provider for the maintainer's setup)
|
||||
- **Gitea MCP** server — for Gitea API access from inside pi
|
||||
- **age**, **git-crypt** — encryption tooling
|
||||
|
||||
### 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).
|
||||
- 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
|
||||
|
||||
From v1.0.0 onward, pi-devbox uses **semver**:
|
||||
|
||||
- **Major** — architectural changes. v1.0.0 is the first decoupled release, where pi-devbox got its own self-contained build chain (previously it was a thin re-brand of opencode-devbox's `pi-only` variant).
|
||||
- **Minor** — new image variants, significant base additions.
|
||||
- **Patch** — pi version bumps, smaller fixes.
|
||||
|
||||
The pi binary version inside any given release is shown in this description (currently **`{{PI_VERSION}}`** for `:latest`) and asserted by smoke tests to match what's documented — version drift is caught at CI time, not on user pull.
|
||||
|
||||
> **Pre-v1.0.0 history.** Tags v0.74.0…v0.79.0 followed the pi npm version directly (`v{pi_version}[letter]`). Those images remain on Hub but are deprecated in favor of `:latest` / `:v1.X.Y`. The legacy `:base-pi-only*` tags were CI artifacts of the old opencode-devbox-based build pipeline; they will be removed in a future opencode-devbox v2.0.0.
|
||||
|
||||
### Build pipeline
|
||||
|
||||
pi-devbox is built in two phases:
|
||||
|
||||
1. **Base** (`Dockerfile.base`) → `base-<hash>` tag, content-addressed over `Dockerfile.base` + `rootfs/` + `entrypoint*.sh`. Rebuilt only when those change.
|
||||
2. **Variant** (`Dockerfile.variant`) → `:latest` and `:vX.Y.Z`. FROMs the base, adds the pi install + companions.
|
||||
|
||||
`base-latest` is an alias of the most recent base.
|
||||
|
||||
## Persistent state
|
||||
|
||||
User edits and pi-installed packages survive container recreation when you mount these named volumes. Use the included `docker-compose.yml` and they're set up automatically.
|
||||
|
||||
| Volume | Mount point | What it holds |
|
||||
|---|---|---|
|
||||
| `devbox-pi-config` | `/home/developer/.pi/` | pi settings, extension toggles, sessions, user-installed pi packages (`npm install -g`, `pi install npm:…`) |
|
||||
| `devbox-shell-history` | `/home/developer/.cache/bash` | bash history |
|
||||
| `devbox-zoxide` | `/home/developer/.local/share/zoxide` | zoxide directory jump database |
|
||||
| `devbox-nvim-data` | `/home/developer/.local/share/nvim` | neovim plugin & Mason package state |
|
||||
| `devbox-uv` | `/home/developer/.local/share/uv` | uv Python installs and tool cache |
|
||||
| `devbox-ssh-local` | `/home/developer/.ssh-local` | LAN-jump key (one-time host authorization survives recreate) |
|
||||
|
||||
Optional volumes for MemPalace (commented out by default — uncomment in `docker-compose.yml` to persist conversation memory across restarts):
|
||||
|
||||
| Volume | Mount point | What it holds |
|
||||
|---|---|---|
|
||||
| `devbox-palace` | `/home/developer/.mempalace` | palace data (drawers, knowledge graph, embeddings) |
|
||||
| `devbox-chroma-cache` | `/home/developer/.cache/chroma` | ChromaDB embedding model cache (~80 MB, can be rebuilt) |
|
||||
|
||||
## User-installed pi packages
|
||||
|
||||
`NPM_CONFIG_PREFIX` is set inside the container to `/home/developer/.pi/npm-global`. Anything you `pi install npm:<pkg>` or `npm install -g` lands on the `devbox-pi-config` named volume — survives container recreation **and** image rebuilds. A user-installed `pi` wins over the baked one via `PATH` order, so you can pin a different pi version without rebuilding the image.
|
||||
|
||||
## Source
|
||||
|
||||
- **This image**: https://gitea.jordbo.se/joakimp/pi-devbox
|
||||
- **pi**: https://github.com/earendil-works/pi
|
||||
- **pi-toolkit**: https://gitea.jordbo.se/joakimp/pi-toolkit
|
||||
- **pi-extensions**: https://gitea.jordbo.se/joakimp/pi-extensions
|
||||
- **MemPalace**: https://github.com/MemPalace/mempalace
|
||||
|
||||
## License
|
||||
|
||||
MIT (the image; pi and the bundled tools each carry their own licenses).
|
||||
|
||||
-54
@@ -1,54 +0,0 @@
|
||||
# pi-devbox — pi coding-agent container
|
||||
#
|
||||
# Builds on top of the opencode-devbox base image, which provides:
|
||||
# Debian trixie, Node.js, AWS CLI, mempalace + MCP server, gitea-mcp,
|
||||
# dev tools (neovim, tmux, bat, eza, fzf, zoxide, ripgrep, uv, rustup),
|
||||
# user setup (developer/gosu), entrypoints, chromadb prewarm.
|
||||
#
|
||||
# This image adds only pi itself and its companion repos.
|
||||
#
|
||||
# Build args:
|
||||
# BASE_IMAGE — base image to build from (default: base-latest)
|
||||
# PI_VERSION — pi npm version: "latest" or a pinned version e.g. "0.74.0"
|
||||
# PI_TOOLKIT_REF — git ref for pi-toolkit (default: main)
|
||||
# PI_EXTENSIONS_REF — git ref for pi-extensions (default: main)
|
||||
|
||||
ARG BASE_IMAGE=joakimp/opencode-devbox:base-latest
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
ARG PI_VERSION=latest
|
||||
ARG PI_TOOLKIT_REF=main
|
||||
ARG PI_EXTENSIONS_REF=main
|
||||
|
||||
# Install pi and clone companion repos.
|
||||
# NPM_CONFIG_PREFIX is overridden to /usr so the baked binary lands at the
|
||||
# system prefix — same pattern as opencode-devbox's variant Dockerfile.
|
||||
# At runtime, NPM_CONFIG_PREFIX is reset to /home/developer/.pi/npm-global
|
||||
# (inherited from base ENV) so user-installed packages land on the named
|
||||
# volume and survive container recreate.
|
||||
#
|
||||
# git clone is wrapped in a retry loop because gitea.jordbo.se occasionally
|
||||
# returns transient HTTP 500s on the first request after idle.
|
||||
RUN set -e && \
|
||||
git_clone_retry() { \
|
||||
url="$1"; ref="$2"; dest="$3"; \
|
||||
for i in 1 2 3 4 5; do \
|
||||
if git clone --depth 1 --branch "$ref" "$url" "$dest"; then return 0; fi; \
|
||||
rm -rf "$dest"; \
|
||||
echo "git clone $url failed (attempt $i/5), retrying in $((i*5))s..."; \
|
||||
sleep $((i*5)); \
|
||||
done; \
|
||||
return 1; \
|
||||
} && \
|
||||
if [ "${PI_VERSION}" = "latest" ]; then \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \
|
||||
else \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
|
||||
fi && \
|
||||
pi --version && \
|
||||
git_clone_retry https://gitea.jordbo.se/joakimp/pi-toolkit.git "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \
|
||||
git_clone_retry https://gitea.jordbo.se/joakimp/pi-extensions.git "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
|
||||
echo "pi-toolkit at $(cd /opt/pi-toolkit && git rev-parse --short HEAD)" && \
|
||||
echo "pi-extensions at $(cd /opt/pi-extensions && git rev-parse --short HEAD)"
|
||||
|
||||
# WORKDIR / ENTRYPOINT / CMD inherited from base.
|
||||
+449
@@ -0,0 +1,449 @@
|
||||
# pi-devbox — base image (variant-independent layers)
|
||||
#
|
||||
# This Dockerfile produces an image tagged base-<hash>, used as the parent
|
||||
# for all published variants of pi-devbox. It contains everything that does
|
||||
# not depend on variant-specific build-args (the pi install moves to
|
||||
# Dockerfile.variant).
|
||||
#
|
||||
# The base is rebuilt only when this file or anything it COPYs in changes
|
||||
# (rootfs/, entrypoint*.sh). Version bumps to PI_VERSION etc. do NOT
|
||||
# trigger a base rebuild.
|
||||
#
|
||||
# To force a base rebuild for fresh apt packages without other code
|
||||
# changes, bump the BASE_REBUILD_DATE comment below. The hash is
|
||||
# content-addressed over this file, so any byte change invalidates the
|
||||
# cache. Recommended cadence: once per release for security updates.
|
||||
#
|
||||
# BASE_REBUILD_DATE: 2026-06-09 (v1.0.0 — decoupled from opencode-devbox)
|
||||
#
|
||||
# ── Lineage note ─────────────────────────────────────────────────────
|
||||
# Adapted from opencode-devbox/Dockerfile.base (commit before v1.16.2).
|
||||
# pi-devbox was previously a thin re-brand of opencode-devbox's pi-only
|
||||
# variant; this file is the start of an independent build chain. The
|
||||
# opencode-devbox install logic (INSTALL_OPENCODE, INSTALL_OMOS) does
|
||||
# not appear here. The base is otherwise broadly equivalent so generic
|
||||
# upstream improvements (CVE updates, new dev tooling) can be cherry-
|
||||
# picked between repos.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
ARG DEBIAN_VERSION=trixie-slim
|
||||
FROM debian:${DEBIAN_VERSION} AS base
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
LABEL maintainer="joakimp"
|
||||
LABEL description="pi-devbox — base image (variant-independent)"
|
||||
LABEL org.opencontainers.image.source="https://gitea.jordbo.se/joakimp/pi-devbox"
|
||||
|
||||
# Avoid interactive prompts during build
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# ── Core system packages ─────────────────────────────────────────────
|
||||
# apt-get upgrade picks up any security/CVE fixes published between
|
||||
# debian:trixie-slim base-image rebuilds. Paired with the index update
|
||||
# and the install in the same layer so we don't bloat image history.
|
||||
#
|
||||
# Additions vs the upstream opencode-devbox base (2026-06-09):
|
||||
# pandoc — Markdown↔HTML/PDF/etc. conversion. Required by pi-studio
|
||||
# preview/export pipelines and broadly useful for any
|
||||
# agent-driven document workflow. ~200 MB.
|
||||
# graphviz — `dot` rendering for many diagram tools. ~10 MB.
|
||||
# imagemagick — image conversion / resizing for thumbnails, etc. ~50 MB.
|
||||
# yq — YAML-aware companion to jq.
|
||||
# socat — TCP relay. Powers `studio-expose`, which bridges
|
||||
# pi-studio's container-loopback server to the container's
|
||||
# external interface so a published port can reach it.
|
||||
# ~1 MB; generally useful for any port-forwarding need.
|
||||
RUN apt-get update && \
|
||||
apt-get upgrade -y --no-install-recommends && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates \
|
||||
curl \
|
||||
wget \
|
||||
git \
|
||||
openssh-client \
|
||||
gnupg \
|
||||
jq \
|
||||
yq \
|
||||
ripgrep \
|
||||
fd-find \
|
||||
tree \
|
||||
less \
|
||||
htop \
|
||||
tmux \
|
||||
make \
|
||||
patch \
|
||||
diffutils \
|
||||
git-crypt \
|
||||
age \
|
||||
file \
|
||||
sudo \
|
||||
locales \
|
||||
procps \
|
||||
unzip \
|
||||
gcc \
|
||||
g++ \
|
||||
rsync \
|
||||
python3-pip \
|
||||
python3-venv \
|
||||
pandoc \
|
||||
graphviz \
|
||||
imagemagick \
|
||||
socat \
|
||||
&& ln -s /usr/bin/fdfind /usr/local/bin/fd \
|
||||
&& apt-get clean \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── tmux defaults: 0-indexed windows and panes ───────────────────────
|
||||
# pi-studio (omaclaren/pi-studio) hard-codes its tmux send target to
|
||||
# `<session>:0.0`. Containers that ship tmux with default options are
|
||||
# already 0-indexed; this file makes the assumption explicit so future
|
||||
# /etc/tmux.conf consumers can read it. Users can override per-user
|
||||
# in ~/.tmux.conf if they want 1-indexing — pi-studio will then fail
|
||||
# to find its REPL session.
|
||||
RUN printf '%s\n' \
|
||||
'# pi-devbox baked default — see Dockerfile.base.' \
|
||||
'# pi-studio targets tmux session :0.0; do not change these here.' \
|
||||
'set -g base-index 0' \
|
||||
'set -g pane-base-index 0' \
|
||||
> /etc/tmux.conf
|
||||
|
||||
# ── SSH client defaults: ControlMaster on a writable socket path ──────
|
||||
# Why this exists: the devbox typically mounts ~/.ssh from the host as
|
||||
# read-only (security: keys are readable, but agents can't tamper with
|
||||
# config / known_hosts / authorized_keys / plant a malicious ProxyCommand).
|
||||
# OpenSSH's default ControlPath is ~/.ssh/cm/... which is unwritable on
|
||||
# such mounts, so any attempt to use ControlMaster fails. Symptoms:
|
||||
# unix_listener: cannot bind to path /home/.../.ssh/cm/...: Read-only file system
|
||||
# kex_exchange_identification: Connection closed by remote host
|
||||
# The latter manifests downstream of CGNAT per-destination flow caps
|
||||
# (~4 concurrent flows on most European residential ISPs) which silently
|
||||
# drop further SYNs once exceeded — making fresh ssh attempts fail with
|
||||
# banner-exchange timeouts that look like a remote problem.
|
||||
#
|
||||
# Fix: set a system-wide default ControlPath in /tmp (per-container,
|
||||
# tmpfs-friendly, always writable) so multiplexing Just Works without
|
||||
# touching the read-only ~/.ssh mount. Per-host overrides in user's
|
||||
# ~/.ssh/config still win — Debian's default /etc/ssh/ssh_config has
|
||||
# `Include /etc/ssh/ssh_config.d/*.conf` *before* the `Host *` block,
|
||||
# so user config can override these defaults if desired.
|
||||
#
|
||||
# ControlPersist=10m means the master socket sticks around 10 min after
|
||||
# the last session closes, so consecutive ssh calls in a workflow reuse
|
||||
# the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm
|
||||
# (mode 700) on each container start.
|
||||
RUN mkdir -p /etc/ssh/ssh_config.d && \
|
||||
printf '%s\n' \
|
||||
'# Devbox-baked default. See Dockerfile.base "SSH client defaults".' \
|
||||
'# Override per-host in ~/.ssh/config if the master socket location' \
|
||||
'# needs to differ.' \
|
||||
'Host *' \
|
||||
' ControlMaster auto' \
|
||||
' ControlPath /tmp/sshcm/%r@%h:%p' \
|
||||
' ControlPersist 10m' \
|
||||
' ServerAliveInterval 30' \
|
||||
' ServerAliveCountMax 6' \
|
||||
> /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf && \
|
||||
chmod 644 /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf
|
||||
|
||||
# ── Go-compiled tools (install from GitHub to avoid CVEs in Debian's old Go builds)
|
||||
#
|
||||
# Version policy: default is `latest` — resolved at build time by
|
||||
# following the /releases/latest redirect and reading the tag from the
|
||||
# Location header. Every base rebuild picks up the newest upstream
|
||||
# release. Explicit pins still work via build-args (e.g.
|
||||
# --build-arg GOSU_VERSION=1.19).
|
||||
|
||||
# gosu — privilege de-escalation
|
||||
ARG GOSU_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${GOSU_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing gosu ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/download/${V}/gosu-${ARCH}" -o /usr/local/bin/gosu && \
|
||||
chmod +x /usr/local/bin/gosu && \
|
||||
gosu --version
|
||||
|
||||
# fzf — fuzzy finder
|
||||
ARG FZF_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${FZF_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing fzf ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/download/v${V}/fzf-${V}-linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin fzf && \
|
||||
fzf --version
|
||||
|
||||
# git-lfs
|
||||
ARG GIT_LFS_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${GIT_LFS_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing git-lfs ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/download/v${V}/git-lfs-linux-${ARCH}-v${V}.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/git-lfs-${V}/git-lfs /usr/local/bin/git-lfs && \
|
||||
rm -rf /tmp/git-lfs-${V} && \
|
||||
git lfs install --system && \
|
||||
git-lfs --version
|
||||
|
||||
# gitleaks
|
||||
ARG GITLEAKS_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x64" ;; arm64) echo "arm64" ;; *) echo "x64" ;; esac) && \
|
||||
V="${GITLEAKS_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing gitleaks ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/download/v${V}/gitleaks_${V}_linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin gitleaks && \
|
||||
chmod +x /usr/local/bin/gitleaks && \
|
||||
gitleaks version
|
||||
|
||||
# neovim
|
||||
ARG NVIM_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${NVIM_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing neovim ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/download/v${V}/nvim-linux-${ARCH}.tar.gz" | tar -xz -C /opt && \
|
||||
ln -s /opt/nvim-linux-${ARCH}/bin/nvim /usr/local/bin/nvim && \
|
||||
nvim --version | head -1
|
||||
|
||||
# bat
|
||||
ARG BAT_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${BAT_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing bat ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/download/v${V}/bat-v${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/bat-v${V}-${ARCH}-unknown-linux-musl/bat /usr/local/bin/bat && \
|
||||
rm -rf /tmp/bat-v${V}-* && \
|
||||
bat --version
|
||||
|
||||
# eza
|
||||
ARG EZA_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${EZA_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing eza ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/download/v${V}/eza_${ARCH}-unknown-linux-gnu.tar.gz" | tar -xz -C /usr/local/bin && \
|
||||
eza --version | head -1
|
||||
|
||||
# zoxide
|
||||
ARG ZOXIDE_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${ZOXIDE_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing zoxide ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/download/v${V}/zoxide-${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /usr/local/bin zoxide && \
|
||||
zoxide --version
|
||||
|
||||
# uv — fast Python package manager. Note: uv tags don't prefix with "v".
|
||||
ARG UV_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${UV_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing uv ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/download/${V}/uv-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/uv-${ARCH}-unknown-linux-musl/uv /usr/local/bin/uv && \
|
||||
install /tmp/uv-${ARCH}-unknown-linux-musl/uvx /usr/local/bin/uvx && \
|
||||
rm -rf /tmp/uv-* && \
|
||||
uv --version
|
||||
|
||||
# ── MemPalace — local-first AI memory system ─────────────────────────
|
||||
# Provides semantic search over conversation history via 29 MCP tools.
|
||||
# Always installed in the base. Set INSTALL_MEMPALACE=false at base-build
|
||||
# time to shave ~300 MB.
|
||||
ARG INSTALL_MEMPALACE=true
|
||||
# Pin to a known-good version. Bump deliberately, not implicitly: an
|
||||
# unpinned install silently swept in mempalace 3.3.x/3.4.0 with a broken
|
||||
# diary_write schema (see workaround RUN below + issue #1728). Pinning
|
||||
# makes mempalace upgrades a reviewable diff rather than a surprise.
|
||||
ARG MEMPALACE_VERSION=3.4.0
|
||||
ENV UV_TOOL_DIR=/opt/uv-tools
|
||||
ENV UV_TOOL_BIN_DIR=/usr/local/bin
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
mkdir -p /opt/uv-tools && \
|
||||
uv tool install --no-cache "mempalace==${MEMPALACE_VERSION}" && \
|
||||
/opt/uv-tools/mempalace/bin/python -c "import mempalace; print('mempalace', mempalace.__version__ if hasattr(mempalace, '__version__') else 'installed')" ; \
|
||||
fi
|
||||
|
||||
# ── workaround: strip top-level anyOf from mempalace_diary_write schema ──
|
||||
# Mempalace 3.3.x/3.4.0 advertise diary_write's input_schema with a
|
||||
# top-level `anyOf: [{required:[entry]}, {required:[content]}]` to express
|
||||
# "either entry or content must be supplied". Anthropic's tools API rejects
|
||||
# top-level anyOf/oneOf/allOf, so pi/Claude fail at session start with
|
||||
# `tools.<n>.custom.input_schema: input_schema does not support oneOf,
|
||||
# allOf, or anyOf at the top level`.
|
||||
#
|
||||
# Patch the advertised schema to require ["agent_name", "entry"] and remove
|
||||
# the anyOf block. The handler keeps accepting `content` server-side as a
|
||||
# kwarg alias so existing callers still work.
|
||||
#
|
||||
# Idempotent and self-deactivating: once upstream releases the fix the
|
||||
# regex no longer matches and this RUN is a silent no-op.
|
||||
# Upstream tracking:
|
||||
# https://github.com/MemPalace/mempalace/issues/1728
|
||||
# https://github.com/MemPalace/mempalace/pull/1735
|
||||
# TODO: remove this RUN once a mempalace release containing PR #1735 is on
|
||||
# PyPI and installed by the line above.
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
MP_FILE="$(find /opt/uv-tools/mempalace -path '*/mempalace/mcp_server.py' | head -n1)" && \
|
||||
if [ -z "$MP_FILE" ]; then echo "mempalace mcp_server.py not found" >&2; exit 1; fi && \
|
||||
perl -0777 -i -pe 's/(?:[ \t]*\#[^\n]*\n)*[ \t]*"required":\s*\[\s*"agent_name"\s*\]\s*,\s*\n[ \t]*"anyOf":\s*\[\s*\n[ \t]*\{\s*"required":\s*\[\s*"entry"\s*\]\s*\}\s*,\s*\n[ \t]*\{\s*"required":\s*\[\s*"content"\s*\]\s*\}\s*,?\s*\n[ \t]*\]\s*,\s*\n/ "required": ["agent_name", "entry"],\n/s' "$MP_FILE" && \
|
||||
if grep -q '"required": \["agent_name", "entry"\]' "$MP_FILE"; then \
|
||||
echo "mempalace diary_write anyOf workaround: applied (or already clean)"; \
|
||||
else \
|
||||
echo "WARN: mempalace diary_write anyOf workaround did not match expected schema — upstream may have changed shape" >&2; \
|
||||
fi ; \
|
||||
fi
|
||||
|
||||
# ── mempalace-toolkit — bash wrappers for session/docs mining ────────
|
||||
ARG INSTALL_MEMPALACE_TOOLKIT=true
|
||||
ARG MEMPALACE_TOOLKIT_REF=main
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ] && [ "${INSTALL_MEMPALACE_TOOLKIT}" = "true" ]; then \
|
||||
git clone --depth 1 --branch "${MEMPALACE_TOOLKIT_REF}" \
|
||||
https://gitea.jordbo.se/joakimp/mempalace-toolkit.git /opt/mempalace-toolkit && \
|
||||
ln -sf /opt/mempalace-toolkit/bin/mempalace-session /usr/local/bin/mempalace-session && \
|
||||
ln -sf /opt/mempalace-toolkit/bin/mempalace-docs /usr/local/bin/mempalace-docs && \
|
||||
chmod +x /opt/mempalace-toolkit/bin/mempalace-session /opt/mempalace-toolkit/bin/mempalace-docs && \
|
||||
mempalace-session --help >/dev/null && \
|
||||
mempalace-docs --help >/dev/null && \
|
||||
echo "mempalace-toolkit installed at $(cd /opt/mempalace-toolkit && git rev-parse --short HEAD)" ; \
|
||||
fi
|
||||
|
||||
# rustup — Rust toolchain manager (init binary only; toolchains installed at runtime)
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://static.rust-lang.org/rustup/dist/${ARCH}-unknown-linux-gnu/rustup-init" -o /usr/local/bin/rustup-init && \
|
||||
chmod +x /usr/local/bin/rustup-init
|
||||
|
||||
# gitea-mcp — MCP server for Gitea API
|
||||
ARG GITEA_MCP_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${GITEA_MCP_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing gitea-mcp ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://gitea.com/gitea/gitea-mcp/releases/download/v${V}/gitea-mcp_Linux_${ARCH}.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin/ gitea-mcp && \
|
||||
chmod +x /usr/local/bin/gitea-mcp && \
|
||||
gitea-mcp --version
|
||||
|
||||
# Locales
|
||||
RUN sed -i -E '/(en_US|en_GB|sv_SE|da_DK|nb_NO|fi_FI|de_DE|fr_FR|es_ES|it_IT|pt_BR|nl_NL|pl_PL|ja_JP|ko_KR|zh_CN)\.UTF-8/s/^# //g' /etc/locale.gen && locale-gen
|
||||
ENV LANG=en_US.UTF-8
|
||||
ENV LANGUAGE=en_US:en
|
||||
ENV LC_ALL=en_US.UTF-8
|
||||
ENV EDITOR=nvim
|
||||
ENV PATH="/home/developer/.local/bin:/home/developer/.cargo/bin:${PATH}"
|
||||
|
||||
# ── Node.js (required for pi + MCP servers + tldr) ──
|
||||
ARG NODE_VERSION=22
|
||||
RUN curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors https://deb.nodesource.com/setup_${NODE_VERSION}.x | bash - && \
|
||||
apt-get install -y --no-install-recommends nodejs && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# ── tldr (tealdeer) — community-maintained command examples ──────────
|
||||
# Tealdeer is a Rust port of the tldr-pages client; ~5 MB static binary,
|
||||
# ~135 MB smaller than the Node tldr global. Same `tldr` command, same UX.
|
||||
ARG TEALDEER_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${TEALDEER_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tealdeer-rs/tealdeer/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing tealdeer ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tealdeer-rs/tealdeer/releases/download/v${V}/tealdeer-linux-${ARCH}-musl" -o /usr/local/bin/tldr && \
|
||||
chmod +x /usr/local/bin/tldr && \
|
||||
tldr --version
|
||||
|
||||
# ── AWS CLI v2 (for SSO/Bedrock authentication) ─────────────────────
|
||||
RUN ARCH=$(case "${TARGETARCH}" in \
|
||||
amd64) echo "x86_64" ;; \
|
||||
arm64) echo "aarch64" ;; \
|
||||
*) echo "x86_64" ;; \
|
||||
esac) && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://awscli.amazonaws.com/awscli-exe-linux-${ARCH}.zip" -o /tmp/awscli.zip && \
|
||||
unzip -q /tmp/awscli.zip -d /tmp && \
|
||||
/tmp/aws/install && \
|
||||
rm -rf /tmp/aws /tmp/awscli.zip && \
|
||||
aws --version
|
||||
|
||||
# ── Non-root user ────────────────────────────────────────────────────
|
||||
ARG USER_NAME=developer
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
|
||||
RUN groupadd --gid ${USER_GID} ${USER_NAME} && \
|
||||
useradd --uid ${USER_UID} --gid ${USER_GID} -m -s /bin/bash ${USER_NAME} && \
|
||||
echo "${USER_NAME} ALL=(ALL) NOPASSWD:ALL" >> /etc/sudoers.d/${USER_NAME}
|
||||
|
||||
# Standard directories
|
||||
RUN mkdir -p /workspace \
|
||||
/home/${USER_NAME}/.pi/agent/extensions \
|
||||
/home/${USER_NAME}/.agents/skills \
|
||||
/home/${USER_NAME}/.cache/bash \
|
||||
/home/${USER_NAME}/.ssh && \
|
||||
chown -R ${USER_NAME}:${USER_NAME} /workspace /home/${USER_NAME}
|
||||
|
||||
# ── Pre-warm chromadb embedding model ──────────────────────────────
|
||||
RUN if [ "${INSTALL_MEMPALACE}" = "true" ]; then \
|
||||
gosu ${USER_NAME} /opt/uv-tools/mempalace/bin/python -c "\
|
||||
from chromadb.utils.embedding_functions import ONNXMiniLM_L6_V2; \
|
||||
ef = ONNXMiniLM_L6_V2(); \
|
||||
_ = ef(['warmup']); \
|
||||
print('chromadb embedding model warmed: all-MiniLM-L6-v2')" && \
|
||||
ls -lh /home/${USER_NAME}/.cache/chroma/onnx_models/all-MiniLM-L6-v2/ ; \
|
||||
fi
|
||||
|
||||
# ── User-writable npm global prefix on the devbox-pi-config volume ──
|
||||
# Build-time installs use NPM_CONFIG_PREFIX=/usr (see Dockerfile.variant).
|
||||
# Runtime npm/pi installs use this prefix → land on the named volume.
|
||||
ENV NPM_CONFIG_PREFIX=/home/${USER_NAME}/.pi/npm-global
|
||||
ENV PATH="/home/${USER_NAME}/.pi/npm-global/bin:${PATH}"
|
||||
|
||||
# ── Shell defaults (bash history, aliases, readline) ─────────────────
|
||||
RUN mkdir -p /etc/skel-devbox
|
||||
COPY rootfs/home/developer/.bash_aliases /etc/skel-devbox/.bash_aliases
|
||||
COPY rootfs/home/developer/.inputrc /etc/skel-devbox/.inputrc
|
||||
|
||||
# ── Entrypoint ────────────────────────────────────────────────────────
|
||||
COPY rootfs/usr/local/lib/pi-devbox/ /usr/local/lib/pi-devbox/
|
||||
COPY rootfs/usr/local/bin/studio-expose /usr/local/bin/studio-expose
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
COPY entrypoint-user.sh /usr/local/bin/entrypoint-user.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh /usr/local/bin/entrypoint-user.sh \
|
||||
/usr/local/bin/studio-expose \
|
||||
/usr/local/lib/pi-devbox/*.sh 2>/dev/null || true
|
||||
|
||||
# Start as root — entrypoint adjusts UID/GID then drops to developer
|
||||
WORKDIR /workspace
|
||||
|
||||
ENTRYPOINT ["entrypoint.sh"]
|
||||
CMD ["bash", "-l"]
|
||||
@@ -0,0 +1,157 @@
|
||||
# pi-devbox — variant image
|
||||
#
|
||||
# FROMs a base-<hash> image produced by Dockerfile.base and adds only
|
||||
# the variant-specific tools — currently just the pi install. Kept as a
|
||||
# separate file (rather than collapsed into Dockerfile.base) so future
|
||||
# variants (e.g. studio, studio-tex) can FROM the variant or extend
|
||||
# this Dockerfile with additional build args without rebuilding the
|
||||
# base on every pi version bump.
|
||||
#
|
||||
# Pass `--build-arg BASE_IMAGE=<repo>:base-<hash>` to select the base.
|
||||
# CI computes the base hash from Dockerfile.base + rootfs/ +
|
||||
# entrypoint*.sh and feeds it in.
|
||||
#
|
||||
# IMPORTANT: the base image sets NPM_CONFIG_PREFIX to
|
||||
# /home/developer/.pi/npm-global so runtime `pi install npm:...` and
|
||||
# `npm install -g` by the developer user lands on the named volume.
|
||||
# At BUILD time we want the baked binaries on /usr so they survive the
|
||||
# volume mount. Each `npm install -g` below therefore prefixes the
|
||||
# command with `NPM_CONFIG_PREFIX=/usr`.
|
||||
|
||||
ARG BASE_IMAGE
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG USER_NAME=developer
|
||||
|
||||
# ── pi coding-agent + companions ─────────────────────────────────────
|
||||
# pi-toolkit and pi-extensions are cloned into /opt/. entrypoint-user.sh
|
||||
# runs each repo's install.sh on container start so symlinks land under
|
||||
# ~/.pi/agent/ on the named volume.
|
||||
#
|
||||
# PI_VERSION should be passed explicitly by CI as a concrete version
|
||||
# (resolved from `npm view @earendil-works/pi-coding-agent version`).
|
||||
# The default `latest` is for local dev convenience only — it has a
|
||||
# known cache-hit footgun in registry-cached CI builds: the resulting
|
||||
# build-arg string is byte-identical across builds, the layer-hash is
|
||||
# identical, and the registry buildcache silently reuses the layer
|
||||
# from whatever pi version was current when the cache was first
|
||||
# populated. CI MUST pass a resolved concrete version. See pi-devbox
|
||||
# v0.75.5b 2026-05-23 for the discovery + canonical fix.
|
||||
ARG PI_VERSION=latest
|
||||
ARG PI_TOOLKIT_REF=main
|
||||
ARG PI_EXTENSIONS_REF=main
|
||||
# pi-fork (fork tool) + pi-observational-memory (recall tool) live on GitHub
|
||||
# under elpapi42. CI resolves these to commit SHAs to defeat the same
|
||||
# cache-hit footgun that affects PI_VERSION.
|
||||
ARG PI_FORK_REPO=https://github.com/elpapi42/pi-fork.git
|
||||
ARG PI_FORK_REF=master
|
||||
ARG PI_OBSMEM_REPO=https://github.com/elpapi42/pi-observational-memory.git
|
||||
ARG PI_OBSMEM_REF=master
|
||||
|
||||
RUN set -e && \
|
||||
# git_fetch_ref: clone-equivalent helper that accepts EITHER a branch name
|
||||
# OR a commit SHA as $ref. Uses `git fetch <ref> + checkout FETCH_HEAD`
|
||||
# which (a) works with both name and SHA forms uniformly, and (b) defeats
|
||||
# the registry-buildcache footgun when CI passes a resolved SHA. The
|
||||
# earlier helper `git_clone_retry` (using `git clone --branch`) only
|
||||
# worked with branch names — a SHA-resolved build-arg made `git clone
|
||||
# --branch <40-char-SHA>` fail with "Remote branch not found". Surfaced
|
||||
# in pi-devbox v1.0.0-rerun (run 374) 2026-06-10 and fixed by switching
|
||||
# all four clones to git_fetch_ref. Both Gitea and GitHub allow fetching
|
||||
# arbitrary commits by default (uploadpack.allowReachableSHA1InWant).
|
||||
git_fetch_ref() { \
|
||||
url="$1"; ref="$2"; dest="$3"; \
|
||||
rm -rf "$dest"; mkdir -p "$dest"; \
|
||||
git -C "$dest" init -q && git -C "$dest" remote add origin "$url" && \
|
||||
for i in 1 2 3 4 5; do \
|
||||
if git -C "$dest" fetch --depth 1 origin "$ref" && git -C "$dest" checkout -q FETCH_HEAD; then return 0; fi; \
|
||||
echo "git fetch $url@$ref failed (attempt $i/5), retrying in $((i*5))s..."; \
|
||||
sleep $((i*5)); \
|
||||
done; \
|
||||
return 1; \
|
||||
} && \
|
||||
if [ "${PI_VERSION}" = "latest" ]; then \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent ; \
|
||||
else \
|
||||
NPM_CONFIG_PREFIX=/usr npm install -g @earendil-works/pi-coding-agent@${PI_VERSION} ; \
|
||||
fi && \
|
||||
pi --version && \
|
||||
git_fetch_ref "https://gitea.jordbo.se/joakimp/pi-toolkit.git" "${PI_TOOLKIT_REF}" /opt/pi-toolkit && \
|
||||
git_fetch_ref "https://gitea.jordbo.se/joakimp/pi-extensions.git" "${PI_EXTENSIONS_REF}" /opt/pi-extensions && \
|
||||
git_fetch_ref "${PI_FORK_REPO}" "${PI_FORK_REF}" /opt/pi-fork && \
|
||||
git_fetch_ref "${PI_OBSMEM_REPO}" "${PI_OBSMEM_REF}" /opt/pi-observational-memory && \
|
||||
(cd /opt/pi-fork && npm install --omit=dev --no-audit --no-fund) && \
|
||||
(cd /opt/pi-observational-memory && npm install --omit=dev --no-audit --no-fund) && \
|
||||
echo "pi-toolkit at $(cd /opt/pi-toolkit && git rev-parse --short HEAD)" && \
|
||||
echo "pi-extensions at $(cd /opt/pi-extensions && git rev-parse --short HEAD)" && \
|
||||
echo "pi-fork at $(cd /opt/pi-fork && git rev-parse --short HEAD)" && \
|
||||
echo "pi-observational-memory at $(cd /opt/pi-observational-memory && git rev-parse --short HEAD)"
|
||||
|
||||
# ── Optional: pi-studio (:latest-studio variant) ─────────────────────
|
||||
# pi-studio (omaclaren/pi-studio) is a pi-package + theme providing a
|
||||
# two-pane browser workspace: prompt/response editor, KaTeX/Mermaid live
|
||||
# preview, and tmux-backed literate REPLs. Off by default; the studio
|
||||
# variant sets INSTALL_STUDIO=true.
|
||||
#
|
||||
# Vendored to /opt/pi-studio and registered at container start by
|
||||
# entrypoint-user.sh via `pi install /opt/pi-studio` — the SAME pattern
|
||||
# as pi-fork / pi-observational-memory above. We deliberately do NOT run
|
||||
# `pi install <git-url>` at build time: that writes into ~/.pi/agent,
|
||||
# which is a named volume, so a build-time install collides with / is
|
||||
# shadowed by the volume on first run. Vendoring to /opt (an image layer)
|
||||
# + a runtime local-path install keeps it on the image and idempotent.
|
||||
#
|
||||
# No build step is needed: pi-studio ships its browser bundle prebuilt in
|
||||
# git (client/studio-client.js) and pi loads index.ts directly; its
|
||||
# package.json scripts are only test/typecheck. So we just fetch + install
|
||||
# the 3 prod deps (@earendil-works/pi-ai, @sinclair/typebox, ws).
|
||||
#
|
||||
# PI_STUDIO_REF is CI-resolved to a commit SHA to defeat the registry-
|
||||
# buildcache cache-hit footgun (see the PI_VERSION note above).
|
||||
ARG INSTALL_STUDIO=false
|
||||
ARG PI_STUDIO_REPO=https://github.com/omaclaren/pi-studio.git
|
||||
ARG PI_STUDIO_REF=main
|
||||
RUN if [ "${INSTALL_STUDIO}" = "true" ]; then \
|
||||
set -e; \
|
||||
rm -rf /opt/pi-studio && mkdir -p /opt/pi-studio && \
|
||||
git -C /opt/pi-studio init -q && \
|
||||
git -C /opt/pi-studio remote add origin "${PI_STUDIO_REPO}" && \
|
||||
ok=0; for i in 1 2 3 4 5; do \
|
||||
if git -C /opt/pi-studio fetch --depth 1 origin "${PI_STUDIO_REF}" && \
|
||||
git -C /opt/pi-studio checkout -q FETCH_HEAD; then ok=1; break; fi; \
|
||||
echo "git fetch pi-studio@${PI_STUDIO_REF} failed (attempt $i/5), retrying in $((i*5))s..."; \
|
||||
sleep $((i*5)); \
|
||||
done; \
|
||||
[ "$ok" = "1" ] && \
|
||||
(cd /opt/pi-studio && npm install --omit=dev --no-audit --no-fund) && \
|
||||
echo "pi-studio at $(cd /opt/pi-studio && git rev-parse --short HEAD)"; \
|
||||
fi
|
||||
|
||||
# STUDIO_PORT: advisory default consumed by docker-compose port publishing
|
||||
# and the recommended `/studio --no-browser --port "$STUDIO_PORT"` launch.
|
||||
# Harmless in the non-studio variant. NOTE: pi-studio hard-binds the server
|
||||
# to 127.0.0.1 inside the container (index.ts: .listen(port,"127.0.0.1")),
|
||||
# so reaching it from a browser needs a loopback bridge or host networking —
|
||||
# see the "Using pi-studio" section in README.md.
|
||||
ENV STUDIO_PORT=8765
|
||||
|
||||
# ── Optional: Go toolchain ───────────────────────────────────────────
|
||||
# Off by default; opt in for users who run Go tools inside the devbox.
|
||||
ARG INSTALL_GO=false
|
||||
ARG GO_VERSION=latest
|
||||
RUN if [ "${INSTALL_GO}" = "true" ]; then \
|
||||
GOARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${GO_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/?mode=json" | \
|
||||
awk -F'"' '/"version":/ { sub(/^go/,"",$4); print $4; exit }'); \
|
||||
fi && \
|
||||
[ -n "$V" ] && \
|
||||
echo "Installing Go ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://go.dev/dl/go${V}.linux-${GOARCH}.tar.gz" | tar -C /usr/local -xz && \
|
||||
ln -s /usr/local/go/bin/go /usr/local/bin/go && \
|
||||
ln -s /usr/local/go/bin/gofmt /usr/local/bin/gofmt; \
|
||||
fi
|
||||
|
||||
# WORKDIR / ENTRYPOINT / CMD inherited from base.
|
||||
@@ -1,58 +1,492 @@
|
||||
# pi-devbox
|
||||
|
||||
A Docker container image with [pi coding-agent](https://github.com/earendil-works/pi) pre-installed, built on the [opencode-devbox](https://gitea.jordbo.se/joakimp/opencode-devbox) base image.
|
||||
A self-contained Docker image for running [pi](https://pi.dev) — the pi
|
||||
coding-agent — in an isolated, reproducible Linux environment with a
|
||||
curated set of developer tooling, AI memory, and shell improvements.
|
||||
|
||||
pi-devbox is opinionated about what's inside but unopinionated about how
|
||||
you use it: a single `docker compose up` gives you an interactive
|
||||
container with pi, a stack of modern CLI tools, MemPalace for persistent
|
||||
agent memory across sessions, and a UID-aligned `/workspace` mount so
|
||||
files you edit inside the container appear with your normal ownership
|
||||
on the host.
|
||||
|
||||
## What's inside
|
||||
|
||||
Built on `opencode-devbox:base-latest`, which provides:
|
||||
### The pi coding-agent
|
||||
|
||||
- **Debian trixie** (stable base)
|
||||
- **Node.js** (LTS), **uv** (Python), **rustup** (Rust on-demand)
|
||||
- **AWS CLI** v2
|
||||
- **MemPalace** + MCP server (persistent agent memory across sessions)
|
||||
- **Gitea MCP** server
|
||||
- **Dev tools**: neovim (LazyVim), tmux, bat, eza, fzf, zoxide, ripgrep, git-lfs, make
|
||||
- **Shell**: bash with history tuning, prefix-search, fzf/zoxide integration
|
||||
- `pi` — the pi-coding-agent CLI (`@earendil-works/pi-coding-agent`)
|
||||
- `pi-toolkit` — keybindings, AWS env loader, settings template
|
||||
- `pi-extensions` — TypeScript extensions for pi (preview, MCP bridges,
|
||||
mempalace integration, etc.)
|
||||
- `pi-fork` — the `fork` tool for spawning sub-agents
|
||||
- `pi-observational-memory` — the `recall` tool for session compaction
|
||||
|
||||
This image adds:
|
||||
### MemPalace (AI memory)
|
||||
|
||||
- **pi** (`@earendil-works/pi-coding-agent`) — baked at `/usr/bin/pi`
|
||||
- **pi-toolkit** — keybindings, env loader, settings template (cloned to `/opt/pi-toolkit`)
|
||||
- **pi-extensions** — ext-toggle, todo, ssh-controlmaster, notify, git-checkpoint, mcp-loader, confirm-destructive (cloned to `/opt/pi-extensions`)
|
||||
- **mempalace bridge** — `mempalace.ts` extension symlinked from `/opt/mempalace-toolkit`
|
||||
- `mempalace` — local-first agent memory system (29 MCP tools)
|
||||
- `mempalace-toolkit` — bash wrappers for session/docs mining
|
||||
- ChromaDB embedding model pre-warmed at build time (`all-MiniLM-L6-v2`)
|
||||
|
||||
## Quick start
|
||||
The host-mounted palace at `~/.mempalace` is shared across the host and
|
||||
this container so all your agents share one brain.
|
||||
|
||||
### Modern CLI tooling
|
||||
|
||||
| Tool | Purpose |
|
||||
|---|---|
|
||||
| `nvim` | Neovim text editor |
|
||||
| `tmux` | Terminal multiplexer (configured for 0-indexed sessions) |
|
||||
| `ripgrep`, `fd` | Fast file content / filename search |
|
||||
| `fzf` | Fuzzy finder |
|
||||
| `bat` | Syntax-highlighted `cat` |
|
||||
| `eza` | Modern `ls` |
|
||||
| `zoxide` | Smart `cd` |
|
||||
| `jq`, `yq` | JSON / YAML query and transformation |
|
||||
| `tldr` (tealdeer) | Quick command examples |
|
||||
| `git`, `git-lfs`, `git-crypt` | Git + extensions |
|
||||
| `gitleaks` | Secret scanning pre-commit hook |
|
||||
| `gosu` | Privilege de-escalation in entrypoint |
|
||||
| `htop`, `tree`, `less` | Inspection utilities |
|
||||
|
||||
### Document and image tooling
|
||||
|
||||
- `pandoc` — universal Markdown↔HTML/Org/RST/etc. converter
|
||||
- `graphviz` — `dot` rendering for diagram pipelines
|
||||
- `imagemagick` — image conversion / resizing (invoked as `magick`)
|
||||
|
||||
### Language toolchains
|
||||
|
||||
- `python3` + `python3-venv` + `python3-pip` (system Python)
|
||||
- `uv` + `uvx` — fast Python package manager (preferred over pip/venv)
|
||||
- `nodejs` (v22) + `npm`
|
||||
- `gcc`, `g++`, `make` — C/C++ build tools
|
||||
- `rustup-init` — Rust toolchain installer (toolchains opt-in at runtime)
|
||||
- Optional `INSTALL_GO=true` build arg for Go
|
||||
|
||||
For Python REPLs and notebooks beyond the system interpreter, see the
|
||||
[uv-driven REPL recipes](#uv-driven-repl-recipes) section.
|
||||
|
||||
### Cloud + secrets
|
||||
|
||||
- AWS CLI v2 — for SSO + Bedrock auth
|
||||
- `gitea-mcp` — MCP server for Gitea API
|
||||
- `age`, `git-crypt` — encryption tooling
|
||||
|
||||
### 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) by
|
||||
multiplexing many ssh calls over one TCP flow.
|
||||
- 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.
|
||||
|
||||
## Quickstart
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Docker or OrbStack (recommended on macOS)
|
||||
- Optional: AWS credentials configured on the host if you'll use the
|
||||
Bedrock LLM provider
|
||||
|
||||
### Pull and run
|
||||
|
||||
```bash
|
||||
git clone https://gitea.jordbo.se/joakimp/pi-devbox
|
||||
cd pi-devbox
|
||||
cp .env.example .env # edit if needed
|
||||
docker compose up -d
|
||||
docker compose exec -u developer devbox bash
|
||||
```
|
||||
|
||||
You're now in the container as user `developer` with `pi` on PATH and
|
||||
your host workspace mounted at `/workspace`.
|
||||
|
||||
To start pi:
|
||||
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# edit .env — set WORKSPACE_PATH, GIT_USER_NAME, GIT_USER_EMAIL
|
||||
docker compose run --rm devbox
|
||||
# inside the container:
|
||||
pi
|
||||
```
|
||||
|
||||
## Versioning
|
||||
First-run pi-toolkit and pi-extensions install steps run automatically
|
||||
on container start; symlinks are written to `~/.pi/agent/` on the
|
||||
named volume (so they persist across container recreations).
|
||||
|
||||
Tags follow the pi npm version: `v0.74.0`, `v0.75.0`, etc.
|
||||
`latest` always points at the most recent release.
|
||||
### Stop / recreate / update
|
||||
|
||||
## Persistence
|
||||
```bash
|
||||
docker compose down # stop, keep volumes
|
||||
docker compose down -v # stop, wipe per-container volumes (palace data is bind-mounted, so unaffected)
|
||||
docker compose pull # fetch latest image
|
||||
docker compose up -d --force-recreate
|
||||
```
|
||||
|
||||
| Volume | What it holds |
|
||||
|--------|---------------|
|
||||
| `devbox-pi-config` | pi settings, extensions toggle state, sessions (`~/.pi/`) |
|
||||
| `devbox-shell-history` | bash history |
|
||||
| `devbox-zoxide` | zoxide directory jump history |
|
||||
| `devbox-nvim-data` | neovim plugins, Mason packages |
|
||||
| `devbox-uv` | uv Python installs and tool cache |
|
||||
## Image variants
|
||||
|
||||
## User-installed pi packages
|
||||
Currently published:
|
||||
|
||||
`NPM_CONFIG_PREFIX` is set to `/home/developer/.pi/npm-global`, so any `pi install npm:...` or `npm install -g` as the `developer` user lands on the `devbox-pi-config` volume and survives container recreation and image rebuilds. A user-installed pi wins over the baked binary via `PATH` order.
|
||||
| Tag | Includes | Size (approx.) |
|
||||
|---|---|---|
|
||||
| `joakimp/pi-devbox:latest` | base + pi + tooling | ~3.2 GB |
|
||||
| `joakimp/pi-devbox:vX.Y.Z` | pinned-version equivalent | ~3.2 GB |
|
||||
| `joakimp/pi-devbox:latest-studio` | `latest` + [pi-studio](https://github.com/omaclaren/pi-studio) (browser prompt editor, KaTeX/Mermaid preview, tmux-backed literate REPLs) | ~3.25 GB |
|
||||
| `joakimp/pi-devbox:vX.Y.Z-studio` | pinned-version studio equivalent | ~3.25 GB |
|
||||
|
||||
## Source
|
||||
Planned for an upcoming minor release:
|
||||
|
||||
- [pi-devbox](https://gitea.jordbo.se/joakimp/pi-devbox) — this repo
|
||||
- [opencode-devbox](https://gitea.jordbo.se/joakimp/opencode-devbox) — base image source
|
||||
- [pi-toolkit](https://gitea.jordbo.se/joakimp/pi-toolkit)
|
||||
- [pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions)
|
||||
- `joakimp/pi-devbox:latest-studio-tex` — `-studio` plus `texlive-xetex`
|
||||
for PDF export from Studio. Adds ~600 MB on top of `-studio`.
|
||||
|
||||
## Using pi-studio (`-studio` variant)
|
||||
|
||||
The `-studio` images bundle [pi-studio](https://github.com/omaclaren/pi-studio):
|
||||
a two-pane browser workspace with a prompt/response editor, live
|
||||
KaTeX/Mermaid preview, and tmux-backed literate REPLs (Shell / Python /
|
||||
IPython / Julia / R / GHCi / Clojure). It is registered automatically on
|
||||
container start (no `pi install` needed) and exposes the `/studio` slash
|
||||
command plus the `studio_repl_send` / `studio_export_*` agent tools.
|
||||
|
||||
Inside a pi session in the container:
|
||||
|
||||
```
|
||||
/studio --no-browser --port 8765 # pin a fixed port; STUDIO_PORT=8765 is the baked default
|
||||
/studio --status # reprint the tokenized URL
|
||||
```
|
||||
|
||||
### Reaching the UI from your browser (the container caveat)
|
||||
|
||||
pi-studio **hard-binds its server to `127.0.0.1` inside the container**
|
||||
(`index.ts`: `.listen(port, "127.0.0.1")`) and serves a tokenized URL.
|
||||
There is no `--host`/bind flag. This matters for a container: a plain
|
||||
`docker run -p 8765:8765` publish forwards to the container's *external*
|
||||
interface, **not** its loopback, so it will not reach Studio. Two paths
|
||||
work:
|
||||
|
||||
**A. Host networking (simplest — OrbStack / single-host, no bridge).**
|
||||
Run the container with host networking so the container's loopback is the
|
||||
host's loopback:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
devbox:
|
||||
network_mode: host # container 127.0.0.1 == host 127.0.0.1
|
||||
```
|
||||
|
||||
Then `http://127.0.0.1:8765/?token=…` works in a browser on the Docker
|
||||
host. This is the most secure option (Studio never leaves loopback). Note:
|
||||
host networking changes `host.docker.internal` semantics, so weigh it
|
||||
against the LAN-jump SSH feature if you use that.
|
||||
|
||||
**B. `studio-expose` bridge (portable — any networking mode).** Publish a
|
||||
port and run the bundled `studio-expose` helper, which uses `socat` to
|
||||
bridge the container's loopback to its external interface (binding the
|
||||
egress IP on the same port, so the token URL Studio printed works
|
||||
verbatim):
|
||||
|
||||
```yaml
|
||||
services:
|
||||
devbox:
|
||||
ports:
|
||||
- "127.0.0.1:8765:8765" # host-localhost only
|
||||
environment:
|
||||
- STUDIO_EXPOSE=1 # auto-start the bridge on container boot
|
||||
```
|
||||
|
||||
With `STUDIO_EXPOSE=1`, the entrypoint starts the bridge for you; just run
|
||||
`/studio --port 8765` in your pi session. To bridge manually instead
|
||||
(leave `STUDIO_EXPOSE` unset), run `studio-expose` in a container shell:
|
||||
|
||||
```bash
|
||||
studio-expose # bridges $STUDIO_PORT (default 8765); --help for details
|
||||
```
|
||||
|
||||
> **Security:** the bridge intentionally exposes Studio beyond loopback;
|
||||
> its tokenized URL is the only auth. Keep the host-side publish on
|
||||
> `127.0.0.1:` and use `ssh -L` for remote access. Default is **off**.
|
||||
|
||||
### Remote host (SSH / mosh)
|
||||
|
||||
When the Docker host is remote, keep Studio on localhost and forward the
|
||||
port from your laptop:
|
||||
|
||||
```bash
|
||||
ssh -L 8765:127.0.0.1:8765 user@docker-host # then open the token URL locally
|
||||
```
|
||||
|
||||
**mosh cannot forward ports** (no `-L`/`-R` equivalent). To use Studio
|
||||
over a mosh session, run a *separate* `ssh -L 8765:127.0.0.1:8765 host`
|
||||
tunnel alongside mosh (mosh for the shell, ssh for the port), or reach the
|
||||
host's published port directly over a trusted network (LAN / Tailscale /
|
||||
WireGuard).
|
||||
|
||||
> PDF export (`/studio-pdf`, `studio_export_pdf`) needs a LaTeX engine,
|
||||
> which is **not** in `-studio` (only the planned `-studio-tex`). HTML
|
||||
> export, KaTeX, Mermaid, and all REPL features work without it.
|
||||
|
||||
## docker-compose.yml — basic shape
|
||||
|
||||
```yaml
|
||||
name: pi-devbox
|
||||
|
||||
services:
|
||||
devbox:
|
||||
image: joakimp/pi-devbox:latest
|
||||
container_name: pi-devbox
|
||||
stdin_open: true
|
||||
tty: true
|
||||
env_file:
|
||||
- .env
|
||||
environment:
|
||||
- TERM=xterm-256color
|
||||
- GITEA_ACCESS_TOKEN=${GITEA_ACCESS_TOKEN:-}
|
||||
- GITEA_HOST=${GITEA_HOST:-}
|
||||
- GITHUB_PERSONAL_ACCESS_TOKEN=${GITHUB_PERSONAL_ACCESS_TOKEN:-}
|
||||
volumes:
|
||||
# Workspace: your host source tree
|
||||
- ${WORKSPACE_PATH:-.}:/workspace
|
||||
# SSH keys: read-only from host
|
||||
- ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro
|
||||
# Per-container persistent state
|
||||
- devbox-pi-config:/home/developer/.pi
|
||||
- devbox-ssh-local:/home/developer/.ssh-local
|
||||
- devbox-shell-history:/home/developer/.cache/bash
|
||||
- devbox-zoxide:/home/developer/.local/share/zoxide
|
||||
- devbox-nvim-data:/home/developer/.local/share/nvim
|
||||
- devbox-uv:/home/developer/.local/share/uv
|
||||
# Optional (uncomment to enable):
|
||||
# - ~/.aws:/home/developer/.aws # AWS creds
|
||||
# - devbox-palace:/home/developer/.mempalace # persist palace
|
||||
# - devbox-chroma-cache:/home/developer/.cache/chroma # embedding cache
|
||||
|
||||
volumes:
|
||||
devbox-pi-config:
|
||||
devbox-ssh-local:
|
||||
devbox-shell-history:
|
||||
devbox-zoxide:
|
||||
devbox-nvim-data:
|
||||
devbox-uv:
|
||||
# devbox-palace:
|
||||
# devbox-chroma-cache:
|
||||
```
|
||||
|
||||
See `docker-compose.yml` and `.env.example` in the repo for the full
|
||||
template (build-from-source args, LAN-jump and skillset mounts, MemPalace
|
||||
persistence). To share one palace between host pi and the container,
|
||||
bind-mount your host `~/.mempalace` to `/home/developer/.mempalace`.
|
||||
|
||||
## uv-driven REPL recipes
|
||||
|
||||
uv is installed in the base image and is the recommended way to run
|
||||
Python interpreters and notebooks without bloating the image:
|
||||
|
||||
| Goal | One-liner |
|
||||
|---|---|
|
||||
| IPython REPL | `uv run --with ipython ipython` |
|
||||
| IPython + scientific stack | `uv run --with ipython --with numpy --with matplotlib --with pandas ipython` |
|
||||
| JupyterLab (browser, port-forward needed) | `uv run --with jupyterlab jupyter lab --no-browser --port 8888` |
|
||||
| Marimo (modern alternative) | `uv run --with marimo marimo edit --port 8889` |
|
||||
|
||||
For long-lived environments, prefer a project venv:
|
||||
|
||||
```bash
|
||||
cd /workspace/myproj
|
||||
uv init && uv add ipython numpy matplotlib
|
||||
# then:
|
||||
uv run ipython
|
||||
```
|
||||
|
||||
`pyproject.toml` + `uv.lock` then capture the dependency state and
|
||||
travel with the project in git.
|
||||
|
||||
uv only manages Python. For other languages:
|
||||
|
||||
| Toolchain | How to add |
|
||||
|---|---|
|
||||
| R | `sudo apt-get install r-base-core` (~200 MB) |
|
||||
| GHCi (Haskell) | `sudo apt-get install ghc` (~700 MB) |
|
||||
| Clojure | `sudo apt-get install clojure` (~150 MB + JVM) |
|
||||
| Julia | `juliaup` is planned for an upcoming release |
|
||||
|
||||
These are runtime opt-ins and persist only in the container's writable
|
||||
layer — they don't survive `docker compose down -v` or image updates.
|
||||
|
||||
## tldr — first-run cache
|
||||
|
||||
The `tldr` command (provided by tealdeer) shows a "Page cache not
|
||||
found" message on first invocation. To populate the cache:
|
||||
|
||||
```bash
|
||||
tldr --update
|
||||
```
|
||||
|
||||
This fetches ~1500 command pages from the [tldr-pages](https://tldr.sh)
|
||||
project and caches them in `~/.cache/tealdeer/`. After that, `tldr ls`,
|
||||
`tldr docker`, etc. work instantly. Re-run `tldr --update` periodically
|
||||
to refresh.
|
||||
|
||||
## Volumes and persistence
|
||||
|
||||
| Path inside container | Volume | What survives |
|
||||
|---|---|---|
|
||||
| `/workspace` | host bind-mount (`WORKSPACE_PATH`) | host filesystem |
|
||||
| `~/.ssh` | host bind-mount (read-only, `SSH_KEY_PATH`) | host filesystem |
|
||||
| `~/.pi` | named volume `devbox-pi-config` | `down -v` wipes |
|
||||
| `~/.ssh-local` | named volume `devbox-ssh-local` | `down -v` wipes |
|
||||
| `~/.cache/bash` | named volume `devbox-shell-history` | `down -v` wipes |
|
||||
| `~/.local/share/zoxide` | named volume `devbox-zoxide` | `down -v` wipes |
|
||||
| `~/.local/share/nvim` | named volume `devbox-nvim-data` | `down -v` wipes |
|
||||
| `~/.local/share/uv` | named volume `devbox-uv` | `down -v` wipes |
|
||||
| `~/.mempalace` | host bind-mount or `devbox-palace` (optional) | host / volume |
|
||||
| `~/.cache/chroma` | `devbox-chroma-cache` (optional) | `down -v` wipes |
|
||||
|
||||
Anything not on a volume is on the writable layer and is lost on
|
||||
container recreate.
|
||||
|
||||
## MemPalace integration
|
||||
|
||||
MemPalace is installed in the base image and pre-warmed with the
|
||||
ChromaDB ONNX embedding model so first-time semantic search is
|
||||
instant.
|
||||
|
||||
The palace data lives at `~/.mempalace/palace` on the host
|
||||
(bind-mounted into the container). This means:
|
||||
|
||||
- A pi running on the host and a pi running inside this container see
|
||||
the same palace.
|
||||
- SQLite's WAL mode handles concurrent reads + single writer cleanly,
|
||||
so simultaneous use is safe in practice.
|
||||
|
||||
`mempalace-session` and `mempalace-docs` are on PATH for one-off
|
||||
session/docs mining; the 29 MCP tools (search, kg-query, drawer-add,
|
||||
diary-write, etc.) are wired into pi automatically by the pi-extensions
|
||||
mempalace bridge.
|
||||
|
||||
## SSH and ControlMaster
|
||||
|
||||
The base image preconfigures `Host *` ssh defaults:
|
||||
|
||||
```
|
||||
ControlMaster auto
|
||||
ControlPath /tmp/sshcm/%r@%h:%p
|
||||
ControlPersist 10m
|
||||
```
|
||||
|
||||
The socket directory `/tmp/sshcm/` is created mode 700 on every
|
||||
container start (per-container, tmpfs-friendly). Multiple ssh calls
|
||||
to the same host within 10 minutes reuse the master TCP flow —
|
||||
important on residential ISPs with CGNAT per-destination flow caps
|
||||
(~4 flows on most European broadband; symptoms are
|
||||
`kex_exchange_identification: Connection closed by remote host` on
|
||||
the 5th+ concurrent ssh).
|
||||
|
||||
User-level overrides in `~/.ssh/config` win because Debian's
|
||||
`/etc/ssh/ssh_config` includes `/etc/ssh/ssh_config.d/*.conf` before
|
||||
the `Host *` block.
|
||||
|
||||
## tmux and 0-indexed sessions
|
||||
|
||||
The image installs `/etc/tmux.conf` with:
|
||||
|
||||
```
|
||||
set -g base-index 0
|
||||
set -g pane-base-index 0
|
||||
```
|
||||
|
||||
This is the default tmux indexing. It's baked here because `pi-studio`
|
||||
(shipped in the `:latest-studio` variant) hard-codes its tmux send
|
||||
target to `<session>:0.0`. If you override `base-index` to 1 in a
|
||||
personal `~/.tmux.conf`, pi-studio will fail with "can't find window: 0".
|
||||
|
||||
## AWS Bedrock auth
|
||||
|
||||
If you use Bedrock as pi's LLM provider:
|
||||
|
||||
1. Configure SSO on the host: `aws configure sso`
|
||||
2. Bind-mount `~/.aws:/home/developer/.aws:ro`
|
||||
3. Set `AWS_PROFILE` and `AWS_REGION` in `.env`
|
||||
4. Inside the container: `aws sso login` if needed; pi picks up the
|
||||
profile via the env vars.
|
||||
|
||||
The pi-toolkit AWS env loader (in `~/.pi/agent/`) prepares Bedrock
|
||||
inference-profile model IDs (with `eu.` / `us.` prefixes) automatically.
|
||||
|
||||
## Build pipeline
|
||||
|
||||
pi-devbox is built from this repo's CI in two phases:
|
||||
|
||||
1. **Base** (`Dockerfile.base`) — produces `joakimp/pi-devbox:base-<hash>`
|
||||
where `<hash>` is content-addressed over `Dockerfile.base`,
|
||||
`rootfs/`, and `entrypoint*.sh`. Rebuilt only when these change.
|
||||
2. **Variant** (`Dockerfile.variant`) — `FROM ${BASE_IMAGE}` and adds
|
||||
the pi install (+ pi-studio when `INSTALL_STUDIO=true`). The `:latest`
|
||||
/ `vX.Y.Z` and `:latest-studio` / `vX.Y.Z-studio` tags are produced
|
||||
from this layer. The studio variant builds via independent
|
||||
`smoke-studio` + `build-variant-studio` CI jobs that gate only the
|
||||
`-studio` tags.
|
||||
|
||||
Tag naming:
|
||||
|
||||
| Tag | Stage |
|
||||
|---|---|
|
||||
| `base-<hash>` | base image — internal building block |
|
||||
| `base-latest` | promoted alias of the most recent base |
|
||||
| `latest`, `vX.Y.Z` | variant: base + pi |
|
||||
| `latest-studio`, `vX.Y.Z-studio` | variant: base + pi + pi-studio |
|
||||
|
||||
CI resolves `PI_VERSION` to a concrete version string before building
|
||||
to defeat a registry-buildcache hit on `npm install -g
|
||||
pi-coding-agent@latest` (the build-arg string would otherwise be
|
||||
byte-identical across releases and the layer would silently reuse the
|
||||
previous version's bytes).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Image grew unexpectedly
|
||||
|
||||
`docker history joakimp/pi-devbox:latest` shows per-layer sizes. The
|
||||
biggest layers are typically the apt block (~600 MB), pi npm install
|
||||
(~330 MB), MemPalace + ChromaDB (~315 MB), AWS CLI (~270 MB), Node.js
|
||||
(~200 MB).
|
||||
|
||||
### pi can't reach LAN peers on macOS
|
||||
|
||||
The LAN-access helper (`/usr/local/lib/pi-devbox/setup-lan-access.sh`)
|
||||
auto-runs on container start and writes `~/.ssh-local/config` with a
|
||||
ssh-jump-via-host configuration. Set `DEVBOX_LAN_ACCESS=jump` and
|
||||
`HOST_SSH_USER=<your-mac-user>` in `.env` if auto-detection fails.
|
||||
|
||||
### Smoke-testing a local build
|
||||
|
||||
```bash
|
||||
./scripts/smoke-test.sh joakimp/pi-devbox:latest
|
||||
```
|
||||
|
||||
## Versioning and release
|
||||
|
||||
pi-devbox follows semver-ish:
|
||||
|
||||
- **Major** — architectural changes. `v1.0.0` is the first decoupled
|
||||
release (independent of opencode-devbox).
|
||||
- **Minor** — new variants, significant base additions.
|
||||
- **Patch** — pi version bumps, smaller fixes.
|
||||
|
||||
The `pi --version` inside the image is asserted by smoke tests to
|
||||
match the release tag's pi component, so version drift between the
|
||||
image and the tag is caught at CI time.
|
||||
|
||||
## Acknowledgements
|
||||
|
||||
pi-devbox was originally a thin re-brand of the `pi-only` variant of
|
||||
[opencode-devbox](https://gitea.jordbo.se/joakimp/opencode-devbox).
|
||||
It was decoupled at `v1.0.0` so it could evolve at its own pace, with
|
||||
self-contained docs and a focused, pi-centric image. Significant base
|
||||
infrastructure (the SSH ControlMaster setup, MemPalace integration,
|
||||
the entrypoint UID/GID dance) was adopted from there.
|
||||
|
||||
The pi coding-agent itself is [@earendil-works/pi-coding-agent](https://www.npmjs.com/package/@earendil-works/pi-coding-agent).
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
+21
-1
@@ -16,8 +16,14 @@ services:
|
||||
# To build from source instead of pulling from Docker Hub:
|
||||
# build:
|
||||
# context: .
|
||||
# dockerfile: Dockerfile.variant
|
||||
# args:
|
||||
# PI_VERSION: "latest"
|
||||
# # Pin a specific base build by hash instead of tracking base-latest:
|
||||
# BASE_IMAGE: "joakimp/pi-devbox:base-<hash>"
|
||||
# # PI_VERSION must be a concrete version, not 'latest', to defeat
|
||||
# # the registry-buildcache cache-hit footgun. CI resolves this from
|
||||
# # the npm registry; for a local build you can set it manually.
|
||||
# PI_VERSION: "0.79.1"
|
||||
container_name: pi-devbox
|
||||
stdin_open: true
|
||||
tty: true
|
||||
@@ -35,12 +41,25 @@ services:
|
||||
# SSH keys (read-only) — for git push/pull
|
||||
- ${SSH_KEY_PATH:-~/.ssh}:/home/developer/.ssh:ro
|
||||
|
||||
# Optional: host-owned shell config + LAN jump overrides. The image's
|
||||
# ~/.bash_aliases sources ~/.config/devbox-shell/bash_aliases if present,
|
||||
# and setup-lan-access.sh reads ~/.config/devbox-shell/ssh-lan.conf for
|
||||
# named-peer `ProxyJump host` overrides (reach LAN peers by name via
|
||||
# `dssh <peer>`; see opencode-devbox's ssh-lan.conf.example).
|
||||
# - ~/.config/devbox-shell:/home/developer/.config/devbox-shell:ro
|
||||
|
||||
# Optional: mount skillset repo for automatic skill/instruction deployment.
|
||||
# - ${SKILLSET_PATH}:/home/developer/skillset
|
||||
|
||||
# Persist pi config (settings.json, extensions, sessions, auth)
|
||||
- devbox-pi-config:/home/developer/.pi
|
||||
|
||||
# Persist the generated LAN-jump keypair (~/.ssh-local) across recreates.
|
||||
# setup-lan-access.sh generates this key once and reuses it; persisting
|
||||
# it means you authorize it on the host ONCE rather than re-authorizing
|
||||
# after every `docker compose up --force-recreate`.
|
||||
- devbox-ssh-local:/home/developer/.ssh-local
|
||||
|
||||
# Persist bash history across container recreations
|
||||
- devbox-shell-history:/home/developer/.cache/bash
|
||||
|
||||
@@ -64,6 +83,7 @@ services:
|
||||
|
||||
volumes:
|
||||
devbox-pi-config:
|
||||
devbox-ssh-local:
|
||||
devbox-shell-history:
|
||||
devbox-zoxide:
|
||||
devbox-nvim-data:
|
||||
|
||||
Executable
+166
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
# ── SSH ControlMaster socket dir ────────────────────────────────
|
||||
# Companion to /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf in the
|
||||
# base image — that file declares ControlPath=/tmp/sshcm/%r@%h:%p; this
|
||||
# creates the directory with the right permissions on every container
|
||||
# start. /tmp is per-container so the dir doesn't survive recreation;
|
||||
# baking it into a Dockerfile layer would be wrong.
|
||||
# Mode 700 is required — OpenSSH refuses to use a ControlPath dir that
|
||||
# others can write to.
|
||||
mkdir -p /tmp/sshcm
|
||||
chmod 700 /tmp/sshcm
|
||||
|
||||
# ── LAN access: generic host-OS-agnostic reachability helper ────────
|
||||
# On VM-backed hosts (macOS OrbStack / Docker Desktop) the container can't
|
||||
# reach the host's directly-attached LAN peers by default; this generates a
|
||||
# writable ~/.ssh-local/config that uses the host as an SSH jump. On native
|
||||
# Linux (LAN reachable directly) it is a no-op. Controlled by DEVBOX_LAN_ACCESS
|
||||
# (auto|jump|off) + HOST_SSH_USER. Always non-fatal. See the script header.
|
||||
if [ -r /usr/local/lib/pi-devbox/setup-lan-access.sh ]; then
|
||||
bash /usr/local/lib/pi-devbox/setup-lan-access.sh || true
|
||||
fi
|
||||
|
||||
# ── Shell defaults: copy baked files from /etc/skel-devbox/ if absent
|
||||
# Respects host bind-mounts and user customizations — existing files
|
||||
# are never overwritten. To restore defaults: rm ~/.bash_aliases (or
|
||||
# .inputrc) and recreate the container, or cp from /etc/skel-devbox/
|
||||
# directly.
|
||||
SKEL_DIR="/etc/skel-devbox"
|
||||
if [ -d "$SKEL_DIR" ]; then
|
||||
for f in .bash_aliases .inputrc; do
|
||||
if [ -f "$SKEL_DIR/$f" ] && [ ! -e "$HOME/$f" ]; then
|
||||
cp "$SKEL_DIR/$f" "$HOME/$f"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── MemPalace: initialize palace for the workspace if mempalace is installed
|
||||
# Creates the palace directory structure on first run. Idempotent — skips
|
||||
# if palace already exists, so upgrades from older versions preserve
|
||||
# existing data. `--yes` auto-accepts detected entities so the init is
|
||||
# non-interactive.
|
||||
if command -v mempalace &>/dev/null && [ -d /workspace ]; then
|
||||
PALACE_DIR="${HOME}/.mempalace"
|
||||
if [ ! -d "$PALACE_DIR/palace" ]; then
|
||||
echo "Initializing MemPalace for workspace (non-interactive)..."
|
||||
# </dev/null: mempalace init has an interactive "Mine this directory
|
||||
# now? [Y/n]" prompt that --yes does not auto-answer in all paths.
|
||||
# Without redirected stdin, the process blocks here forever when run
|
||||
# from `docker run -it` (the TTY keeps stdin open). EOF on stdin
|
||||
# makes the prompt fall through to its default (skip).
|
||||
mempalace init --yes /workspace </dev/null >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Git config defaults ──────────────────────────────────────────────
|
||||
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
|
||||
git config --global user.name "$GIT_USER_NAME"
|
||||
fi
|
||||
if [ -n "${GIT_USER_EMAIL:-}" ] && ! git config --global user.email &>/dev/null; then
|
||||
git config --global user.email "$GIT_USER_EMAIL"
|
||||
fi
|
||||
|
||||
# ── pi: deploy toolkit + extensions + mempalace bridge ─────────────
|
||||
# pi is always installed in pi-devbox; no INSTALL_PI guard needed.
|
||||
# Each install.sh is idempotent and backs up real files before linking,
|
||||
# so re-running across container restarts is safe.
|
||||
#
|
||||
# Order: pi-toolkit first (creates ~/.pi/agent/keybindings.json symlink
|
||||
# and writes the AWS env loader), then pi-extensions (symlinks our
|
||||
# extensions), then settings.json bootstrap from the toolkit template,
|
||||
# then the mempalace bridge symlink (one-liner; mempalace-toolkit's
|
||||
# install_skill is intentionally skipped to avoid racing with skillset
|
||||
# auto-deploy below).
|
||||
if command -v pi &>/dev/null; then
|
||||
if [ -d /opt/pi-toolkit ]; then
|
||||
(cd /opt/pi-toolkit && ./install.sh --yes) || \
|
||||
echo "WARN: pi-toolkit install.sh failed (continuing)"
|
||||
fi
|
||||
|
||||
if [ -d /opt/pi-extensions ]; then
|
||||
(cd /opt/pi-extensions && ./install.sh --yes) || \
|
||||
echo "WARN: pi-extensions install.sh failed (continuing)"
|
||||
fi
|
||||
|
||||
# Bootstrap settings.json from template if absent (pi rewrites this
|
||||
# file at runtime — lastChangelogVersion, etc — so we can't symlink it).
|
||||
if [ ! -f "$HOME/.pi/agent/settings.json" ] && \
|
||||
[ -f /opt/pi-toolkit/settings.example.json ]; then
|
||||
cp /opt/pi-toolkit/settings.example.json "$HOME/.pi/agent/settings.json"
|
||||
fi
|
||||
|
||||
# pi↔mempalace MCP bridge — single extension symlink.
|
||||
if [ -f /opt/mempalace-toolkit/extensions/pi/mempalace.ts ] && \
|
||||
command -v mempalace &>/dev/null && \
|
||||
[ ! -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
|
||||
ln -sf /opt/mempalace-toolkit/extensions/pi/mempalace.ts \
|
||||
"$HOME/.pi/agent/extensions/mempalace.ts"
|
||||
fi
|
||||
|
||||
# pi-fork (fork tool) + pi-observational-memory (recall tool) + (in the
|
||||
# :latest-studio variant only) pi-studio (/studio command + studio_*
|
||||
# tools + theme). These are pi packages (not symlink-style extensions):
|
||||
# they're cloned to /opt with node_modules baked at BUILD time, then
|
||||
# registered here via `pi install <local-path>`. A local-path install is
|
||||
# instant + in-place (pi loads the extension directly from /opt) +
|
||||
# idempotent (no duplicate package entry on re-run), and stores a relative
|
||||
# path that resolves into the image-layer /opt so it survives volume
|
||||
# recreate. The tools/command register on the NEXT pi start (extensions
|
||||
# bind at startup). Guard on settings.json so we only install once per
|
||||
# volume. /opt/pi-studio is present only in the studio variant; the
|
||||
# `[ -d ]` test makes this a no-op everywhere else.
|
||||
for _pkg in /opt/pi-fork /opt/pi-observational-memory /opt/pi-studio; do
|
||||
[ -d "$_pkg" ] || continue
|
||||
_name=$(basename "$_pkg")
|
||||
if ! grep -q "$_name" "$HOME/.pi/agent/settings.json" 2>/dev/null; then
|
||||
pi install "$_pkg" >/dev/null 2>&1 || \
|
||||
echo "WARN: pi install $_name failed (continuing)"
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
# ── pi-studio: optional loopback bridge (opt-in) ──────────────────────
|
||||
# pi-studio binds its server to 127.0.0.1 inside the container, which a
|
||||
# published Docker port cannot reach. When STUDIO_EXPOSE is truthy (set in
|
||||
# compose), start the `studio-expose` socat bridge in the background so a
|
||||
# published port + `ssh -L` tunnel can reach Studio once the user runs
|
||||
# `/studio --port "$STUDIO_PORT"`. Default OFF — Studio stays loopback-only
|
||||
# (its secure default) unless explicitly opted in. Guarded on the studio
|
||||
# variant (/opt/pi-studio) so it is a no-op in the plain image.
|
||||
case "${STUDIO_EXPOSE:-}" in
|
||||
1|true|TRUE|yes|on)
|
||||
if [ -d /opt/pi-studio ] && command -v studio-expose &>/dev/null && command -v socat &>/dev/null; then
|
||||
echo "STUDIO_EXPOSE set — starting studio-expose bridge on port ${STUDIO_PORT:-8765} (background)"
|
||||
nohup studio-expose "${STUDIO_PORT:-8765}" >/tmp/studio-expose.log 2>&1 &
|
||||
else
|
||||
echo "STUDIO_EXPOSE set but studio-expose/socat/pi-studio unavailable — skipping bridge"
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
# ── Skillset: deploy skills/instructions from mounted skillset repo ──
|
||||
# When the skillset repo is mounted (at $HOME/skillset or /workspace/skillset),
|
||||
# run the deploy script to create relative symlinks for skills and instructions.
|
||||
# This ensures skills resolve correctly inside the container regardless of
|
||||
# where the repo lives on the host. Idempotent — second run is a no-op.
|
||||
#
|
||||
# Detection order:
|
||||
# 1. SKILLSET_CONTAINER_PATH env var (explicit, for non-standard layouts)
|
||||
# 2. $HOME/skillset (dedicated volume mount via SKILLSET_PATH in compose)
|
||||
# 3. /workspace/skillset (skillset is directly inside workspace root)
|
||||
SKILLSET_DEPLOY=""
|
||||
if [ -n "${SKILLSET_CONTAINER_PATH:-}" ] && [ -x "${SKILLSET_CONTAINER_PATH}/deploy-skills.sh" ]; then
|
||||
SKILLSET_DEPLOY="${SKILLSET_CONTAINER_PATH}/deploy-skills.sh"
|
||||
elif [ -x "$HOME/skillset/deploy-skills.sh" ]; then
|
||||
SKILLSET_DEPLOY="$HOME/skillset/deploy-skills.sh"
|
||||
elif [ -x /workspace/skillset/deploy-skills.sh ]; then
|
||||
SKILLSET_DEPLOY="/workspace/skillset/deploy-skills.sh"
|
||||
fi
|
||||
if [ -n "$SKILLSET_DEPLOY" ]; then
|
||||
"$SKILLSET_DEPLOY" --bootstrap --prune-stale >/dev/null 2>&1 || true
|
||||
fi
|
||||
|
||||
# ── Execute command ──────────────────────────────────────────────────
|
||||
exec "$@"
|
||||
Executable
+122
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
USER_NAME="developer"
|
||||
CURRENT_UID=$(id -u "$USER_NAME")
|
||||
CURRENT_GID=$(id -g "$USER_NAME")
|
||||
|
||||
# ── UID/GID adjustment ───────────────────────────────────────────────
|
||||
# Priority per dimension: env var > auto-detect from /workspace > no-op
|
||||
# UID and GID are detected independently so a GID-only mismatch (e.g. host
|
||||
# user has UID 1000 but primary group at GID 1001) is still corrected.
|
||||
TARGET_UID="${USER_UID:-}"
|
||||
TARGET_GID="${USER_GID:-}"
|
||||
|
||||
if [ -d /workspace ]; then
|
||||
WORKSPACE_UID=$(stat -c '%u' /workspace 2>/dev/null || stat -f '%u' /workspace 2>/dev/null || echo "")
|
||||
WORKSPACE_GID=$(stat -c '%g' /workspace 2>/dev/null || stat -f '%g' /workspace 2>/dev/null || echo "")
|
||||
# Adopt workspace UID if env var not set and workspace is non-root-owned
|
||||
if [ -z "$TARGET_UID" ] && [ -n "$WORKSPACE_UID" ] && [ "$WORKSPACE_UID" != "0" ] && [ "$WORKSPACE_UID" != "$CURRENT_UID" ]; then
|
||||
TARGET_UID="$WORKSPACE_UID"
|
||||
fi
|
||||
# Adopt workspace GID if env var not set and workspace group differs
|
||||
if [ -z "$TARGET_GID" ] && [ -n "$WORKSPACE_GID" ] && [ "$WORKSPACE_GID" != "0" ] && [ "$WORKSPACE_GID" != "$CURRENT_GID" ]; then
|
||||
TARGET_GID="$WORKSPACE_GID"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Apply UID/GID changes if needed
|
||||
if [ -n "$TARGET_GID" ] && [ "$TARGET_GID" != "$CURRENT_GID" ]; then
|
||||
groupmod -g "$TARGET_GID" "$USER_NAME" 2>/dev/null || true
|
||||
find /home/"$USER_NAME" -not -path "/home/$USER_NAME/.ssh/*" -group "$CURRENT_GID" -exec chgrp "$TARGET_GID" {} + 2>/dev/null || true
|
||||
echo "Adjusted developer GID to $TARGET_GID"
|
||||
fi
|
||||
|
||||
if [ -n "$TARGET_UID" ] && [ "$TARGET_UID" != "$CURRENT_UID" ]; then
|
||||
usermod -u "$TARGET_UID" "$USER_NAME" 2>/dev/null || true
|
||||
find /home/"$USER_NAME" -not -path "/home/$USER_NAME/.ssh/*" -user "$CURRENT_UID" -exec chown "$TARGET_UID" {} + 2>/dev/null || true
|
||||
echo "Adjusted developer UID to $TARGET_UID"
|
||||
fi
|
||||
|
||||
# ── SSH key permissions ──────────────────────────────────────────────
|
||||
# If SSH keys are mounted, fix permissions (skip if read-only mount)
|
||||
if [ -d "/home/$USER_NAME/.ssh" ] && [ "$(ls -A "/home/$USER_NAME/.ssh" 2>/dev/null)" ]; then
|
||||
if touch "/home/$USER_NAME/.ssh/.perm_test" 2>/dev/null; then
|
||||
rm -f "/home/$USER_NAME/.ssh/.perm_test"
|
||||
chmod 700 "/home/$USER_NAME/.ssh"
|
||||
find "/home/$USER_NAME/.ssh" -type f -name "id_*" ! -name "*.pub" -exec chmod 600 {} \; 2>/dev/null || true
|
||||
find "/home/$USER_NAME/.ssh" -type f -name "*.pub" -exec chmod 644 {} \; 2>/dev/null || true
|
||||
[ -f "/home/$USER_NAME/.ssh/known_hosts" ] && chmod 644 "/home/$USER_NAME/.ssh/known_hosts"
|
||||
[ -f "/home/$USER_NAME/.ssh/config" ] && chmod 600 "/home/$USER_NAME/.ssh/config"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Fix ownership of named volume mount points ──────────────────────
|
||||
# Named volumes are created as root on first use. Fix ownership so the
|
||||
# developer user can write to them.
|
||||
FINAL_UID="${TARGET_UID:-$CURRENT_UID}"
|
||||
FINAL_GID="${TARGET_GID:-$CURRENT_GID}"
|
||||
|
||||
# First, fix parent dirs that Docker auto-creates as root:root when it
|
||||
# materializes nested mount points (e.g. mounting a volume at
|
||||
# .local/state/opencode creates .local/state as root). Non-recursive —
|
||||
# we only need the dir node itself; children are handled below or were
|
||||
# created by the user.
|
||||
for parent in \
|
||||
/home/"$USER_NAME"/.local \
|
||||
/home/"$USER_NAME"/.local/share \
|
||||
/home/"$USER_NAME"/.local/state \
|
||||
/home/"$USER_NAME"/.cache \
|
||||
/home/"$USER_NAME"/.config; do
|
||||
if [ -d "$parent" ] && [ "$(stat -c '%u' "$parent" 2>/dev/null)" != "$FINAL_UID" ]; then
|
||||
chown "$FINAL_UID":"$FINAL_GID" "$parent" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
for dir in \
|
||||
/home/"$USER_NAME"/.local/share/opencode \
|
||||
/home/"$USER_NAME"/.local/state/opencode \
|
||||
/home/"$USER_NAME"/.local/share/uv \
|
||||
/home/"$USER_NAME"/.local/share/zoxide \
|
||||
/home/"$USER_NAME"/.local/share/nvim \
|
||||
/home/"$USER_NAME"/.mempalace \
|
||||
/home/"$USER_NAME"/.cache/bash \
|
||||
/home/"$USER_NAME"/.cache/chroma \
|
||||
/home/"$USER_NAME"/.rustup \
|
||||
/home/"$USER_NAME"/.cargo \
|
||||
/home/"$USER_NAME"/.vscode-server \
|
||||
/home/"$USER_NAME"/.config/opencode \
|
||||
/home/"$USER_NAME"/.config/nvim \
|
||||
/home/"$USER_NAME"/.pi \
|
||||
/home/"$USER_NAME"/.ssh-local \
|
||||
/home/"$USER_NAME"/.agents/skills; do
|
||||
[ -d "$dir" ] || continue
|
||||
|
||||
# Sentinel-file fast path: on volumes with thousands of files (nvim
|
||||
# plugins, palace data) the recursive chown used to cost multiple
|
||||
# seconds on every container start even when ownership was already
|
||||
# correct. Now we write a sentinel after a successful chown and skip
|
||||
# the walk when the sentinel matches the target UID:GID.
|
||||
#
|
||||
# If USER_UID changes between runs (user switches hosts, different
|
||||
# workspace owner), the sentinel won't match and the full chown runs.
|
||||
sentinel="$dir/.devbox-owner"
|
||||
expected="$FINAL_UID:$FINAL_GID"
|
||||
if [ -f "$sentinel" ] && [ "$(cat "$sentinel" 2>/dev/null)" = "$expected" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Recursive chown needed. Only do it when the top-level differs too
|
||||
# (covers the common case of fresh root-owned named volumes).
|
||||
if [ "$(stat -c '%u' "$dir" 2>/dev/null)" != "$FINAL_UID" ]; then
|
||||
chown -R "$FINAL_UID":"$FINAL_GID" "$dir" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Write sentinel so subsequent starts skip the recursive walk.
|
||||
# Suppress errors — a read-only mount would fail here, but that would
|
||||
# already have failed above on the chown itself.
|
||||
echo "$expected" > "$sentinel" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# ── Drop to developer user for remaining setup ──────────────────────
|
||||
exec gosu "$USER_NAME" /usr/local/bin/entrypoint-user.sh "$@"
|
||||
@@ -0,0 +1,105 @@
|
||||
# opencode-devbox bash aliases and customizations
|
||||
# Sourced by the Debian-default ~/.bashrc on shell startup.
|
||||
# To override, bind-mount your host's ~/.bash_aliases over this file
|
||||
# via docker-compose.yml.
|
||||
|
||||
# ── Host-shared shell customizations (devbox-shell bridge) ───────────
|
||||
# If the host bind-mounts a directory at ~/.config/devbox-shell/ (the
|
||||
# recommended pattern for sharing aliases/PATH/utilities between host
|
||||
# and container), source the bash_aliases file from it. This survives
|
||||
# --force-recreate because it's baked into the image's skel, not the
|
||||
# container's writable layer. Hosts that don't use this pattern are
|
||||
# unaffected — the test silently skips if the file doesn't exist.
|
||||
[ -r "$HOME/.config/devbox-shell/bash_aliases" ] && . "$HOME/.config/devbox-shell/bash_aliases"
|
||||
|
||||
# ── History persistence and quality ──────────────────────────────────
|
||||
# The named volume devbox-shell-history is mounted at ~/.cache/bash
|
||||
# so history survives container recreation.
|
||||
export HISTFILE="${HOME}/.cache/bash/history"
|
||||
mkdir -p "$(dirname "$HISTFILE")" 2>/dev/null || true
|
||||
|
||||
# Large, time-stamped, deduplicated history. Append rather than overwrite.
|
||||
export HISTSIZE=100000
|
||||
export HISTFILESIZE=200000
|
||||
export HISTCONTROL=ignoreboth:erasedups
|
||||
export HISTTIMEFORMAT='%F %T '
|
||||
shopt -s histappend 2>/dev/null
|
||||
shopt -s cmdhist 2>/dev/null
|
||||
# Note: PROMPT_COMMAND="history -a" is installed LATER in this file,
|
||||
# after zoxide's init runs. Installing it here would create a
|
||||
# "history -a;;__zoxide_hook" chain because zoxide's init uses ';'
|
||||
# as its separator and prepends itself; two adjacent ';' breaks the
|
||||
# parser. See https://github.com/ajeetdsouza/zoxide/issues/722.
|
||||
|
||||
# ── Common aliases ───────────────────────────────────────────────────
|
||||
# Prefer eza (modern ls) when available
|
||||
if command -v eza >/dev/null 2>&1; then
|
||||
alias ls='eza --group-directories-first'
|
||||
alias ll='eza -lh --group-directories-first --git'
|
||||
alias la='eza -lha --group-directories-first --git'
|
||||
alias tree='eza --tree'
|
||||
else
|
||||
alias ll='ls -lh'
|
||||
alias la='ls -lha'
|
||||
fi
|
||||
|
||||
# Prefer bat (syntax-highlighted cat) when available
|
||||
if command -v bat >/dev/null 2>&1; then
|
||||
alias cat='bat --style=plain --paging=never'
|
||||
alias less='bat --paging=always'
|
||||
fi
|
||||
|
||||
# Git shortcuts
|
||||
alias gs='git status'
|
||||
alias gd='git diff'
|
||||
alias gl='git log --oneline --graph --decorate -20'
|
||||
|
||||
# ── LAN access via the host (dssh) ───────────────────────────────────
|
||||
# When running on a VM-backed host (macOS OrbStack / Docker Desktop), the
|
||||
# entrypoint's setup-lan-access.sh generates ~/.ssh-local/config so the host
|
||||
# can be used as an SSH jump to reach LAN peers. These aliases wrap `ssh -F`
|
||||
# / `scp -F` against that config. Guarded so they only appear when the config
|
||||
# was actually generated (no-op / absent on native Linux hosts).
|
||||
if [ -r "$HOME/.ssh-local/config" ]; then
|
||||
alias dssh='ssh -F "$HOME/.ssh-local/config"'
|
||||
alias dscp='scp -F "$HOME/.ssh-local/config"'
|
||||
fi
|
||||
|
||||
# Safety: confirm before destructive ops
|
||||
alias rm='rm -i'
|
||||
alias mv='mv -i'
|
||||
alias cp='cp -i'
|
||||
|
||||
# ── Shell integrations ───────────────────────────────────────────────
|
||||
# zoxide — smarter cd. Use 'z <fragment>' to jump to previously-visited dirs.
|
||||
if command -v zoxide >/dev/null 2>&1; then
|
||||
eval "$(zoxide init bash)"
|
||||
fi
|
||||
|
||||
# fzf — fuzzy finder key bindings (Ctrl-R for history, Ctrl-T for files).
|
||||
# We install fzf from GitHub releases (not apt), so sourcing from the
|
||||
# apt-path /usr/share/doc/fzf/examples/* would find nothing. Use the
|
||||
# binary's own --bash flag (available since fzf 0.48) for setup.
|
||||
if command -v fzf >/dev/null 2>&1; then
|
||||
eval "$(fzf --bash)" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── PROMPT_COMMAND: flush history every prompt ───────────────────────
|
||||
# Installed AFTER zoxide init so zoxide's hook is already in place;
|
||||
# we append with a newline separator to avoid the ';;' parse error
|
||||
# described at the top of this file. Guarded so repeated sourcing
|
||||
# (e.g. `exec bash`) doesn't stack duplicates.
|
||||
if [ -z "${DEVBOX_HIST_SET:-}" ]; then
|
||||
PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND$'\n'}history -a"
|
||||
export DEVBOX_HIST_SET=1
|
||||
fi
|
||||
|
||||
# ── Prompt: show [opencode-devbox] tag so it's obvious you're in the container
|
||||
# Preserves the default Debian PS1 logic but prefixes with a container marker.
|
||||
# We check for the literal '[devbox]' substring in PS1 rather than relying on
|
||||
# an exported guard variable — otherwise `exec bash` inherits the guard but
|
||||
# gets a fresh (prefix-less) PS1 from .bashrc, and the prefix would never be
|
||||
# re-added in the new shell.
|
||||
if [ -n "${PS1:-}" ] && [[ "$PS1" != *"[devbox]"* ]]; then
|
||||
PS1='\[\e[38;5;39m\][devbox]\[\e[0m\] '"${PS1}"
|
||||
fi
|
||||
@@ -0,0 +1,27 @@
|
||||
# opencode-devbox readline defaults
|
||||
# To override, bind-mount your host's ~/.inputrc over this file
|
||||
# via docker-compose.yml.
|
||||
|
||||
# Inherit system-wide defaults (colour, 8-bit input, …) if present
|
||||
$include /etc/inputrc
|
||||
|
||||
# ── History search on Up/Down ────────────────────────────────────────
|
||||
# Type a prefix, press Up, and walk through previous commands starting
|
||||
# with that prefix. Ctrl-Up / Ctrl-Down keep the unconditional stepper.
|
||||
"\e[A": history-search-backward
|
||||
"\e[B": history-search-forward
|
||||
"\e[1;5A": previous-history
|
||||
"\e[1;5B": next-history
|
||||
|
||||
# ── Completion quality ───────────────────────────────────────────────
|
||||
set show-all-if-ambiguous on # single Tab shows matches on ambiguity
|
||||
set completion-ignore-case on # case-insensitive file/dir completion
|
||||
set colored-stats on # colour ls-style completion list entries
|
||||
set colored-completion-prefix on # highlight the matched prefix
|
||||
set visible-stats on # append /*@ type indicators in completion
|
||||
set mark-symlinked-directories on # add trailing / to symlinks to dirs
|
||||
set skip-completed-text on # don't re-insert already-typed text
|
||||
|
||||
# Treat hyphens and underscores as equivalent when completing (e.g.
|
||||
# typing `foo-` matches both `foo-bar` and `foo_bar`).
|
||||
set completion-map-case on
|
||||
Executable
+75
@@ -0,0 +1,75 @@
|
||||
#!/usr/bin/env bash
|
||||
# studio-expose — make a container-loopback pi-studio server reachable
|
||||
# through a published Docker port.
|
||||
#
|
||||
# WHY THIS EXISTS
|
||||
# pi-studio hard-binds its HTTP/WebSocket server to 127.0.0.1 inside the
|
||||
# container (index.ts: `.listen(port, "127.0.0.1")`) and there is no
|
||||
# --host / bind flag. A plain `docker run -p 8765:8765` forwards to the
|
||||
# container's EXTERNAL interface (eth0), not its loopback, so it cannot
|
||||
# reach Studio. This helper runs a socat TCP relay that listens on the
|
||||
# container's egress IP and forwards to 127.0.0.1:<port>, so a published
|
||||
# port (and an `ssh -L` tunnel from your laptop) can reach Studio.
|
||||
#
|
||||
# SECURITY
|
||||
# This intentionally exposes Studio beyond loopback — anything that can
|
||||
# reach the container's network interface (and the host port you publish)
|
||||
# can connect. Studio's tokenized URL is the only auth. Mitigate by
|
||||
# publishing the host port on localhost only:
|
||||
# ports: ["127.0.0.1:${STUDIO_PORT}:${STUDIO_PORT}"]
|
||||
# and use `ssh -L` for remote access. Bridge nothing you don't intend to.
|
||||
#
|
||||
# USAGE
|
||||
# studio-expose [PORT] # bridge PORT (default: $STUDIO_PORT or 8765)
|
||||
# studio-expose --help
|
||||
#
|
||||
# Typically: inside a pi session run `/studio --no-browser --port 8765`,
|
||||
# then in a container shell run `studio-expose` (or set STUDIO_EXPOSE=1 in
|
||||
# compose to auto-start it on container boot — see entrypoint-user.sh).
|
||||
#
|
||||
# Runs in the foreground; Ctrl-C to stop. The entrypoint auto-start path
|
||||
# runs it backgrounded.
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${1:-${STUDIO_PORT:-8765}}"
|
||||
|
||||
if [ "$PORT" = "--help" ] || [ "$PORT" = "-h" ]; then
|
||||
sed -n '2,31p' "$0" | sed 's/^# \{0,1\}//'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$PORT" in
|
||||
''|*[!0-9]*) echo "studio-expose: invalid port '$PORT'" >&2; exit 2 ;;
|
||||
esac
|
||||
|
||||
if ! command -v socat >/dev/null 2>&1; then
|
||||
echo "studio-expose: socat not found in PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Container's primary egress IPv4. In Docker the container hostname resolves
|
||||
# to its eth0 address, so `hostname -I` lists it; we take the first
|
||||
# non-loopback IPv4. We must bind this specific address rather than 0.0.0.0
|
||||
# — binding 0.0.0.0 would collide with Studio's own 127.0.0.1:PORT listener
|
||||
# (0.0.0.0 includes loopback) and fail with EADDRINUSE. `ip route get` is a
|
||||
# fallback only when iproute2 happens to be present (not in the base image).
|
||||
BIND_IP="$(hostname -I 2>/dev/null | tr ' ' '\n' \
|
||||
| grep -E '^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$' | grep -vE '^127\.' | head -n1)"
|
||||
if [ -z "${BIND_IP:-}" ] && command -v ip >/dev/null 2>&1; then
|
||||
BIND_IP="$(ip -4 route get 1.1.1.1 2>/dev/null | awk '{for(i=1;i<=NF;i++) if($i=="src"){print $(i+1); exit}}')"
|
||||
fi
|
||||
[ -n "${BIND_IP:-}" ] || BIND_IP="$(hostname -i 2>/dev/null | awk '{print $1}')"
|
||||
if [ -z "${BIND_IP:-}" ]; then
|
||||
echo "studio-expose: could not determine container egress IP" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "studio-expose: bridging ${BIND_IP}:${PORT} -> 127.0.0.1:${PORT}"
|
||||
echo "studio-expose: open the tokenized URL pi-studio printed; if the host"
|
||||
echo "studio-expose: publishes ${PORT}, reach it at http://127.0.0.1:${PORT}/?token=..."
|
||||
echo "studio-expose: (remote host: ssh -L ${PORT}:127.0.0.1:${PORT} user@host)"
|
||||
|
||||
# fork: one child per connection (handles concurrent + long-lived WebSocket
|
||||
# connections). reuseaddr: survive quick restarts. Studio need not be up yet
|
||||
# — connections simply fail until `/studio --port ${PORT}` is running.
|
||||
exec socat "TCP-LISTEN:${PORT},bind=${BIND_IP},fork,reuseaddr" "TCP:127.0.0.1:${PORT}"
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup-lan-access.sh — generic, host-OS-agnostic LAN reachability helper.
|
||||
#
|
||||
# THE PROBLEM
|
||||
# On macOS (OrbStack / Docker Desktop) and Docker Desktop on Windows, the
|
||||
# container runs inside a Linux VM behind the host's network stack. The
|
||||
# host's *directly-attached* LAN peers (e.g. other boxes on 192.168.1.0/24)
|
||||
# are NOT bridged into the container by default — only the host itself and
|
||||
# *routed* subnets are reachable. On native Linux Docker the default bridge
|
||||
# already NATs container egress onto the host's LAN, so LAN peers are usually
|
||||
# reachable directly and no workaround is needed.
|
||||
#
|
||||
# THE APPROACH ("detect, and on a VM-backed host use the host as a jump")
|
||||
# The one thing reachable from a container on every OS is the host itself
|
||||
# (host.docker.internal). So on VM-backed hosts we generate a writable SSH
|
||||
# config that reaches the host and lets the user ProxyJump onward to LAN
|
||||
# peers the host can reach. On native Linux we do nothing.
|
||||
#
|
||||
# We ship the MECHANISM (a generic `host` jump alias + writable config),
|
||||
# never the POLICY: the user's specific target hosts live in their own
|
||||
# bind-mounted ~/.ssh/config (add `ProxyJump host` to those entries) — which
|
||||
# is pulled in via the `Include ~/.ssh/config` line below.
|
||||
#
|
||||
# WHY A WRITABLE SIDECAR (~/.ssh-local)
|
||||
# The devbox typically bind-mounts the host's ~/.ssh READ-ONLY (so agents
|
||||
# can read keys for git but can't tamper with config/known_hosts/authorized_
|
||||
# keys). That means we cannot edit ~/.ssh/config or write ~/.ssh/known_hosts.
|
||||
# So everything generated here lives under the writable ~/.ssh-local, used
|
||||
# via `ssh -F ~/.ssh-local/config` (the `dssh`/`dscp` aliases wrap that).
|
||||
#
|
||||
# CONTROLS (env)
|
||||
# DEVBOX_LAN_ACCESS = auto (default) | jump | off
|
||||
# auto → set up the jump config only on VM-backed hosts; no-op on Linux.
|
||||
# jump → always set up (e.g. native Linux with extra_hosts host-gateway).
|
||||
# off → do nothing.
|
||||
# HOST_SSH_USER — the username to SSH into the host as. REQUIRED for the
|
||||
# jump to authenticate. If unset we still generate the config but print
|
||||
# a hint with the public key to authorize on the host.
|
||||
# DEVBOX_HOST_ALIAS — host hostname to reach (default host.docker.internal).
|
||||
# DEVBOX_LAN_AUTOJUMP_PRIVATE = 0 (default) | 1
|
||||
# 1 → also emit a catch-all that ProxyJumps *any* RFC1918 (private) IP
|
||||
# through the host. Lets bare `dssh user@<private-IP>` work on whatever
|
||||
# LAN the (roaming) host is currently joined to, without naming peers.
|
||||
# Matches by the address you TYPE, not the resolved HostName, so it never
|
||||
# overrides named hosts that already carry their own ProxyJump.
|
||||
#
|
||||
# HOST-OWNED PEER POLICY (portable; keeps this image generic)
|
||||
# Named LAN peers are facts about a *specific* host's network, not about the
|
||||
# image — a roaming laptop sees different LANs. So we never bake peer names
|
||||
# here. Instead, if the host bind-mounts ~/.config/devbox-shell/ssh-lan.conf
|
||||
# (the same devbox-shell bridge dir used for shared aliases), we Include it
|
||||
# *before* ~/.ssh/config. That file holds the host's own jump overrides, e.g.
|
||||
# Host pve pve-2 pbs-vm
|
||||
# ProxyJump host
|
||||
# First-value-wins means ProxyJump is taken from there while HostName/User/
|
||||
# IdentityFile are inherited from the matching block in ~/.ssh/config.
|
||||
#
|
||||
# SCOPING NOTE (important)
|
||||
# `Include` is scoped to the enclosing Host/Match block. So every Include
|
||||
# below is preceded by a bare `Host *` to reset the active context to
|
||||
# match-all — otherwise the included config would only apply when targeting
|
||||
# `host`/`mac` and named peers like `pve` would silently fall back to ssh
|
||||
# defaults.
|
||||
#
|
||||
# Idempotent: re-renders the config every run (cheap); never regenerates the
|
||||
# key. Always non-fatal — never blocks container startup.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
MODE="${DEVBOX_LAN_ACCESS:-auto}"
|
||||
[ "$MODE" = "off" ] && exit 0
|
||||
|
||||
HOST_ALIAS_HOSTNAME="${DEVBOX_HOST_ALIAS:-host.docker.internal}"
|
||||
SSH_LOCAL="${HOME}/.ssh-local"
|
||||
CONFIG="${SSH_LOCAL}/config"
|
||||
KEY="${SSH_LOCAL}/devbox_jump_ed25519"
|
||||
|
||||
# ── Detection: is this a VM-backed host (macOS / Docker Desktop)? ──────
|
||||
# host.docker.internal resolves on OrbStack and Docker Desktop (mac/win) but
|
||||
# NOT on native Linux Docker (unless the user added extra_hosts: host-gateway,
|
||||
# in which case the jump is still harmless / usable, and they can force it
|
||||
# with DEVBOX_LAN_ACCESS=jump).
|
||||
is_vm_backed() {
|
||||
getent hosts "$HOST_ALIAS_HOSTNAME" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
if [ "$MODE" = "auto" ] && ! is_vm_backed; then
|
||||
# Native Linux host: LAN peers are reachable directly. Nothing to do.
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# From here: MODE=jump, or MODE=auto on a VM-backed host.
|
||||
|
||||
command -v ssh-keygen >/dev/null 2>&1 || exit 0
|
||||
|
||||
mkdir -p "${SSH_LOCAL}/cm" 2>/dev/null || true
|
||||
chmod 700 "${SSH_LOCAL}" "${SSH_LOCAL}/cm" 2>/dev/null || true
|
||||
|
||||
# ── Jump key (generated once; preserved across restarts) ──────────────
|
||||
# Persisted via a named volume on ~/.ssh-local (see compose), so a fresh key
|
||||
# is generated only on the very first start (or if the volume is wiped). When
|
||||
# we DO generate one it must be (re-)authorized on the host, so we flag it and
|
||||
# print a copy-paste authorize line below.
|
||||
KEY_JUST_GENERATED=0
|
||||
if [ ! -f "$KEY" ]; then
|
||||
ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1 || exit 0
|
||||
chmod 600 "$KEY" 2>/dev/null || true
|
||||
KEY_JUST_GENERATED=1
|
||||
fi
|
||||
|
||||
# ── Render the writable config ────────────────────────────────────────
|
||||
USER_LINE=""
|
||||
if [ -n "${HOST_SSH_USER:-}" ]; then
|
||||
USER_LINE=" User ${HOST_SSH_USER}"
|
||||
fi
|
||||
|
||||
# Optional host-owned named-peer jump overrides (portable: lives on the host,
|
||||
# not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins.
|
||||
SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf"
|
||||
LAN_CONF_BLOCK=""
|
||||
if [ -r "$SSH_LAN_CONF" ]; then
|
||||
LAN_CONF_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# Host-owned named-peer jump overrides (bind-mounted; edit on the host).
|
||||
# Scope reset to match-all so the Include applies to every target host.
|
||||
Host *
|
||||
Include ~/.config/devbox-shell/ssh-lan.conf
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
|
||||
# Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the
|
||||
# host. Matches the typed address, never the resolved HostName, so named hosts
|
||||
# with their own ProxyJump are unaffected. Network-agnostic → roaming-safe.
|
||||
AUTOJUMP_BLOCK=""
|
||||
if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then
|
||||
AUTOJUMP_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# RFC1918 auto-jump (DEVBOX_LAN_AUTOJUMP_PRIVATE=1): reach any private IP on
|
||||
# the host's CURRENT LAN via bare `dssh user@<ip>`. Public IPs are unmatched
|
||||
# and go direct via the container's NAT egress. NOTE: also matches the
|
||||
# container's own bridge subnet and any private IP the host can't actually
|
||||
# reach — for non-LAN private hosts behind a different jump, use their named
|
||||
# entry (which matches first by name and keeps its own ProxyJump).
|
||||
Host 10.* 192.168.* 172.16.* 172.17.* 172.18.* 172.19.* 172.20.* 172.21.* 172.22.* 172.23.* 172.24.* 172.25.* 172.26.* 172.27.* 172.28.* 172.29.* 172.30.* 172.31.*
|
||||
ProxyJump host
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
|
||||
INCLUDE_BLOCK=""
|
||||
if [ -r "${HOME}/.ssh/config" ]; then
|
||||
INCLUDE_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# 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).
|
||||
# Add 'ProxyJump host' to LAN entries here (or in ssh-lan.conf above).
|
||||
Host *
|
||||
Include ~/.ssh/config
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
|
||||
cat > "$CONFIG" <<EOF
|
||||
# AUTO-GENERATED by setup-lan-access.sh on every container start. Do not edit
|
||||
# by hand — edits are overwritten. Used via: ssh -F ~/.ssh-local/config <host>
|
||||
# (or the dssh / dscp aliases). See the script header for the full rationale.
|
||||
|
||||
# ~/.ssh is typically mounted read-only, so keep our own known_hosts here.
|
||||
# Also redirect ControlPath into the writable sidecar: the bind-mounted
|
||||
# ~/.ssh/config commonly sets 'ControlPath ~/.ssh/cm/...' for CGNAT multiplexing,
|
||||
# but ~/.ssh is read-only here so the master socket can't be created and those
|
||||
# hosts fail to connect. First-value-wins: setting it here (before the Include)
|
||||
# overrides the read-only path for every host. Harmless when ControlMaster is off.
|
||||
Host *
|
||||
UserKnownHostsFile ~/.ssh-local/known_hosts
|
||||
StrictHostKeyChecking accept-new
|
||||
ControlPath ~/.ssh-local/cm/%r@%h:%p
|
||||
|
||||
# The container host (OrbStack / Docker Desktop). 'host' and 'mac' are aliases.
|
||||
Host host mac
|
||||
HostName ${HOST_ALIAS_HOSTNAME}
|
||||
${USER_LINE}
|
||||
IdentityFile ~/.ssh-local/devbox_jump_ed25519
|
||||
IdentitiesOnly yes
|
||||
ControlMaster auto
|
||||
ControlPath ~/.ssh-local/cm/%r@%h:%p
|
||||
ControlPersist 4h
|
||||
ServerAliveInterval 30
|
||||
${LAN_CONF_BLOCK}
|
||||
${AUTOJUMP_BLOCK}
|
||||
${INCLUDE_BLOCK}
|
||||
EOF
|
||||
chmod 600 "$CONFIG" 2>/dev/null || true
|
||||
|
||||
# ── Authorize hints ───────────────────────────────────────────────────
|
||||
# Print the copy-paste authorize line whenever we either (a) can't yet
|
||||
# authenticate (HOST_SSH_USER unset) or (b) just generated a NEW key that the
|
||||
# host won't recognize. With ~/.ssh-local persisted via a named volume, case
|
||||
# (b) fires only on first-ever start (or after the volume is reset) — so this
|
||||
# is normally a one-time, one-line step per machine, with no file to locate.
|
||||
PUBKEY_TEXT="$(cat "${KEY}.pub" 2>/dev/null)"
|
||||
if [ -z "${HOST_SSH_USER:-}" ]; then
|
||||
cat <<EOF
|
||||
[devbox] LAN-access jump config generated at ~/.ssh-local/config, but
|
||||
HOST_SSH_USER is unset so it can't authenticate to the host yet.
|
||||
To enable container -> host -> LAN-peer access:
|
||||
1. Set HOST_SSH_USER=<your host username> in the container env.
|
||||
2. Authorize this key on the host (run ON THE HOST, once):
|
||||
echo '${PUBKEY_TEXT}' >> ~/.ssh/authorized_keys
|
||||
3. Ensure the host's SSH server (Remote Login) is enabled.
|
||||
Then: dssh host (or add 'ProxyJump host' to targets in ~/.ssh/config)
|
||||
EOF
|
||||
elif [ "$KEY_JUST_GENERATED" = "1" ]; then
|
||||
cat <<EOF
|
||||
[devbox] Generated a NEW LAN-jump key. Authorize it on the host (${HOST_SSH_USER}@host),
|
||||
then 'dssh host' and your LAN peers will work. Run this ONCE, ON THE HOST:
|
||||
echo '${PUBKEY_TEXT}' >> ~/.ssh/authorized_keys
|
||||
(Ensure the host's SSH server / Remote Login is enabled.)
|
||||
This key is persisted in the ~/.ssh-local volume, so you won't need to
|
||||
repeat this on container updates — only if that volume is reset.
|
||||
EOF
|
||||
fi
|
||||
|
||||
exit 0
|
||||
+135
-10
@@ -1,23 +1,34 @@
|
||||
#!/usr/bin/env bash
|
||||
# smoke-test.sh — basic sanity checks for the pi-devbox image
|
||||
# smoke-test.sh — sanity checks for the pi-devbox image
|
||||
#
|
||||
# Usage: ./scripts/smoke-test.sh <image>
|
||||
#
|
||||
# Verifies:
|
||||
# - pi binary present and returns a version
|
||||
# - pi binary present and (if EXPECTED_PI_VERSION set) matches CI's resolved version
|
||||
# - new v1.0.0 base additions (pandoc, graphviz, imagemagick, yq, tealdeer)
|
||||
# - tmux 0-indexing baked in /etc/tmux.conf (required for pi-studio variants)
|
||||
# - pi-toolkit cloned at /opt/pi-toolkit
|
||||
# - pi-extensions cloned at /opt/pi-extensions
|
||||
# - pi-fork + pi-observational-memory cloned with node_modules baked
|
||||
# - entrypoint deploys pi-toolkit keybindings symlink
|
||||
# - entrypoint deploys ≥4 extensions
|
||||
# - mempalace bridge symlink present
|
||||
# - settings.json bootstrapped
|
||||
# - pi-fork + pi-observational-memory registered via `pi install`
|
||||
# - (studio variant only, auto-detected) pi-studio cloned + prebuilt
|
||||
# client bundle present + registered via `pi install`
|
||||
# - image size within threshold
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="${1:?usage: $0 <image>}"
|
||||
PASS=0; FAIL=0
|
||||
SIZE_THRESHOLD_MB=2200
|
||||
# pi-devbox v1.0.0 (decoupled from opencode-devbox) added pandoc, graphviz,
|
||||
# imagemagick, yq, tealdeer, and a baked /etc/tmux.conf. Local arm64 build
|
||||
# observed 3.20 GB. CI amd64 builds may differ slightly; threshold below
|
||||
# carries +300 MB margin to absorb arch differences without false reds.
|
||||
# Tighten in a follow-up release once amd64 actuals are observed in CI logs.
|
||||
SIZE_THRESHOLD_MB=3500
|
||||
|
||||
run() {
|
||||
local label="$1"; local cmd="$2"
|
||||
@@ -28,24 +39,79 @@ run() {
|
||||
fi
|
||||
}
|
||||
|
||||
# Stricter version of `run` that asserts an expected substring in stdout.
|
||||
# Catches the "image bytes silently identical to previous release" class of
|
||||
# regression — Docker layer cache hit on `npm install -g <pkg>` because the
|
||||
# bare command string is identical across builds, even when `latest` would
|
||||
# resolve differently. Discovered 2026-05-23 — every pi-devbox release
|
||||
# v0.74.0..v0.75.5 had been shipping the same image bytes.
|
||||
run_expect() {
|
||||
local label="$1"; local cmd="$2"; local expect="$3"
|
||||
local out
|
||||
out=$(docker run --rm --entrypoint="" "$IMAGE" sh -c "$cmd" 2>&1) || true
|
||||
if echo "$out" | grep -Fq "$expect"; then
|
||||
printf " ✅ %s (got %s)\n" "$label" "$expect"; PASS=$((PASS+1))
|
||||
else
|
||||
printf " ❌ %s — expected substring %q, got: %s\n" "$label" "$expect" "$out"; FAIL=$((FAIL+1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "=== pi-devbox smoke test: $IMAGE ==="
|
||||
echo ""
|
||||
|
||||
# ── Basic binary checks ───────────────────────────────────────────────
|
||||
# ── Binaries ─────────────────────────────────────────────────────────
|
||||
echo "── Binaries ──"
|
||||
run "pi" "pi --version"
|
||||
if [ -n "${EXPECTED_PI_VERSION:-}" ]; then
|
||||
run_expect "pi version matches build arg" "pi --version" "$EXPECTED_PI_VERSION"
|
||||
else
|
||||
run "pi" "pi --version"
|
||||
fi
|
||||
run "node" "node --version"
|
||||
run "git" "git --version"
|
||||
run "aws" "aws --version"
|
||||
run "uv" "uv --version"
|
||||
run "nvim" "nvim --version"
|
||||
run "mempalace-mcp" "mempalace-mcp --help"
|
||||
# v1.0.0 base additions — verify presence and basic functionality.
|
||||
run "pandoc" "pandoc --version"
|
||||
run "graphviz (dot)" "dot -V"
|
||||
run "imagemagick" "magick --version"
|
||||
run "yq" "yq --version"
|
||||
run "tldr (tealdeer)" "tldr --version"
|
||||
run "socat" "socat -V"
|
||||
run "studio-expose helper" "test -x /usr/local/bin/studio-expose"
|
||||
|
||||
# ── tmux 0-indexing (required for pi-studio variants) ─────────────────
|
||||
echo ""
|
||||
echo "── tmux config ──"
|
||||
run_expect "/etc/tmux.conf has base-index 0" \
|
||||
"cat /etc/tmux.conf" "set -g base-index 0"
|
||||
run_expect "/etc/tmux.conf has pane-base-index 0" \
|
||||
"cat /etc/tmux.conf" "set -g pane-base-index 0"
|
||||
|
||||
# ── Repo clones ───────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "── Repo clones ──"
|
||||
run "pi-toolkit clone" "test -d /opt/pi-toolkit && git -C /opt/pi-toolkit rev-parse --short HEAD"
|
||||
run "pi-extensions clone" "test -d /opt/pi-extensions && git -C /opt/pi-extensions rev-parse --short HEAD"
|
||||
run "pi-fork clone + node_modules" \
|
||||
"test -f /opt/pi-fork/package.json && test -d /opt/pi-fork/node_modules"
|
||||
run "pi-observational-memory clone + node_modules" \
|
||||
"test -f /opt/pi-observational-memory/package.json && test -d /opt/pi-observational-memory/node_modules"
|
||||
|
||||
# pi-studio is present only in the :latest-studio variant. Auto-detect by
|
||||
# probing /opt/pi-studio so this one script covers both variants.
|
||||
if docker run --rm --entrypoint="" "$IMAGE" sh -c 'test -d /opt/pi-studio' >/dev/null 2>&1; then
|
||||
STUDIO_VARIANT=1
|
||||
echo " ℹ️ pi-studio detected — running studio assertions"
|
||||
run "pi-studio clone + node_modules" \
|
||||
"test -f /opt/pi-studio/package.json && test -d /opt/pi-studio/node_modules"
|
||||
run "pi-studio prebuilt client bundle" \
|
||||
"test -f /opt/pi-studio/client/studio-client.js"
|
||||
else
|
||||
STUDIO_VARIANT=0
|
||||
echo " ℹ️ pi-studio not present (non-studio variant) — skipping studio clone checks"
|
||||
fi
|
||||
|
||||
# ── Runtime deployment (needs entrypoint to run) ──────────────────────
|
||||
echo ""
|
||||
@@ -58,9 +124,19 @@ CID=$(docker run -d --rm "$IMAGE" tail -f /dev/null)
|
||||
cleanup() { docker rm -f "$CID" >/dev/null 2>&1 || true; }
|
||||
trap cleanup EXIT
|
||||
|
||||
# Wait for entrypoint-user.sh to finish deploying pi-toolkit + extensions
|
||||
for i in $(seq 1 30); do
|
||||
if docker exec "$CID" test -L /home/developer/.pi/agent/keybindings.json 2>/dev/null; then
|
||||
# Wait for entrypoint-user.sh to finish deploying pi-toolkit + extensions.
|
||||
# Gate on BOTH the keybindings symlink (deployed by pi-toolkit) AND the
|
||||
# mempalace.ts bridge (deployed last by entrypoint-user.sh) AND ≥4 *.ts
|
||||
# extensions present. Parallel build load can otherwise sample the *.ts
|
||||
# count mid-deploy and produce a flake. See opencode-devbox c6f9d11
|
||||
# (2026-06-08) — same fix transplanted.
|
||||
for i in $(seq 1 45); do
|
||||
if docker exec "$CID" sh -c '
|
||||
test -L /home/developer/.pi/agent/keybindings.json && \
|
||||
test -L /home/developer/.pi/agent/extensions/mempalace.ts && \
|
||||
count=$(ls -1 /home/developer/.pi/agent/extensions/*.ts 2>/dev/null | wc -l) && \
|
||||
[ "$count" -ge 4 ]
|
||||
' >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
@@ -80,11 +156,60 @@ exec_test "extensions ≥ 4 (pi-extensions)" 'count=$(ls -1 $HOME/.pi/age
|
||||
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'
|
||||
|
||||
# pi-fork + pi-observational-memory are registered by entrypoint-user.sh via
|
||||
# `pi install /opt/<pkg>`, which runs slightly after the keybindings marker.
|
||||
for i in $(seq 1 15); do
|
||||
if docker exec "$CID" grep -q pi-observational-memory \
|
||||
/home/developer/.pi/agent/settings.json 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
exec_test "pi-fork registered (fork tool)" 'grep -q pi-fork $HOME/.pi/agent/settings.json && echo ok'
|
||||
exec_test "pi-observational-memory registered (recall tool)" 'grep -q pi-observational-memory $HOME/.pi/agent/settings.json && echo ok'
|
||||
|
||||
# pi-studio registration (studio variant only) — registered by the same
|
||||
# entrypoint-user.sh local-path install loop as fork/obsmem.
|
||||
if [ "${STUDIO_VARIANT:-0}" = "1" ]; then
|
||||
for i in $(seq 1 15); do
|
||||
if docker exec "$CID" grep -q pi-studio \
|
||||
/home/developer/.pi/agent/settings.json 2>/dev/null; then
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
exec_test "pi-studio registered (/studio command + studio_* tools)" \
|
||||
'grep -q pi-studio $HOME/.pi/agent/settings.json && echo ok'
|
||||
fi
|
||||
|
||||
# ── /tmp/sshcm directory created by entrypoint ────────────────────────
|
||||
exec_test "/tmp/sshcm dir mode 700 (ssh ControlMaster)" \
|
||||
'test -d /tmp/sshcm && [ "$(stat -c %a /tmp/sshcm)" = "700" ] && echo ok'
|
||||
|
||||
# ── Image size ────────────────────────────────────────────────────────
|
||||
echo ""
|
||||
echo "── Image size ──"
|
||||
SIZE_MB=$(docker image inspect "$IMAGE" --format='{{.Size}}' | awk '{printf "%d", $1/1048576}')
|
||||
if [ "$SIZE_MB" -le "$SIZE_THRESHOLD_MB" ]; then
|
||||
# Sum all layers via `docker history`. Docker's `image inspect --format='{{.Size}}'`
|
||||
# returns ONLY the variant-unique layer when the base is content-addressed and
|
||||
# shared (the case in this repo's two-phase build), which understates the
|
||||
# user-facing image size by 2+ GB. Summing layer sizes from history is the
|
||||
# metric Hub displays to users and the one we actually want to gate on.
|
||||
SIZE_MB=$(docker history --format '{{.Size}}' "$IMAGE" | python3 -c '
|
||||
import sys, re
|
||||
total=0.0
|
||||
for line in sys.stdin:
|
||||
s=line.strip()
|
||||
if s in ("0B", ""): continue
|
||||
m=re.match(r"^([0-9.]+)(B|kB|MB|GB)$", s)
|
||||
if not m: continue
|
||||
v=float(m.group(1)); u=m.group(2)
|
||||
mult={"B":1/1048576,"kB":1/1024,"MB":1,"GB":1024}[u]
|
||||
total+=v*mult
|
||||
print(int(total))
|
||||
')
|
||||
if [ -z "$SIZE_MB" ] || [ "$SIZE_MB" = "0" ]; then
|
||||
printf " ⚠️ image size: could not parse — skipping check\n"
|
||||
elif [ "$SIZE_MB" -le "$SIZE_THRESHOLD_MB" ]; then
|
||||
printf " ✅ size: %d MB (threshold %d MB)\n" "$SIZE_MB" "$SIZE_THRESHOLD_MB"; PASS=$((PASS+1))
|
||||
else
|
||||
printf " ❌ size: %d MB exceeds threshold %d MB\n" "$SIZE_MB" "$SIZE_THRESHOLD_MB"; FAIL=$((FAIL+1))
|
||||
|
||||
Reference in New Issue
Block a user