diff --git a/.gitea/workflows/docker-publish.yml b/.gitea/workflows/docker-publish.yml index ac8ee1d..aab8f7e 100644 --- a/.gitea/workflows/docker-publish.yml +++ b/.gitea/workflows/docker-publish.yml @@ -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-. +# 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- → 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,190 @@ 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 }} + steps: + - name: Resolve pi version + fork/obsmem 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" + echo "Resolved PI_VERSION=${PI_VERSION}" + echo "Resolved PI_FORK_REF=${FORK_REF}, PI_OBSMEM_REF=${OBSMEM_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,42 +232,34 @@ 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} - - # Derive PI_VERSION from the tag (e.g. v0.78.0 -> 0.78.0; v0.78.0b -> 0.78.0). - # Since the refactor to FROM opencode-devbox:latest-with-pi, this repo no - # longer installs pi itself — pi comes from the base image. We still resolve - # the tag version and feed it to the smoke test as EXPECTED_PI_VERSION: the - # smoke asserts the inherited base actually carries this pi version, which - # turns the version coupling into an enforced publish-ordering guard (it - # fails loudly if latest-with-pi is stale relative to this tag). - - name: Resolve PI_VERSION from tag - id: resolve - run: | - TAG="${{ github.ref_name }}" - PI_VERSION="${TAG#v}" - PI_VERSION=$(echo "$PI_VERSION" | sed 's/[a-z]*$//') - echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT" - echo "Resolved PI_VERSION=${PI_VERSION} from tag ${TAG}" - - - 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 }} + - name: Smoke test (amd64) env: - EXPECTED_PI_VERSION: ${{ steps.resolve.outputs.pi_version }} + EXPECTED_PI_VERSION: ${{ needs.resolve-versions.outputs.pi_version }} run: bash scripts/smoke-test.sh pi-devbox:smoke - publish: - needs: smoke + # ── Phase 4: multi-arch publish ───────────────────────────────────── + build-variant: + needs: [base-decide, smoke, resolve-versions] runs-on: ubuntu-latest container: image: catthehacker/ubuntu:act-latest @@ -79,7 +274,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 @@ -88,50 +282,40 @@ 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<> "$GITHUB_OUTPUT" - - # See the smoke job for why the tag version is resolved (now used only for - # the base-freshness smoke guard; pi is no longer installed in this repo). - - name: Resolve PI_VERSION from tag - id: resolve - run: | - TAG="${{ github.ref_name }}" - PI_VERSION="${TAG#v}" - PI_VERSION=$(echo "$PI_VERSION" | sed 's/[a-z]*$//') - echo "pi_version=${PI_VERSION}" >> "$GITHUB_OUTPUT" - echo "Resolved PI_VERSION=${PI_VERSION} from tag ${TAG}" - - - name: Build and push (amd64 + arm64) — with retry + - 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 }} run: | set -euo pipefail - # Convert newline-delimited TAGS env var (build-push-action's native - # format from the `Compute tags` step) into a bash array of -t flags. TAG_FLAGS=() while IFS= read -r t; do [[ -n "$t" ]] && TAG_FLAGS+=( -t "$t" ); done <<< "${TAGS}" - # 3-attempt retry around `docker buildx build --push` for transient - # registry-1.docker.io blips (rate limits, CDN flap, brief 5xx). - # The build itself is now trivial (FROM opencode-devbox:latest-with-pi - # + an empty layer) so it is fast even without registry cache. - # Registry cache stays disabled (buildkit mode=max cache-export hits a - # reproducible HTTP 400 from Hub CDN since ~2026-05-23; image push is - # unaffected). See opencode-devbox CHANGELOG v1.15.12. + # 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}" \ "${TAG_FLAGS[@]}" \ .; then echo "==> Attempt ${attempt} succeeded" @@ -146,8 +330,55 @@ jobs: echo "==> All 3 build+push attempts failed" exit 1 + # ── Phase 5: promote base- → 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-, 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- 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] + 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 @@ -155,12 +386,27 @@ jobs: - uses: actions/checkout@v4 - name: Update Docker Hub description run: | - PAYLOAD=$(jq -n --rawfile desc DOCKER_HUB.md '{"full_description": $desc}') - TOKEN=$(curl -s -X POST "https://hub.docker.com/v2/auth/token" \ + 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 DOCKER_HUB.md \ + --arg short "Self-contained Linux container for the pi coding-agent — pi + companions + MemPalace + curated dev tooling. Decoupled from opencode-devbox at v1.0.0." \ + '{"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." diff --git a/AGENTS.md b/AGENTS.md index 70db297..2587464 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,65 +1,130 @@ # AGENTS.md — pi-devbox -Container image that re-brands the opencode-devbox **pi-only** variant as a -pi-focused image. As of 2026-06-03 it no longer installs pi itself. +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` — thin re-brand: `FROM joakimp/pi-devbox:base-pi-only` (overridable via `BASE_IMAGE` arg). No install logic of its own — pi + companions are inherited from the pi-only build (built `INSTALL_OPENCODE=false`, so **no opencode** — that's the distinction from `opencode-devbox:latest-with-pi`). The `base-pi-only` tag is produced by opencode-devbox CI (from `opencode-devbox/Dockerfile.variant`) but published into THIS repo as an internal building-block tag. This refactor removed the install-logic duplication that used to drift against `opencode-devbox/Dockerfile.variant`. -- `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-`, adds pi + companions + (`pi-toolkit`, `pi-extensions`, `pi-fork`, `pi-observational-memory`). +- `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-install, skillset + deploy. +- `rootfs/` — files baked into the image (bash aliases, inputrc, + setup-lan-access.sh). +- `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). ## Versioning scheme -- Tags follow the pi npm version: `v{pi_version}[letter]` -- The image inherits pi from `base-pi-only`, so the **publish ordering matters**: rebuild opencode-devbox first so `joakimp/pi-devbox:base-pi-only` carries the target pi version, *then* tag this repo. The smoke test asserts `pi --version` matches the tag (`EXPECTED_PI_VERSION`) and fails loudly if the base is stale. -- 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`. + Internal tags: `joakimp/pi-devbox:base-` (content-addressed) + + `joakimp/pi-devbox:base-latest` (alias of most recent base). ## Release-day checklist -1. Ensure opencode-devbox has been released so `joakimp/pi-devbox:base-pi-only` carries the target pi version (and the fork/recall extensions). This is the hard prerequisite — the smoke guard enforces it. -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, 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`. -When drafting CHANGELOG entries, pull pi's release notes from the -`CHANGELOG.md` shipped inside the npm tarball: +## Cache-hit footgun (must-know) -```bash -cd /tmp && npm pack @earendil-works/pi-coding-agent@ -tar -xzf earendil-works-pi-coding-agent-.tgz package/CHANGELOG.md -head -40 package/CHANGELOG.md -``` +`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. -Pi's CHANGELOG has rich New Features / Added / Changed / Fixed sections -per version. Don't try to derive notes from the npm registry metadata -(`npm view`) — it doesn't include the changelog body. +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`. -## Key facts +## Smoke-test gate -- **Base image**: `joakimp/pi-devbox:base-pi-only` — an internal building-block tag (produced by opencode-devbox CI from `Dockerfile.variant`, the single source of truth for the pi install + companions; published into this repo, not under opencode-devbox). Rebuilt whenever opencode-devbox releases. Not for end users — they pull `joakimp/pi-devbox:latest` or a `vX.Y.Z` tag. -- **Inherited content**: pi (`/usr/bin/pi`), pi-toolkit, pi-extensions, pi-fork (`fork`), pi-observational-memory (`recall`), the mempalace bridge, the LAN-access helper, entrypoints, and all base dev tooling. The pi-only variant is built `INSTALL_OPENCODE=false`, so the image does **not** contain opencode. -- **Companion repos**: cloned to `/opt/` by the pi-only build; `entrypoint-user.sh` (inherited) deploys/registers them on container start. -- **MemPalace**: fully operational — inherited from base; bridge extension deployed by entrypoint. +`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). -## Conventions +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. -- This repo no longer installs pi or clones companion repos — do **not** re-add that logic here. Change it in `opencode-devbox/Dockerfile.variant` (the single source of truth) instead. -- The smoke test threshold is 2750 MB (tracks the pi-only variant) — update if the image legitimately grows past it. -- The CI still resolves the tag's pi version, but only to feed `EXPECTED_PI_VERSION` to the smoke base-freshness guard — it is no longer passed as a build-arg (nothing in the Dockerfile consumes it). -- To pin a specific base build instead of tracking `base-pi-only`, override the `BASE_IMAGE` arg (a `base-pi-only-vX.Y.Z` tag or a digest). +## Build pipeline notes -## Documentation drift sweep +- **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-` tag). +- **`base-latest` alias** is promoted from `base-` 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. -Before committing any non-trivial change, check that prose still matches code. Drift hotspots in this repo: +## Decoupling history (briefly) -- `README.md` — quick-start examples, env-var table, base-image reference (must match `FROM` in `Dockerfile`), "what's inside" (fork/recall; no opencode). -- `AGENTS.md` (this file) — `Key facts` block (base-image tag, inherited content), smoke-test threshold number. -- `CHANGELOG.md` — promote `Unreleased` only on tag, but record post-release fixes in a fresh `Unreleased` block. -- `DOCKER_HUB.md` — hand-maintained slim Hub description; sync anything user-facing that changes (env vars, run command, base image). -- `.env.example` — hand-updated, must match Dockerfile/entrypoint env vars (including the inherited LAN-access knobs). -- `Dockerfile` `BASE_IMAGE` ARG default — the pi-only tag this image tracks. +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. -Quick triage: `git diff --name-only HEAD | xargs -I{} grep -l 'thing-you-changed' README.md AGENTS.md DOCKER_HUB.md CHANGELOG.md .env.example`. +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 + can install on demand: `sudo apt-get install texlive-xetex + texlive-latex-recommended`. The planned `:latest-studio-tex` variant + will bake this in. +- **No pi-studio** (yet). Coming in v1.1.0 as the `:latest-studio` + variant. v1.0.0 is intentionally scope-limited to "decouple, don't + reshape." +- **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-bash-history`, + `devbox-nvim-data`, `devbox-uv-tools`, `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. diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a89ae2..e26992d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,13 +2,96 @@ 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 -_(no changes since v0.79.0)_ +_(no changes since v1.0.0)_ + +## 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-` (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 diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index d2143a3..0000000 --- a/Dockerfile +++ /dev/null @@ -1,35 +0,0 @@ -# pi-devbox — pi coding-agent container -# -# As of 2026-06-03 this image is a thin re-brand of the "pi-only" build, which -# is the SINGLE SOURCE OF TRUTH for the pi install and its companion repos -# (pi-toolkit, pi-extensions, pi-fork, pi-observational-memory). That build is -# produced by opencode-devbox's CI (from opencode-devbox/Dockerfile.variant -# with INSTALL_OPENCODE=false), but is published as an INTERNAL building-block -# tag in THIS repo — joakimp/pi-devbox:base-pi-only — NOT under opencode-devbox. -# Rationale: an "opencode-devbox" tag containing no opencode confuses -# opencode-devbox users, so the pi-only artifact lives here instead. -# Previously pi-devbox/Dockerfile duplicated the install logic, which drifted -# from opencode-devbox/Dockerfile.variant; this refactor eliminates the dup. -# -# The pi-only build uses INSTALL_OPENCODE=false, so this image does NOT contain -# opencode — it stays a lean, pi-focused image, distinct from -# opencode-devbox:latest-with-pi (which carries both). -# -# Everything is inherited from the pi-only build: -# pi + pi-toolkit + pi-extensions + pi-fork (fork) + pi-observational-memory -# (recall), the mempalace bridge, the LAN-access helper, entrypoints, and -# all base dev tooling. -# -# NOTE on PUBLISH ORDERING: rebuild opencode-devbox (so `base-pi-only` carries -# the target pi version) BEFORE tagging this repo. The smoke test asserts -# `pi --version` matches this repo's tag and fails loudly if the base is stale -# — turning the version coupling into an enforced ordering check. -# -# base-pi-only is an internal building-block alias (existence-only, not for -# end users — pull joakimp/pi-devbox:latest or a vX.Y.Z tag instead). Override -# BASE_IMAGE to pin a specific pi-only build (a version tag or a digest). -ARG BASE_IMAGE=joakimp/pi-devbox:base-pi-only -FROM ${BASE_IMAGE} - -# WORKDIR / ENTRYPOINT / CMD and all tooling inherited from the base. -# No additional layers — the value here is the single-source-of-truth refactor. diff --git a/Dockerfile.base b/Dockerfile.base new file mode 100644 index 0000000..d773f07 --- /dev/null +++ b/Dockerfile.base @@ -0,0 +1,407 @@ +# pi-devbox — base image (variant-independent layers) +# +# This Dockerfile produces an image tagged base-, 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. +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 \ + && 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 +# `: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 +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 && \ + /opt/uv-tools/mempalace/bin/python -c "import mempalace; print('mempalace', mempalace.__version__ if hasattr(mempalace, '__version__') else 'installed')" ; \ + 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 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/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"] diff --git a/Dockerfile.variant b/Dockerfile.variant new file mode 100644 index 0000000..f5a6ae1 --- /dev/null +++ b/Dockerfile.variant @@ -0,0 +1,109 @@ +# pi-devbox — variant image +# +# FROMs a base- 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=:base-` 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_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; \ + } && \ + 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_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 && \ + 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: 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. diff --git a/README.md b/README.md index be0f510..e969a61 100644 --- a/README.md +++ b/README.md @@ -1,277 +1,395 @@ # pi-devbox -A Docker container 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. Persistent state, full dev toolchain, MemPalace memory, and provider-agnostic LLM auth — in one `docker compose run`. +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. -> **Hub:** [`joakimp/pi-devbox`](https://hub.docker.com/r/joakimp/pi-devbox) · multi-arch (amd64 + arm64) -> **Source:** [gitea.jordbo.se/joakimp/pi-devbox](https://gitea.jordbo.se/joakimp/pi-devbox) - ---- +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 -pi-devbox is a thin re-brand of the **`pi-only` build** — it `FROM`s -`joakimp/pi-devbox:base-pi-only` and adds no layers of its own. That base build -is produced by opencode-devbox's CI (from `opencode-devbox/Dockerfile.variant` -with `INSTALL_OPENCODE=false`, the single source of truth for the pi install + -companions) but is published **into this repo** as the internal building-block -tag `base-pi-only` — *not* under opencode-devbox, so an "opencode-devbox" tag -never ships without opencode. Everything below is inherited from that build, -which is lean and pi-focused — no opencode. +### The pi coding-agent -Base tooling: +- `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 -- **Debian trixie** (current stable) -- **Node.js** (LTS), **uv** (Python), **rustup** (Rust on-demand) -- **AWS CLI v2** (with Bedrock support) -- **MemPalace** + MCP server — persistent agent memory across sessions; queryable via `mempalace_*` tools inside pi -- **Gitea MCP** server -- **Dev tools**: neovim (LazyVim), tmux, bat, eza, fzf, zoxide, ripgrep, jq, git-lfs, make -- **Shell**: bash with history tuning, prefix-search, fzf/zoxide integration -- **Host-OS-agnostic LAN access** — on VM-backed hosts (macOS OrbStack / Docker Desktop) the entrypoint sets up the host as an SSH jump so you can reach LAN peers (`dssh` alias; `DEVBOX_LAN_ACCESS` / `HOST_SSH_USER` / `DEVBOX_LAN_AUTOJUMP_PRIVATE` env; host-owned `~/.config/devbox-shell/ssh-lan.conf` for named-peer jumps). No-op on native Linux. +### MemPalace (AI memory) -pi and companions: +- `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`) -- **pi** ([`@earendil-works/pi-coding-agent`](https://www.npmjs.com/package/@earendil-works/pi-coding-agent)) — baked at `/usr/bin/pi`, version pinned by the pi-only base build -- **[pi-toolkit](https://gitea.jordbo.se/joakimp/pi-toolkit)** — mosh/tmux-friendly keybindings (Shift+Enter, Ctrl+J, Alt+J newline), AWS env loader, settings template -- **[pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions)** — 7 extensions: `ext-toggle`, `mcp-loader`, `todo`, `ssh-controlmaster`, `notify`, `git-checkpoint`, `confirm-destructive` -- **`fork` tool** ([pi-fork](https://github.com/elpapi42/pi-fork)) and **`recall` tool** ([pi-observational-memory](https://github.com/elpapi42/pi-observational-memory)) — baked into `/opt` and registered at runtime -- **mempalace bridge** — auto-symlinked MCP extension so pi reads/writes the same palace as opencode-devbox's palace +The host-mounted palace at `~/.mempalace` is shared across the host and +this container so all your agents share one brain. -(opencode itself is **not** included — that's the difference from `opencode-devbox:latest-with-pi`. If you want both opencode and pi in one image, use that variant instead.) +### Modern CLI tooling -The entrypoint deploys/registers all of these on first container start. Idempotent and preserves user edits. +| 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 -## Quick start (no git clone) +- `pandoc` — universal Markdown↔HTML/Org/RST/etc. converter +- `graphviz` — `dot` rendering for diagram pipelines +- `imagemagick` — image conversion / resizing (invoked as `magick`) -If you just want to run pi-devbox and don't plan to modify the source, grab the two template files and go: +### Language toolchains -```bash -mkdir -p ~/pi-devbox && cd ~/pi-devbox +- `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 -# Pull the docker-compose.yml and .env template -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 +For Python REPLs and notebooks beyond the system interpreter, see the +[uv-driven REPL recipes](#uv-driven-repl-recipes) section. -# Edit .env — at minimum set WORKSPACE_PATH, an LLM API key, and your git identity -$EDITOR .env +### Cloud + secrets -# Pull and run pi -docker compose run --rm devbox pi -``` +- AWS CLI v2 — for SSO + Bedrock auth +- `gitea-mcp` — MCP server for Gitea API +- `age`, `git-crypt` — encryption tooling -`docker compose run --rm devbox` (no command) drops you into bash; you can then run `pi`, `aws sso login`, etc. manually. +### SSH and networking -To attach a second terminal to the same container (e.g. shell while pi is running): +- 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. -```bash -docker compose exec -u developer devbox bash -``` +## Quickstart ---- +### Prerequisites -## Quick start (with git clone) +- Docker or OrbStack (recommended on macOS) +- Optional: AWS credentials configured on the host if you'll use the + Bedrock LLM provider -If you want to follow upstream changes, run a customized fork, or rebuild the image yourself: +### Pull and run ```bash git clone https://gitea.jordbo.se/joakimp/pi-devbox cd pi-devbox -cp .env.example .env -$EDITOR .env -docker compose run --rm devbox pi +cp .env.example .env # edit if needed +docker compose up -d +docker compose exec devbox bash ``` ---- +You're now in the container as user `developer` with `pi` on PATH and +your host workspace mounted at `/workspace`. -## Authentication +To start pi: -pi reads provider credentials from environment variables, which the container picks up from `.env` automatically. - -### Anthropic (Claude) - -```ini -ANTHROPIC_API_KEY=sk-ant-... +```bash +pi ``` -Generate a key at . +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). -### OpenAI +### Stop / recreate / update -```ini -OPENAI_API_KEY=sk-... +```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 ``` -### Google Gemini +## Image variants -```ini -GEMINI_API_KEY=... -``` +Currently published: -### AWS Bedrock (e.g. Claude on Bedrock) +| 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 | -Two paths — pick one: +Planned for upcoming minor releases: -**A) Static credentials** (simplest, lower-trust environments only): +- `joakimp/pi-devbox:latest-studio` — adds [pi-studio](https://github.com/omaclaren/pi-studio) + for browser-based prompt editing, KaTeX/Mermaid preview, and + literate REPLs (Shell / Python / IPython / Julia / R / GHCi / + Clojure). Adds ~50 MB. +- `joakimp/pi-devbox:latest-studio-tex` — also adds `texlive-xetex` + for PDF export from Studio. Adds ~600 MB on top of `-studio`. -```ini -AWS_REGION=eu-west-1 -AWS_ACCESS_KEY_ID=AKIA... -AWS_SECRET_ACCESS_KEY=... -``` - -**B) AWS SSO** (recommended for corporate AWS, requires mounting `~/.aws`): - -```ini -AWS_REGION=eu-west-1 -AWS_PROFILE=your-profile -``` - -Then in your `docker-compose.yml`, uncomment the `~/.aws` bind-mount: +## 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: + - TZ=${TZ:-Europe/Stockholm} + - TERM=xterm-256color + - AWS_PROFILE=${AWS_PROFILE:-} + - AWS_REGION=${AWS_REGION:-eu-west-1} + volumes: + # Workspace: your host source tree, read-write + - ${HOST_WORKSPACE:-./workspace}:/workspace:rw + # SSH keys: read-only from host + - ${HOME}/.ssh:/home/developer/.ssh:ro + # AWS config: read-only from host + - ${HOME}/.aws:/home/developer/.aws:ro + # MemPalace: bind-mounted so host pi and container pi share a brain + - ${HOME}/.mempalace:/home/developer/.mempalace:rw + # Per-container persistent state + - devbox-pi-config:/home/developer/.pi + - devbox-bash-history:/home/developer/.cache/bash + - devbox-nvim-data:/home/developer/.local/share/nvim + - devbox-uv-tools:/opt/uv-tools + - devbox-chroma-cache:/home/developer/.cache/chroma + volumes: - - ~/.aws:/home/developer/.aws + devbox-pi-config: + devbox-bash-history: + devbox-nvim-data: + devbox-uv-tools: + devbox-chroma-cache: ``` -Inside the container, run `aws sso login` once per session. The token cache lives on the bind-mount, so subsequent `pi` invocations pick it up automatically. The pi-toolkit's `pi-env.zsh` (deployed to `~/.config/pi/`) auto-sources `AWS_PROFILE`/`AWS_REGION` whenever a shell starts. +See `.env.example` in the repo for available environment variables. -### First-run pi configuration +## uv-driven REPL recipes -On first start, pi reads `~/.pi/agent/settings.json` (auto-bootstrapped from the pi-toolkit template). Edit it inside the container to pick a default provider/model: +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 -docker compose exec -u developer devbox bash -$EDITOR ~/.pi/agent/settings.json +cd /workspace/myproj +uv init && uv add ipython numpy matplotlib +# then: +uv run ipython ``` -The file is rewritten by pi at runtime (e.g. `lastChangelogVersion`), so it lives on the `devbox-pi-config` named volume — your edits persist across container recreation. +`pyproject.toml` + `uv.lock` then capture the dependency state and +travel with the project in git. -For pi's full configuration model (provider list, model overrides, MCP integration, themes, extensions): . +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 | -## Persistent state +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. -Persistent state is what makes the difference between "use this once" and "make it my long-term coding environment". Everything important survives `docker compose down` and image upgrades; only `docker compose down -v` wipes the volumes. +## tldr — first-run cache -| Volume | Mount point | What survives | Notes | -|---|---|---|---| -| `devbox-pi-config` | `/home/developer/.pi/` | pi settings.json, extension toggles, sessions, user-installed pi packages | `NPM_CONFIG_PREFIX` set inside the container so `pi install npm:…` and `npm install -g` lands here automatically | -| `devbox-ssh-local` | `/home/developer/.ssh-local` | generated LAN-jump keypair + known_hosts | Authorize the jump key on the host **once per machine**; persisting it avoids re-authorizing after every update (see opencode-devbox README → *Reaching your LAN*) | -| `devbox-shell-history` | `/home/developer/.cache/bash` | bash history | Across container recreate | -| `devbox-zoxide` | `/home/developer/.local/share/zoxide` | zoxide directory jump history | The `z`/`zi` shortcuts remember where you've been | -| `devbox-nvim-data` | `/home/developer/.local/share/nvim` | neovim plugin & Mason package state | LazyVim plugins persist | -| `devbox-uv` | `/home/developer/.local/share/uv` | uv-managed Python installs and tool cache | `uv tool install` results live here | - -### Optional persistent volumes - -These are commented out in `docker-compose.yml` by default. Uncomment them if you want the corresponding state to persist: - -| Volume | Mount point | What survives | -|---|---|---| -| `devbox-palace` | `/home/developer/.mempalace` | MemPalace data — drawers, knowledge graph, embeddings. Treat as primary storage if you rely on agent memory. | -| `devbox-chroma-cache` | `/home/developer/.cache/chroma` | ChromaDB embedding model cache (~80 MB; disposable, re-downloads in seconds) | - -### Workspace bind mount - -`/workspace` is bind-mounted from `WORKSPACE_PATH` on the host (default `~/projects`). Source code never lives inside the container — your editor on the host and pi inside the container see the same files. - -### SSH keys (read-only) - -`~/.ssh` is mounted read-only at `/home/developer/.ssh` for git push/pull. The container does **not** write to it. - ---- - -## Configuration reference - -All config flows through `.env`. The full list (with annotations) is in [`.env.example`](https://gitea.jordbo.se/joakimp/pi-devbox/src/branch/main/.env.example). Here's the most relevant subset: - -| Variable | Default | Purpose | -|---|---|---| -| `WORKSPACE_PATH` | `~/projects` | Host path mounted as `/workspace` | -| `SSH_KEY_PATH` | `~/.ssh` | Host path for SSH keys (read-only) | -| `GIT_USER_NAME` | (empty) | Sets `git config --global user.name` inside container | -| `GIT_USER_EMAIL` | (empty) | Sets `git config --global user.email` inside container | -| `ANTHROPIC_API_KEY` | (unset) | Anthropic provider auth | -| `OPENAI_API_KEY` | (unset) | OpenAI provider auth | -| `GEMINI_API_KEY` | (unset) | Google Gemini auth | -| `AWS_PROFILE` / `AWS_REGION` | (unset) | AWS Bedrock SSO flow | -| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | (unset) | AWS Bedrock static creds | -| `GITEA_ACCESS_TOKEN` / `GITEA_HOST` | (unset) | Gitea MCP server (optional) | -| `GITHUB_PERSONAL_ACCESS_TOKEN` | (unset) | GitHub MCP server / git ops over HTTPS | -| `DEVBOX_LAN_ACCESS` | `auto` | LAN-access mode: `auto` (jump only on VM-backed hosts), `jump`, `off` | -| `HOST_SSH_USER` | (unset) | Host username for the LAN SSH jump (see opencode-devbox README) | -| `DEVBOX_LAN_AUTOJUMP_PRIVATE` | `0` | `1` = ProxyJump any private (RFC1918) IP through the host (roaming-friendly; see opencode-devbox README) | -| `LANG` / `LANGUAGE` / `LC_ALL` | `en_US.UTF-8` | Locale override | - ---- - -## Versioning - -Tags follow the pi npm package version: `v0.74.0`, `v0.75.0`, … `latest` always points at the most recent successful release. - -Container-level rebuilds on the same pi version (security updates, base bumps, fixes) get a letter suffix: `v0.74.0b`, `v0.74.0c`, … - -The pi binary is inherited from `joakimp/pi-devbox:base-pi-only`, so a release of this image must be preceded by an opencode-devbox release that bakes the target pi version into `base-pi-only`. The smoke test enforces this (it asserts `pi --version` matches the tag). - ---- - -## Building from source - -This image is a thin re-brand of the pi-only variant, so building it just pulls -the base. To pin a specific pi-only build or hack on it: +The `tldr` command (provided by tealdeer) shows a "Page cache not +found" message on first invocation. To populate the cache: ```bash -git clone https://gitea.jordbo.se/joakimp/pi-devbox -cd pi-devbox - -# Default tracks base-pi-only; override BASE_IMAGE to pin a build: -docker compose build \ - --build-arg BASE_IMAGE=joakimp/pi-devbox:base-pi-only-v1.15.13c - -docker compose up -d +tldr --update ``` -To change the pi version, the pi extensions, or the install logic, edit -`opencode-devbox/Dockerfile.variant` (the single source of truth) and release -opencode-devbox — not this repo. +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. -Build args supported: +## Volumes and persistence -| Arg | Default | Effect | +| Path inside container | Volume | What survives | |---|---|---| -| `BASE_IMAGE` | `joakimp/pi-devbox:base-pi-only` | Parent image (internal building-block tag) — set to a `:base-pi-only-vX.Y.Z` tag or a digest for reproducible builds | +| `/workspace` | host bind-mount | host filesystem | +| `~/.ssh` | host bind-mount (read-only) | host filesystem | +| `~/.aws` | host bind-mount (read-only) | host filesystem | +| `~/.mempalace` | host bind-mount | host filesystem | +| `~/.pi` | named volume `devbox-pi-config` | `down -v` wipes | +| `~/.cache/bash` | named volume | `down -v` wipes | +| `~/.local/share/nvim` | named volume | `down -v` wipes | +| `/opt/uv-tools` | named volume | `down -v` wipes | +| `~/.cache/chroma` | named volume | `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` +(planned for `:latest-studio`) hard-codes its tmux send target to +`: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-` + where `` 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. The `:latest` and `vX.Y.Z` tags are produced from + this layer; future Studio variants will extend further. + +Tag naming: + +| Tag | Stage | +|---|---| +| `base-` | base image — internal building block | +| `base-latest` | promoted alias of the most recent base | +| `latest`, `vX.Y.Z` | variant: base + pi | + +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 -**`pi --version` works but `pi` exits immediately.** First-run config probably hasn't been done. `docker compose exec -u developer devbox bash`, edit `~/.pi/agent/settings.json`, ensure a provider is set and the matching API key is exported. +### Image grew unexpectedly -**AWS SSO token expired.** `aws sso login` from inside the container. The token cache is on the `~/.aws` bind-mount, so it persists; expiration is the issue. +`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). -**Anthropic 401 / OpenAI 401.** Check the `.env` value made it in: `docker compose exec devbox env | grep ANTHROPIC` (etc). +### pi can't reach LAN peers on macOS -**`pi` prompts for an extension/MCP server you don't recognize.** Either toggle it off via `/ext` inside pi, or rename the file: `mv ~/.pi/agent/extensions/.ts{,.off}`. +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=` in `.env` if auto-detection fails. -**Container won't start, error about `/workspace`.** `WORKSPACE_PATH` in `.env` doesn't exist on the host. Create the directory or fix the path. +### Smoke-testing a local build -**Pi-toolkit symlinks lost after `docker compose down -v`.** That's expected — `-v` wipes named volumes. Don't do it unless you mean it. Container recreation without `-v` (the default) preserves all state. +```bash +./scripts/smoke-test.sh joakimp/pi-devbox:latest +``` ---- +## Versioning and release -## Related +pi-devbox follows semver-ish: -- **[opencode-devbox](https://gitea.jordbo.se/joakimp/opencode-devbox)** — the base image. Use this if you want both opencode and pi (it has a `latest-with-pi` variant) or just opencode. -- **[pi-toolkit](https://gitea.jordbo.se/joakimp/pi-toolkit)** — keybindings, env loader, settings template. Cloned into `/opt/pi-toolkit` at image build time and `install.sh` runs on container start. -- **[pi-extensions](https://gitea.jordbo.se/joakimp/pi-extensions)** — extension source. Same install pattern. -- **[mempalace-toolkit](https://gitea.jordbo.se/joakimp/mempalace-toolkit)** — MemPalace bring-up. The `mempalace.ts` extension symlinked into `~/.pi/agent/extensions/` comes from here. -- **[pi (upstream)](https://github.com/earendil-works/pi)** — the coding-agent itself. +- **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 (this image and its source). Pi and the bundled tools each carry their own licenses. +MIT diff --git a/docker-compose.yml b/docker-compose.yml index cbbde47..3cf9930 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,9 +16,14 @@ services: # To build from source instead of pulling from Docker Hub: # build: # context: . + # dockerfile: Dockerfile.variant # args: - # # Pin a specific pi-only build instead of tracking base-pi-only: - # BASE_IMAGE: "joakimp/pi-devbox:base-pi-only-v1.15.13c" + # # Pin a specific base build by hash instead of tracking base-latest: + # BASE_IMAGE: "joakimp/pi-devbox:base-" + # # 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 diff --git a/entrypoint-user.sh b/entrypoint-user.sh new file mode 100755 index 0000000..0c8ba41 --- /dev/null +++ b/entrypoint-user.sh @@ -0,0 +1,144 @@ +#!/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 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). + # 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 `. 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 fork/recall + # tools register on the NEXT pi start (extensions bind at startup). Guard + # on settings.json so we only install once per volume. + for _pkg in /opt/pi-fork /opt/pi-observational-memory; 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 + +# ── 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 "$@" diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..cc25c8a --- /dev/null +++ b/entrypoint.sh @@ -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 "$@" diff --git a/rootfs/home/developer/.bash_aliases b/rootfs/home/developer/.bash_aliases new file mode 100644 index 0000000..65d7148 --- /dev/null +++ b/rootfs/home/developer/.bash_aliases @@ -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 ' 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 diff --git a/rootfs/home/developer/.inputrc b/rootfs/home/developer/.inputrc new file mode 100644 index 0000000..f7a2406 --- /dev/null +++ b/rootfs/home/developer/.inputrc @@ -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 diff --git a/rootfs/usr/local/lib/pi-devbox/setup-lan-access.sh b/rootfs/usr/local/lib/pi-devbox/setup-lan-access.sh new file mode 100755 index 0000000..5061a9a --- /dev/null +++ b/rootfs/usr/local/lib/pi-devbox/setup-lan-access.sh @@ -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@` 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@`. 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" < +# (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 < host -> LAN-peer access: + 1. Set HOST_SSH_USER= 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 <> ~/.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 diff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh index dc916ed..3d9ef67 100755 --- a/scripts/smoke-test.sh +++ b/scripts/smoke-test.sh @@ -1,26 +1,32 @@ #!/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 # # 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` # - image size within threshold set -euo pipefail IMAGE="${1:?usage: $0 }" PASS=0; FAIL=0 -# Since the refactor to FROM opencode-devbox:latest-pi-only, this image equals -# the pi-only variant (pi + companions + fork/recall node_modules, NO opencode), -# so the threshold tracks pi-only's (2850 MB), not the old standalone 2200 MB. -SIZE_THRESHOLD_MB=2850 +# 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" @@ -31,12 +37,12 @@ run() { fi } -# Stricter version of `run` that also asserts an expected substring in stdout. -# Used for catching the "image bytes silently identical to previous release" -# class of regression (Docker layer cache hit on `npm install -g ` 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 -# through v0.75.5 had been shipping the same image bytes. +# 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 ` 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 @@ -51,7 +57,7 @@ run_expect() { echo "=== pi-devbox smoke test: $IMAGE ===" echo "" -# ── Basic binary checks ─────────────────────────────────────────────── +# ── Binaries ───────────────────────────────────────────────────────── echo "── Binaries ──" if [ -n "${EXPECTED_PI_VERSION:-}" ]; then run_expect "pi version matches build arg" "pi --version" "$EXPECTED_PI_VERSION" @@ -64,14 +70,26 @@ 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" + +# ── 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" -# pi-fork (fork tool) + pi-observational-memory (recall tool) — inherited from -# the pi-only base, cloned to /opt with node_modules baked at build time. 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" \ @@ -88,9 +106,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 @@ -122,11 +150,34 @@ 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' +# ── /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))