Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1154f1fa6 | |||
| 36afd3c716 |
@@ -1,9 +1,38 @@
|
||||
name: Publish Docker Image
|
||||
|
||||
# Two-phase split-base build pipeline for pi-devbox.
|
||||
# Adapted from opencode-devbox/.gitea/workflows/docker-publish-split.yml
|
||||
# (commit before v1.16.2). pi-devbox v1.0.0 introduces a self-contained
|
||||
# build chain — base + variant Dockerfiles in this repo — so this
|
||||
# workflow no longer depends on opencode-devbox CI.
|
||||
#
|
||||
# Pipeline shape:
|
||||
# 1. base-decide compute base hash from Dockerfile.base + rootfs/
|
||||
# + entrypoints; probe Docker Hub for existing tag.
|
||||
# 2. resolve-versions resolve pi @ npm 'latest', pi-fork/pi-obsmem refs
|
||||
# to commit SHAs (defeats registry-buildcache
|
||||
# cache-hit footgun on byte-identical build args).
|
||||
# 3. build-base only if probe missed; multi-arch push of base-<hash>.
|
||||
# 4. smoke amd64-only build of the variant FROMing the base
|
||||
# tag; runs scripts/smoke-test.sh.
|
||||
# 5. build-variant multi-arch push of latest + vX.Y.Z tags.
|
||||
# 6. promote-base-latest re-tag base-<hash> → base-latest with `crane copy`.
|
||||
# 7. update-description patch Docker Hub description.
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: 'Release tag to publish (e.g. v1.0.0). Used only for workflow_dispatch runs.'
|
||||
required: false
|
||||
default: ''
|
||||
promote_latest:
|
||||
description: 'Update latest aliases (default true for tag-push, false for manual test runs)'
|
||||
required: false
|
||||
default: 'false'
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
@@ -12,16 +41,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<<EOF"
|
||||
echo "${IMAGE}:${VERSION}"
|
||||
echo "${IMAGE}:latest"
|
||||
if [ "${{ env.PROMOTE_LATEST }}" = "true" ]; then
|
||||
echo "${IMAGE}:latest"
|
||||
fi
|
||||
echo "EOF"
|
||||
} >> "$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-<hash> → base-latest (manifest copy only) ─
|
||||
promote-base-latest:
|
||||
needs:
|
||||
- base-decide
|
||||
- build-variant
|
||||
# Skip on cache-hit base builds: when need_build=false, base-latest
|
||||
# already points at the same digest as base-<hash>, so the retag is
|
||||
# a tautology and any transient failure of it is purely cosmetic.
|
||||
# Manual workflow_dispatch with promote_latest=true overrides this
|
||||
# gate as an escape hatch (e.g., if base-latest got hand-deleted).
|
||||
if: |
|
||||
always() &&
|
||||
needs.build-variant.result == 'success' &&
|
||||
(inputs.promote_latest == 'true' ||
|
||||
(github.ref_type == 'tag' && needs.base-decide.outputs.need_build == 'true'))
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: catthehacker/ubuntu:act-latest
|
||||
steps:
|
||||
# Direct pinned install instead of imjasonh/setup-crane@v0.4. The
|
||||
# action's bootstrap script periodically rate-limits on
|
||||
# api.github.com/.../releases/latest. Pinning removes the runtime
|
||||
# dependency on GitHub API entirely.
|
||||
- name: Install crane (pinned)
|
||||
env:
|
||||
CRANE_VERSION: v0.21.6
|
||||
run: |
|
||||
set -eux
|
||||
curl -fsSL "https://github.com/google/go-containerregistry/releases/download/${CRANE_VERSION}/go-containerregistry_Linux_x86_64.tar.gz" \
|
||||
| tar -xz -C /usr/local/bin crane
|
||||
crane version
|
||||
- name: Login (crane)
|
||||
run: |
|
||||
crane auth login docker.io \
|
||||
-u ${{ vars.DOCKERHUB_USERNAME }} \
|
||||
-p "${{ secrets.DOCKERHUB_TOKEN }}"
|
||||
- name: Re-tag base-<hash> as base-latest
|
||||
run: |
|
||||
crane copy \
|
||||
${{ env.IMAGE }}:${{ needs.base-decide.outputs.base_tag }} \
|
||||
${{ env.IMAGE }}:base-latest
|
||||
|
||||
# ── Phase 6: update Hub description (only on real release runs) ────
|
||||
update-description:
|
||||
needs: publish
|
||||
needs: [build-variant]
|
||||
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."
|
||||
|
||||
@@ -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-<hash>`, 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-<hash>` (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@<version>
|
||||
tar -xzf earendil-works-pi-coding-agent-<version>.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-<hash>` tag).
|
||||
- **`base-latest` alias** is promoted from `base-<hash>` via `crane copy`
|
||||
(manifest copy, no rebuild) only when the base actually changed.
|
||||
- **`docker buildx build --push` retry**: 3 attempts with backoff for
|
||||
transient Hub blips. Deterministic failures fail all 3 and the job
|
||||
fails as expected.
|
||||
- **Registry buildcache disabled**: buildkit's cache-export hits HTTP 400
|
||||
on Hub CDN since ~2026-05-23. Image push works fine; we pay the full
|
||||
base build on Dockerfile.base change, but base tags are content-
|
||||
addressed so unchanged bases short-circuit at the probe step.
|
||||
|
||||
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.
|
||||
|
||||
+116
-2
@@ -2,13 +2,127 @@
|
||||
|
||||
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.78.1)_
|
||||
_(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-<hash>` (content-addressed); `Dockerfile.variant`
|
||||
FROMs the base and adds the pi install. Replaces the prior 5-line
|
||||
`Dockerfile` shim that FROMed `joakimp/pi-devbox:base-pi-only` (an
|
||||
opencode-devbox CI artifact).
|
||||
- **No more publish-ordering coupling.** pi-devbox releases no longer
|
||||
require rebuilding opencode-devbox first.
|
||||
- **Adapted from opencode-devbox** at the time of decoupling — the
|
||||
apt set, ssh ControlMaster setup, MemPalace integration, entrypoint
|
||||
UID/GID dance, and CI pipeline shape are all derived from there. See
|
||||
Acknowledgements in README.md.
|
||||
- **CI workflow** rewritten as two-phase split-base build pipeline
|
||||
(mirrors opencode-devbox's `docker-publish-split.yml` shape, simplified
|
||||
to a single variant). Includes `crane`-based `base-latest` promotion,
|
||||
registry-buildcache footgun guard via concrete `PI_VERSION` resolution,
|
||||
and the c6f9d11 smoke-test gate (waits for keybindings + mempalace.ts
|
||||
+ ≥4 *.ts before sampling).
|
||||
|
||||
### Added (base image)
|
||||
|
||||
- **pandoc** — universal Markdown↔HTML/Org/RST/etc. conversion. ~200 MB.
|
||||
- **graphviz** — `dot` rendering for diagram pipelines. ~10 MB.
|
||||
- **imagemagick** — image conversion (invoked as `magick`, not `convert`,
|
||||
in v7+). ~50 MB.
|
||||
- **yq** — YAML-aware companion to jq.
|
||||
- **tldr (tealdeer)** — Rust port of tldr-pages, ~5 MB static binary.
|
||||
Replaced the Node `tldr` global (which was ~140 MB).
|
||||
- **`/etc/tmux.conf`** with `set -g base-index 0` + `set -g
|
||||
pane-base-index 0`. Required for the planned `:latest-studio`
|
||||
variant; pi-studio hard-codes its tmux send target to `:0.0`. User-
|
||||
level `~/.tmux.conf` overrides still win.
|
||||
|
||||
### Added (smoke test)
|
||||
|
||||
- Asserts pandoc, graphviz, imagemagick, yq, and tldr are present.
|
||||
- Asserts `/etc/tmux.conf` has the 0-indexed config baked.
|
||||
- Asserts `/tmp/sshcm/` directory created mode 700 by entrypoint.
|
||||
- Image-size measurement now sums `docker history` layer sizes (the
|
||||
prior `image inspect --format='{{.Size}}'` approach returned only
|
||||
the variant-unique layer when the base was content-addressed and
|
||||
shared, understating the user-facing image size by 2+ GB).
|
||||
- Size threshold raised to 3500 MB (was 2850) to cover the new base
|
||||
additions plus +200 MB safety margin. Tighten in a follow-up release
|
||||
once amd64 actuals settle.
|
||||
|
||||
### Image size
|
||||
|
||||
Local arm64 build of `pi-devbox-test:latest` (this branch's content):
|
||||
3.20 GB. Up ~390 MB from the prior pi-only-equivalent (~2.81 GB) due
|
||||
to pandoc, graphviz, imagemagick, yq, and minor expansion in pi npm
|
||||
dependencies.
|
||||
|
||||
### Migration notes
|
||||
|
||||
- Existing volumes (`devbox-pi-config`, `devbox-bash-history`,
|
||||
`devbox-nvim-data`, `devbox-uv-tools`, `devbox-chroma-cache`) are
|
||||
unchanged in name and structure. `docker compose pull && docker
|
||||
compose up -d --force-recreate` is a clean upgrade path.
|
||||
- The `:latest` and `vX.Y.Z` Hub tags continue to point at a "base +
|
||||
pi" image. Same shape, just built differently.
|
||||
- `:base-pi-only` and `:base-pi-only-vX.Y.Z` tags from prior releases
|
||||
remain on Hub for now; will be deprecated when opencode-devbox
|
||||
retires the pi paths in its next major release.
|
||||
|
||||
### Future work
|
||||
|
||||
- v1.1.0: `:latest-studio` variant (adds [pi-studio](https://github.com/omaclaren/pi-studio)).
|
||||
- v1.2.0: `:latest-studio-tex` variant (adds texlive-xetex for PDF export).
|
||||
|
||||
## v0.79.0 — 2026-06-08
|
||||
|
||||
First build on pi **`0.79.0`** (upstream `@earendil-works/pi-coding-agent` bump
|
||||
from `0.78.1`). Built `FROM` the freshly republished
|
||||
`joakimp/pi-devbox:base-pi-only` from opencode-devbox `v1.16.2`, which carries
|
||||
pi `0.79.0` (and picks up opencode `1.16.2` in the sibling opencode-bearing
|
||||
variants, though this pi-only image has no opencode).
|
||||
|
||||
### Bumped: pi 0.78.1 → 0.79.0
|
||||
|
||||
Resolved from the tag and asserted by the smoke base-freshness guard
|
||||
(`EXPECTED_PI_VERSION`). Highlights from the upstream `CHANGELOG.md`:
|
||||
|
||||
- **Project trust for local inputs** — pi now asks before loading project-local
|
||||
settings, resources, instructions, and packages, with saved decisions and
|
||||
`--approve` / `--no-approve` controls for non-interactive modes, plus a
|
||||
`project_trust` extension event so global/CLI extensions can decide or defer.
|
||||
- **Cache-hit visibility in the footer** — the interactive footer shows the
|
||||
latest prompt cache hit rate (`CH`).
|
||||
- **Richer SDK/RPC extension surfaces** — public exports now include RPC
|
||||
extension UI request/response types and package asset path helpers.
|
||||
- Plus a large batch of TUI and provider fixes (Kitty keyboard fallback,
|
||||
prompt-history cursor placement, large-JSONL session reads, custom-provider
|
||||
routing).
|
||||
|
||||
### Smoke size threshold 2750 → 2850 MB
|
||||
|
||||
Tracks opencode-devbox's `pi-only` variant, which was raised to 2850 MB in
|
||||
`v1.16.2` for headroom against the pi `0.79.0` bump (and routine apt drift).
|
||||
Kept in lockstep so this image's guard matches its source-of-truth variant.
|
||||
|
||||
## v0.78.1 — 2026-06-04
|
||||
|
||||
|
||||
-35
@@ -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.
|
||||
+407
@@ -0,0 +1,407 @@
|
||||
# pi-devbox — base image (variant-independent layers)
|
||||
#
|
||||
# This Dockerfile produces an image tagged base-<hash>, used as the parent
|
||||
# for all published variants of pi-devbox. It contains everything that does
|
||||
# not depend on variant-specific build-args (the pi install moves to
|
||||
# Dockerfile.variant).
|
||||
#
|
||||
# The base is rebuilt only when this file or anything it COPYs in changes
|
||||
# (rootfs/, entrypoint*.sh). Version bumps to PI_VERSION etc. do NOT
|
||||
# trigger a base rebuild.
|
||||
#
|
||||
# To force a base rebuild for fresh apt packages without other code
|
||||
# changes, bump the BASE_REBUILD_DATE comment below. The hash is
|
||||
# content-addressed over this file, so any byte change invalidates the
|
||||
# cache. Recommended cadence: once per release for security updates.
|
||||
#
|
||||
# BASE_REBUILD_DATE: 2026-06-09 (v1.0.0 — decoupled from opencode-devbox)
|
||||
#
|
||||
# ── Lineage note ─────────────────────────────────────────────────────
|
||||
# Adapted from opencode-devbox/Dockerfile.base (commit before v1.16.2).
|
||||
# pi-devbox was previously a thin re-brand of opencode-devbox's pi-only
|
||||
# variant; this file is the start of an independent build chain. The
|
||||
# opencode-devbox install logic (INSTALL_OPENCODE, INSTALL_OMOS) does
|
||||
# not appear here. The base is otherwise broadly equivalent so generic
|
||||
# upstream improvements (CVE updates, new dev tooling) can be cherry-
|
||||
# picked between repos.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
ARG DEBIAN_VERSION=trixie-slim
|
||||
FROM debian:${DEBIAN_VERSION} AS base
|
||||
|
||||
ARG TARGETARCH
|
||||
|
||||
LABEL maintainer="joakimp"
|
||||
LABEL description="pi-devbox — base image (variant-independent)"
|
||||
LABEL org.opencontainers.image.source="https://gitea.jordbo.se/joakimp/pi-devbox"
|
||||
|
||||
# Avoid interactive prompts during build
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# ── Core system packages ─────────────────────────────────────────────
|
||||
# apt-get upgrade picks up any security/CVE fixes published between
|
||||
# debian:trixie-slim base-image rebuilds. Paired with the index update
|
||||
# and the install in the same layer so we don't bloat image history.
|
||||
#
|
||||
# Additions vs the upstream opencode-devbox base (2026-06-09):
|
||||
# pandoc — Markdown↔HTML/PDF/etc. conversion. Required by pi-studio
|
||||
# preview/export pipelines and broadly useful for any
|
||||
# agent-driven document workflow. ~200 MB.
|
||||
# graphviz — `dot` rendering for many diagram tools. ~10 MB.
|
||||
# imagemagick — image conversion / resizing for thumbnails, etc. ~50 MB.
|
||||
# yq — YAML-aware companion to jq.
|
||||
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
|
||||
# `<session>:0.0`. Containers that ship tmux with default options are
|
||||
# already 0-indexed; this file makes the assumption explicit so future
|
||||
# /etc/tmux.conf consumers can read it. Users can override per-user
|
||||
# in ~/.tmux.conf if they want 1-indexing — pi-studio will then fail
|
||||
# to find its REPL session.
|
||||
RUN printf '%s\n' \
|
||||
'# pi-devbox baked default — see Dockerfile.base.' \
|
||||
'# pi-studio targets tmux session :0.0; do not change these here.' \
|
||||
'set -g base-index 0' \
|
||||
'set -g pane-base-index 0' \
|
||||
> /etc/tmux.conf
|
||||
|
||||
# ── SSH client defaults: ControlMaster on a writable socket path ──────
|
||||
# Why this exists: the devbox typically mounts ~/.ssh from the host as
|
||||
# read-only (security: keys are readable, but agents can't tamper with
|
||||
# config / known_hosts / authorized_keys / plant a malicious ProxyCommand).
|
||||
# OpenSSH's default ControlPath is ~/.ssh/cm/... which is unwritable on
|
||||
# such mounts, so any attempt to use ControlMaster fails. Symptoms:
|
||||
# unix_listener: cannot bind to path /home/.../.ssh/cm/...: Read-only file system
|
||||
# kex_exchange_identification: Connection closed by remote host
|
||||
# The latter manifests downstream of CGNAT per-destination flow caps
|
||||
# (~4 concurrent flows on most European residential ISPs) which silently
|
||||
# drop further SYNs once exceeded — making fresh ssh attempts fail with
|
||||
# banner-exchange timeouts that look like a remote problem.
|
||||
#
|
||||
# Fix: set a system-wide default ControlPath in /tmp (per-container,
|
||||
# tmpfs-friendly, always writable) so multiplexing Just Works without
|
||||
# touching the read-only ~/.ssh mount. Per-host overrides in user's
|
||||
# ~/.ssh/config still win — Debian's default /etc/ssh/ssh_config has
|
||||
# `Include /etc/ssh/ssh_config.d/*.conf` *before* the `Host *` block,
|
||||
# so user config can override these defaults if desired.
|
||||
#
|
||||
# ControlPersist=10m means the master socket sticks around 10 min after
|
||||
# the last session closes, so consecutive ssh calls in a workflow reuse
|
||||
# the same TCP flow. Companion entrypoint-user.sh creates /tmp/sshcm
|
||||
# (mode 700) on each container start.
|
||||
RUN mkdir -p /etc/ssh/ssh_config.d && \
|
||||
printf '%s\n' \
|
||||
'# Devbox-baked default. See Dockerfile.base "SSH client defaults".' \
|
||||
'# Override per-host in ~/.ssh/config if the master socket location' \
|
||||
'# needs to differ.' \
|
||||
'Host *' \
|
||||
' ControlMaster auto' \
|
||||
' ControlPath /tmp/sshcm/%r@%h:%p' \
|
||||
' ControlPersist 10m' \
|
||||
' ServerAliveInterval 30' \
|
||||
' ServerAliveCountMax 6' \
|
||||
> /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf && \
|
||||
chmod 644 /etc/ssh/ssh_config.d/00-devbox-controlmaster.conf
|
||||
|
||||
# ── Go-compiled tools (install from GitHub to avoid CVEs in Debian's old Go builds)
|
||||
#
|
||||
# Version policy: default is `latest` — resolved at build time by
|
||||
# following the /releases/latest redirect and reading the tag from the
|
||||
# Location header. Every base rebuild picks up the newest upstream
|
||||
# release. Explicit pins still work via build-args (e.g.
|
||||
# --build-arg GOSU_VERSION=1.19).
|
||||
|
||||
# gosu — privilege de-escalation
|
||||
ARG GOSU_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${GOSU_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing gosu ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/tianon/gosu/releases/download/${V}/gosu-${ARCH}" -o /usr/local/bin/gosu && \
|
||||
chmod +x /usr/local/bin/gosu && \
|
||||
gosu --version
|
||||
|
||||
# fzf — fuzzy finder
|
||||
ARG FZF_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${FZF_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing fzf ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/junegunn/fzf/releases/download/v${V}/fzf-${V}-linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin fzf && \
|
||||
fzf --version
|
||||
|
||||
# git-lfs
|
||||
ARG GIT_LFS_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "amd64" ;; arm64) echo "arm64" ;; *) echo "amd64" ;; esac) && \
|
||||
V="${GIT_LFS_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing git-lfs ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/git-lfs/git-lfs/releases/download/v${V}/git-lfs-linux-${ARCH}-v${V}.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/git-lfs-${V}/git-lfs /usr/local/bin/git-lfs && \
|
||||
rm -rf /tmp/git-lfs-${V} && \
|
||||
git lfs install --system && \
|
||||
git-lfs --version
|
||||
|
||||
# gitleaks
|
||||
ARG GITLEAKS_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x64" ;; arm64) echo "arm64" ;; *) echo "x64" ;; esac) && \
|
||||
V="${GITLEAKS_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing gitleaks ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/gitleaks/gitleaks/releases/download/v${V}/gitleaks_${V}_linux_${ARCH}.tar.gz" | tar -xz -C /usr/local/bin gitleaks && \
|
||||
chmod +x /usr/local/bin/gitleaks && \
|
||||
gitleaks version
|
||||
|
||||
# neovim
|
||||
ARG NVIM_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "arm64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${NVIM_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing neovim ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/neovim/neovim/releases/download/v${V}/nvim-linux-${ARCH}.tar.gz" | tar -xz -C /opt && \
|
||||
ln -s /opt/nvim-linux-${ARCH}/bin/nvim /usr/local/bin/nvim && \
|
||||
nvim --version | head -1
|
||||
|
||||
# bat
|
||||
ARG BAT_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${BAT_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing bat ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/sharkdp/bat/releases/download/v${V}/bat-v${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/bat-v${V}-${ARCH}-unknown-linux-musl/bat /usr/local/bin/bat && \
|
||||
rm -rf /tmp/bat-v${V}-* && \
|
||||
bat --version
|
||||
|
||||
# eza
|
||||
ARG EZA_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${EZA_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing eza ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/eza-community/eza/releases/download/v${V}/eza_${ARCH}-unknown-linux-gnu.tar.gz" | tar -xz -C /usr/local/bin && \
|
||||
eza --version | head -1
|
||||
|
||||
# zoxide
|
||||
ARG ZOXIDE_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${ZOXIDE_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing zoxide ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/ajeetdsouza/zoxide/releases/download/v${V}/zoxide-${V}-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /usr/local/bin zoxide && \
|
||||
zoxide --version
|
||||
|
||||
# uv — fast Python package manager. Note: uv tags don't prefix with "v".
|
||||
ARG UV_VERSION=latest
|
||||
RUN ARCH=$(case "${TARGETARCH}" in amd64) echo "x86_64" ;; arm64) echo "aarch64" ;; *) echo "x86_64" ;; esac) && \
|
||||
V="${UV_VERSION}" && \
|
||||
if [ "$V" = "latest" ]; then \
|
||||
V=$(curl -sI --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/latest" | awk 'tolower($1)=="location:" { sub(/\r$/,"",$2); n=split($2,a,"/"); print a[n] }'); \
|
||||
fi && \
|
||||
V="${V#v}" && [ -n "$V" ] && \
|
||||
echo "Installing uv ${V}" && \
|
||||
curl -fsSL --retry 5 --retry-delay 5 --retry-all-errors "https://github.com/astral-sh/uv/releases/download/${V}/uv-${ARCH}-unknown-linux-musl.tar.gz" | tar -xz -C /tmp && \
|
||||
install /tmp/uv-${ARCH}-unknown-linux-musl/uv /usr/local/bin/uv && \
|
||||
install /tmp/uv-${ARCH}-unknown-linux-musl/uvx /usr/local/bin/uvx && \
|
||||
rm -rf /tmp/uv-* && \
|
||||
uv --version
|
||||
|
||||
# ── MemPalace — local-first AI memory system ─────────────────────────
|
||||
# Provides semantic search over conversation history via 29 MCP tools.
|
||||
# Always installed in the base. Set INSTALL_MEMPALACE=false at base-build
|
||||
# time to shave ~300 MB.
|
||||
ARG INSTALL_MEMPALACE=true
|
||||
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"]
|
||||
@@ -0,0 +1,109 @@
|
||||
# pi-devbox — variant image
|
||||
#
|
||||
# FROMs a base-<hash> image produced by Dockerfile.base and adds only
|
||||
# the variant-specific tools — currently just the pi install. Kept as a
|
||||
# separate file (rather than collapsed into Dockerfile.base) so future
|
||||
# variants (e.g. studio, studio-tex) can FROM the variant or extend
|
||||
# this Dockerfile with additional build args without rebuilding the
|
||||
# base on every pi version bump.
|
||||
#
|
||||
# Pass `--build-arg BASE_IMAGE=<repo>:base-<hash>` to select the base.
|
||||
# CI computes the base hash from Dockerfile.base + rootfs/ +
|
||||
# entrypoint*.sh and feeds it in.
|
||||
#
|
||||
# IMPORTANT: the base image sets NPM_CONFIG_PREFIX to
|
||||
# /home/developer/.pi/npm-global so runtime `pi install npm:...` and
|
||||
# `npm install -g` by the developer user lands on the named volume.
|
||||
# At BUILD time we want the baked binaries on /usr so they survive the
|
||||
# volume mount. Each `npm install -g` below therefore prefixes the
|
||||
# command with `NPM_CONFIG_PREFIX=/usr`.
|
||||
|
||||
ARG BASE_IMAGE
|
||||
FROM ${BASE_IMAGE}
|
||||
|
||||
ARG TARGETARCH
|
||||
ARG USER_NAME=developer
|
||||
|
||||
# ── pi coding-agent + companions ─────────────────────────────────────
|
||||
# pi-toolkit and pi-extensions are cloned into /opt/. entrypoint-user.sh
|
||||
# runs each repo's install.sh on container start so symlinks land under
|
||||
# ~/.pi/agent/ on the named volume.
|
||||
#
|
||||
# PI_VERSION should be passed explicitly by CI as a concrete version
|
||||
# (resolved from `npm view @earendil-works/pi-coding-agent version`).
|
||||
# The default `latest` is for local dev convenience only — it has a
|
||||
# known cache-hit footgun in registry-cached CI builds: the resulting
|
||||
# build-arg string is byte-identical across builds, the layer-hash is
|
||||
# identical, and the registry buildcache silently reuses the layer
|
||||
# from whatever pi version was current when the cache was first
|
||||
# populated. CI MUST pass a resolved concrete version. See pi-devbox
|
||||
# v0.75.5b 2026-05-23 for the discovery + canonical fix.
|
||||
ARG PI_VERSION=latest
|
||||
ARG PI_TOOLKIT_REF=main
|
||||
ARG PI_EXTENSIONS_REF=main
|
||||
# pi-fork (fork tool) + pi-observational-memory (recall tool) live on GitHub
|
||||
# under elpapi42. CI resolves these to commit SHAs to defeat the same
|
||||
# cache-hit footgun that affects PI_VERSION.
|
||||
ARG PI_FORK_REPO=https://github.com/elpapi42/pi-fork.git
|
||||
ARG PI_FORK_REF=master
|
||||
ARG PI_OBSMEM_REPO=https://github.com/elpapi42/pi-observational-memory.git
|
||||
ARG PI_OBSMEM_REF=master
|
||||
|
||||
RUN set -e && \
|
||||
git_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.
|
||||
@@ -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 <https://console.anthropic.com/settings/keys>.
|
||||
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): <https://github.com/earendil-works/pi#configuration>.
|
||||
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
|
||||
`<session>:0.0`. If you override `base-index` to 1 in a personal
|
||||
`~/.tmux.conf`, pi-studio will fail with "can't find window: 0".
|
||||
|
||||
## AWS Bedrock auth
|
||||
|
||||
If you use Bedrock as pi's LLM provider:
|
||||
|
||||
1. Configure SSO on the host: `aws configure sso`
|
||||
2. Bind-mount `~/.aws:/home/developer/.aws:ro`
|
||||
3. Set `AWS_PROFILE` and `AWS_REGION` in `.env`
|
||||
4. Inside the container: `aws sso login` if needed; pi picks up the
|
||||
profile via the env vars.
|
||||
|
||||
The pi-toolkit AWS env loader (in `~/.pi/agent/`) prepares Bedrock
|
||||
inference-profile model IDs (with `eu.` / `us.` prefixes) automatically.
|
||||
|
||||
## Build pipeline
|
||||
|
||||
pi-devbox is built from this repo's CI in two phases:
|
||||
|
||||
1. **Base** (`Dockerfile.base`) — produces `joakimp/pi-devbox:base-<hash>`
|
||||
where `<hash>` is content-addressed over `Dockerfile.base`,
|
||||
`rootfs/`, and `entrypoint*.sh`. Rebuilt only when these change.
|
||||
2. **Variant** (`Dockerfile.variant`) — `FROM ${BASE_IMAGE}` and adds
|
||||
the pi install. The `:latest` and `vX.Y.Z` tags are produced from
|
||||
this layer; future Studio variants will extend further.
|
||||
|
||||
Tag naming:
|
||||
|
||||
| Tag | Stage |
|
||||
|---|---|
|
||||
| `base-<hash>` | base image — internal building block |
|
||||
| `base-latest` | promoted alias of the most recent base |
|
||||
| `latest`, `vX.Y.Z` | variant: base + pi |
|
||||
|
||||
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/<name>.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=<your-mac-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
|
||||
|
||||
+7
-2
@@ -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-<hash>"
|
||||
# # PI_VERSION must be a concrete version, not 'latest', to defeat
|
||||
# # the registry-buildcache cache-hit footgun. CI resolves this from
|
||||
# # the npm registry; for a local build you can set it manually.
|
||||
# PI_VERSION: "0.79.1"
|
||||
container_name: pi-devbox
|
||||
stdin_open: true
|
||||
tty: true
|
||||
|
||||
Executable
+144
@@ -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: mempalace init has an interactive "Mine this directory
|
||||
# now? [Y/n]" prompt that --yes does not auto-answer in all paths.
|
||||
# Without redirected stdin, the process blocks here forever when run
|
||||
# from `docker run -it` (the TTY keeps stdin open). EOF on stdin
|
||||
# makes the prompt fall through to its default (skip).
|
||||
mempalace init --yes /workspace </dev/null >/dev/null 2>&1 || true
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Git config defaults ──────────────────────────────────────────────
|
||||
if [ -n "${GIT_USER_NAME:-}" ] && ! git config --global user.name &>/dev/null; then
|
||||
git config --global user.name "$GIT_USER_NAME"
|
||||
fi
|
||||
if [ -n "${GIT_USER_EMAIL:-}" ] && ! git config --global user.email &>/dev/null; then
|
||||
git config --global user.email "$GIT_USER_EMAIL"
|
||||
fi
|
||||
|
||||
# ── pi: deploy toolkit + extensions + mempalace bridge ─────────────
|
||||
# pi is always installed in pi-devbox; no INSTALL_PI guard needed.
|
||||
# Each install.sh is idempotent and backs up real files before linking,
|
||||
# so re-running across container restarts is safe.
|
||||
#
|
||||
# Order: pi-toolkit first (creates ~/.pi/agent/keybindings.json symlink
|
||||
# and writes the AWS env loader), then pi-extensions (symlinks our
|
||||
# extensions), then settings.json bootstrap from the toolkit template,
|
||||
# then the mempalace bridge symlink (one-liner; mempalace-toolkit's
|
||||
# install_skill is intentionally skipped to avoid racing with skillset
|
||||
# auto-deploy below).
|
||||
if command -v pi &>/dev/null; then
|
||||
if [ -d /opt/pi-toolkit ]; then
|
||||
(cd /opt/pi-toolkit && ./install.sh --yes) || \
|
||||
echo "WARN: pi-toolkit install.sh failed (continuing)"
|
||||
fi
|
||||
|
||||
if [ -d /opt/pi-extensions ]; then
|
||||
(cd /opt/pi-extensions && ./install.sh --yes) || \
|
||||
echo "WARN: pi-extensions install.sh failed (continuing)"
|
||||
fi
|
||||
|
||||
# Bootstrap settings.json from template if absent (pi rewrites this
|
||||
# file at runtime — lastChangelogVersion, etc — so we can't symlink it).
|
||||
if [ ! -f "$HOME/.pi/agent/settings.json" ] && \
|
||||
[ -f /opt/pi-toolkit/settings.example.json ]; then
|
||||
cp /opt/pi-toolkit/settings.example.json "$HOME/.pi/agent/settings.json"
|
||||
fi
|
||||
|
||||
# pi↔mempalace MCP bridge — single extension symlink.
|
||||
if [ -f /opt/mempalace-toolkit/extensions/pi/mempalace.ts ] && \
|
||||
command -v mempalace &>/dev/null && \
|
||||
[ ! -L "$HOME/.pi/agent/extensions/mempalace.ts" ]; then
|
||||
ln -sf /opt/mempalace-toolkit/extensions/pi/mempalace.ts \
|
||||
"$HOME/.pi/agent/extensions/mempalace.ts"
|
||||
fi
|
||||
|
||||
# pi-fork (fork tool) + pi-observational-memory (recall tool).
|
||||
# These are pi packages (not symlink-style extensions): they're cloned to
|
||||
# /opt with node_modules baked at BUILD time, then registered here via
|
||||
# `pi install <local-path>`. A local-path install is instant + in-place
|
||||
# (pi loads the extension directly from /opt) + idempotent (no duplicate
|
||||
# package entry on re-run), and stores a relative path that resolves into
|
||||
# the image-layer /opt so it survives volume recreate. The 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 "$@"
|
||||
Executable
+122
@@ -0,0 +1,122 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
USER_NAME="developer"
|
||||
CURRENT_UID=$(id -u "$USER_NAME")
|
||||
CURRENT_GID=$(id -g "$USER_NAME")
|
||||
|
||||
# ── UID/GID adjustment ───────────────────────────────────────────────
|
||||
# Priority per dimension: env var > auto-detect from /workspace > no-op
|
||||
# UID and GID are detected independently so a GID-only mismatch (e.g. host
|
||||
# user has UID 1000 but primary group at GID 1001) is still corrected.
|
||||
TARGET_UID="${USER_UID:-}"
|
||||
TARGET_GID="${USER_GID:-}"
|
||||
|
||||
if [ -d /workspace ]; then
|
||||
WORKSPACE_UID=$(stat -c '%u' /workspace 2>/dev/null || stat -f '%u' /workspace 2>/dev/null || echo "")
|
||||
WORKSPACE_GID=$(stat -c '%g' /workspace 2>/dev/null || stat -f '%g' /workspace 2>/dev/null || echo "")
|
||||
# Adopt workspace UID if env var not set and workspace is non-root-owned
|
||||
if [ -z "$TARGET_UID" ] && [ -n "$WORKSPACE_UID" ] && [ "$WORKSPACE_UID" != "0" ] && [ "$WORKSPACE_UID" != "$CURRENT_UID" ]; then
|
||||
TARGET_UID="$WORKSPACE_UID"
|
||||
fi
|
||||
# Adopt workspace GID if env var not set and workspace group differs
|
||||
if [ -z "$TARGET_GID" ] && [ -n "$WORKSPACE_GID" ] && [ "$WORKSPACE_GID" != "0" ] && [ "$WORKSPACE_GID" != "$CURRENT_GID" ]; then
|
||||
TARGET_GID="$WORKSPACE_GID"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Apply UID/GID changes if needed
|
||||
if [ -n "$TARGET_GID" ] && [ "$TARGET_GID" != "$CURRENT_GID" ]; then
|
||||
groupmod -g "$TARGET_GID" "$USER_NAME" 2>/dev/null || true
|
||||
find /home/"$USER_NAME" -not -path "/home/$USER_NAME/.ssh/*" -group "$CURRENT_GID" -exec chgrp "$TARGET_GID" {} + 2>/dev/null || true
|
||||
echo "Adjusted developer GID to $TARGET_GID"
|
||||
fi
|
||||
|
||||
if [ -n "$TARGET_UID" ] && [ "$TARGET_UID" != "$CURRENT_UID" ]; then
|
||||
usermod -u "$TARGET_UID" "$USER_NAME" 2>/dev/null || true
|
||||
find /home/"$USER_NAME" -not -path "/home/$USER_NAME/.ssh/*" -user "$CURRENT_UID" -exec chown "$TARGET_UID" {} + 2>/dev/null || true
|
||||
echo "Adjusted developer UID to $TARGET_UID"
|
||||
fi
|
||||
|
||||
# ── SSH key permissions ──────────────────────────────────────────────
|
||||
# If SSH keys are mounted, fix permissions (skip if read-only mount)
|
||||
if [ -d "/home/$USER_NAME/.ssh" ] && [ "$(ls -A "/home/$USER_NAME/.ssh" 2>/dev/null)" ]; then
|
||||
if touch "/home/$USER_NAME/.ssh/.perm_test" 2>/dev/null; then
|
||||
rm -f "/home/$USER_NAME/.ssh/.perm_test"
|
||||
chmod 700 "/home/$USER_NAME/.ssh"
|
||||
find "/home/$USER_NAME/.ssh" -type f -name "id_*" ! -name "*.pub" -exec chmod 600 {} \; 2>/dev/null || true
|
||||
find "/home/$USER_NAME/.ssh" -type f -name "*.pub" -exec chmod 644 {} \; 2>/dev/null || true
|
||||
[ -f "/home/$USER_NAME/.ssh/known_hosts" ] && chmod 644 "/home/$USER_NAME/.ssh/known_hosts"
|
||||
[ -f "/home/$USER_NAME/.ssh/config" ] && chmod 600 "/home/$USER_NAME/.ssh/config"
|
||||
fi
|
||||
fi
|
||||
|
||||
# ── Fix ownership of named volume mount points ──────────────────────
|
||||
# Named volumes are created as root on first use. Fix ownership so the
|
||||
# developer user can write to them.
|
||||
FINAL_UID="${TARGET_UID:-$CURRENT_UID}"
|
||||
FINAL_GID="${TARGET_GID:-$CURRENT_GID}"
|
||||
|
||||
# First, fix parent dirs that Docker auto-creates as root:root when it
|
||||
# materializes nested mount points (e.g. mounting a volume at
|
||||
# .local/state/opencode creates .local/state as root). Non-recursive —
|
||||
# we only need the dir node itself; children are handled below or were
|
||||
# created by the user.
|
||||
for parent in \
|
||||
/home/"$USER_NAME"/.local \
|
||||
/home/"$USER_NAME"/.local/share \
|
||||
/home/"$USER_NAME"/.local/state \
|
||||
/home/"$USER_NAME"/.cache \
|
||||
/home/"$USER_NAME"/.config; do
|
||||
if [ -d "$parent" ] && [ "$(stat -c '%u' "$parent" 2>/dev/null)" != "$FINAL_UID" ]; then
|
||||
chown "$FINAL_UID":"$FINAL_GID" "$parent" 2>/dev/null || true
|
||||
fi
|
||||
done
|
||||
|
||||
for dir in \
|
||||
/home/"$USER_NAME"/.local/share/opencode \
|
||||
/home/"$USER_NAME"/.local/state/opencode \
|
||||
/home/"$USER_NAME"/.local/share/uv \
|
||||
/home/"$USER_NAME"/.local/share/zoxide \
|
||||
/home/"$USER_NAME"/.local/share/nvim \
|
||||
/home/"$USER_NAME"/.mempalace \
|
||||
/home/"$USER_NAME"/.cache/bash \
|
||||
/home/"$USER_NAME"/.cache/chroma \
|
||||
/home/"$USER_NAME"/.rustup \
|
||||
/home/"$USER_NAME"/.cargo \
|
||||
/home/"$USER_NAME"/.vscode-server \
|
||||
/home/"$USER_NAME"/.config/opencode \
|
||||
/home/"$USER_NAME"/.config/nvim \
|
||||
/home/"$USER_NAME"/.pi \
|
||||
/home/"$USER_NAME"/.ssh-local \
|
||||
/home/"$USER_NAME"/.agents/skills; do
|
||||
[ -d "$dir" ] || continue
|
||||
|
||||
# Sentinel-file fast path: on volumes with thousands of files (nvim
|
||||
# plugins, palace data) the recursive chown used to cost multiple
|
||||
# seconds on every container start even when ownership was already
|
||||
# correct. Now we write a sentinel after a successful chown and skip
|
||||
# the walk when the sentinel matches the target UID:GID.
|
||||
#
|
||||
# If USER_UID changes between runs (user switches hosts, different
|
||||
# workspace owner), the sentinel won't match and the full chown runs.
|
||||
sentinel="$dir/.devbox-owner"
|
||||
expected="$FINAL_UID:$FINAL_GID"
|
||||
if [ -f "$sentinel" ] && [ "$(cat "$sentinel" 2>/dev/null)" = "$expected" ]; then
|
||||
continue
|
||||
fi
|
||||
|
||||
# Recursive chown needed. Only do it when the top-level differs too
|
||||
# (covers the common case of fresh root-owned named volumes).
|
||||
if [ "$(stat -c '%u' "$dir" 2>/dev/null)" != "$FINAL_UID" ]; then
|
||||
chown -R "$FINAL_UID":"$FINAL_GID" "$dir" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# Write sentinel so subsequent starts skip the recursive walk.
|
||||
# Suppress errors — a read-only mount would fail here, but that would
|
||||
# already have failed above on the chown itself.
|
||||
echo "$expected" > "$sentinel" 2>/dev/null || true
|
||||
done
|
||||
|
||||
# ── Drop to developer user for remaining setup ──────────────────────
|
||||
exec gosu "$USER_NAME" /usr/local/bin/entrypoint-user.sh "$@"
|
||||
@@ -0,0 +1,105 @@
|
||||
# opencode-devbox bash aliases and customizations
|
||||
# Sourced by the Debian-default ~/.bashrc on shell startup.
|
||||
# To override, bind-mount your host's ~/.bash_aliases over this file
|
||||
# via docker-compose.yml.
|
||||
|
||||
# ── Host-shared shell customizations (devbox-shell bridge) ───────────
|
||||
# If the host bind-mounts a directory at ~/.config/devbox-shell/ (the
|
||||
# recommended pattern for sharing aliases/PATH/utilities between host
|
||||
# and container), source the bash_aliases file from it. This survives
|
||||
# --force-recreate because it's baked into the image's skel, not the
|
||||
# container's writable layer. Hosts that don't use this pattern are
|
||||
# unaffected — the test silently skips if the file doesn't exist.
|
||||
[ -r "$HOME/.config/devbox-shell/bash_aliases" ] && . "$HOME/.config/devbox-shell/bash_aliases"
|
||||
|
||||
# ── History persistence and quality ──────────────────────────────────
|
||||
# The named volume devbox-shell-history is mounted at ~/.cache/bash
|
||||
# so history survives container recreation.
|
||||
export HISTFILE="${HOME}/.cache/bash/history"
|
||||
mkdir -p "$(dirname "$HISTFILE")" 2>/dev/null || true
|
||||
|
||||
# Large, time-stamped, deduplicated history. Append rather than overwrite.
|
||||
export HISTSIZE=100000
|
||||
export HISTFILESIZE=200000
|
||||
export HISTCONTROL=ignoreboth:erasedups
|
||||
export HISTTIMEFORMAT='%F %T '
|
||||
shopt -s histappend 2>/dev/null
|
||||
shopt -s cmdhist 2>/dev/null
|
||||
# Note: PROMPT_COMMAND="history -a" is installed LATER in this file,
|
||||
# after zoxide's init runs. Installing it here would create a
|
||||
# "history -a;;__zoxide_hook" chain because zoxide's init uses ';'
|
||||
# as its separator and prepends itself; two adjacent ';' breaks the
|
||||
# parser. See https://github.com/ajeetdsouza/zoxide/issues/722.
|
||||
|
||||
# ── Common aliases ───────────────────────────────────────────────────
|
||||
# Prefer eza (modern ls) when available
|
||||
if command -v eza >/dev/null 2>&1; then
|
||||
alias ls='eza --group-directories-first'
|
||||
alias ll='eza -lh --group-directories-first --git'
|
||||
alias la='eza -lha --group-directories-first --git'
|
||||
alias tree='eza --tree'
|
||||
else
|
||||
alias ll='ls -lh'
|
||||
alias la='ls -lha'
|
||||
fi
|
||||
|
||||
# Prefer bat (syntax-highlighted cat) when available
|
||||
if command -v bat >/dev/null 2>&1; then
|
||||
alias cat='bat --style=plain --paging=never'
|
||||
alias less='bat --paging=always'
|
||||
fi
|
||||
|
||||
# Git shortcuts
|
||||
alias gs='git status'
|
||||
alias gd='git diff'
|
||||
alias gl='git log --oneline --graph --decorate -20'
|
||||
|
||||
# ── LAN access via the host (dssh) ───────────────────────────────────
|
||||
# When running on a VM-backed host (macOS OrbStack / Docker Desktop), the
|
||||
# entrypoint's setup-lan-access.sh generates ~/.ssh-local/config so the host
|
||||
# can be used as an SSH jump to reach LAN peers. These aliases wrap `ssh -F`
|
||||
# / `scp -F` against that config. Guarded so they only appear when the config
|
||||
# was actually generated (no-op / absent on native Linux hosts).
|
||||
if [ -r "$HOME/.ssh-local/config" ]; then
|
||||
alias dssh='ssh -F "$HOME/.ssh-local/config"'
|
||||
alias dscp='scp -F "$HOME/.ssh-local/config"'
|
||||
fi
|
||||
|
||||
# Safety: confirm before destructive ops
|
||||
alias rm='rm -i'
|
||||
alias mv='mv -i'
|
||||
alias cp='cp -i'
|
||||
|
||||
# ── Shell integrations ───────────────────────────────────────────────
|
||||
# zoxide — smarter cd. Use 'z <fragment>' to jump to previously-visited dirs.
|
||||
if command -v zoxide >/dev/null 2>&1; then
|
||||
eval "$(zoxide init bash)"
|
||||
fi
|
||||
|
||||
# fzf — fuzzy finder key bindings (Ctrl-R for history, Ctrl-T for files).
|
||||
# We install fzf from GitHub releases (not apt), so sourcing from the
|
||||
# apt-path /usr/share/doc/fzf/examples/* would find nothing. Use the
|
||||
# binary's own --bash flag (available since fzf 0.48) for setup.
|
||||
if command -v fzf >/dev/null 2>&1; then
|
||||
eval "$(fzf --bash)" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
# ── PROMPT_COMMAND: flush history every prompt ───────────────────────
|
||||
# Installed AFTER zoxide init so zoxide's hook is already in place;
|
||||
# we append with a newline separator to avoid the ';;' parse error
|
||||
# described at the top of this file. Guarded so repeated sourcing
|
||||
# (e.g. `exec bash`) doesn't stack duplicates.
|
||||
if [ -z "${DEVBOX_HIST_SET:-}" ]; then
|
||||
PROMPT_COMMAND="${PROMPT_COMMAND:+$PROMPT_COMMAND$'\n'}history -a"
|
||||
export DEVBOX_HIST_SET=1
|
||||
fi
|
||||
|
||||
# ── Prompt: show [opencode-devbox] tag so it's obvious you're in the container
|
||||
# Preserves the default Debian PS1 logic but prefixes with a container marker.
|
||||
# We check for the literal '[devbox]' substring in PS1 rather than relying on
|
||||
# an exported guard variable — otherwise `exec bash` inherits the guard but
|
||||
# gets a fresh (prefix-less) PS1 from .bashrc, and the prefix would never be
|
||||
# re-added in the new shell.
|
||||
if [ -n "${PS1:-}" ] && [[ "$PS1" != *"[devbox]"* ]]; then
|
||||
PS1='\[\e[38;5;39m\][devbox]\[\e[0m\] '"${PS1}"
|
||||
fi
|
||||
@@ -0,0 +1,27 @@
|
||||
# opencode-devbox readline defaults
|
||||
# To override, bind-mount your host's ~/.inputrc over this file
|
||||
# via docker-compose.yml.
|
||||
|
||||
# Inherit system-wide defaults (colour, 8-bit input, …) if present
|
||||
$include /etc/inputrc
|
||||
|
||||
# ── History search on Up/Down ────────────────────────────────────────
|
||||
# Type a prefix, press Up, and walk through previous commands starting
|
||||
# with that prefix. Ctrl-Up / Ctrl-Down keep the unconditional stepper.
|
||||
"\e[A": history-search-backward
|
||||
"\e[B": history-search-forward
|
||||
"\e[1;5A": previous-history
|
||||
"\e[1;5B": next-history
|
||||
|
||||
# ── Completion quality ───────────────────────────────────────────────
|
||||
set show-all-if-ambiguous on # single Tab shows matches on ambiguity
|
||||
set completion-ignore-case on # case-insensitive file/dir completion
|
||||
set colored-stats on # colour ls-style completion list entries
|
||||
set colored-completion-prefix on # highlight the matched prefix
|
||||
set visible-stats on # append /*@ type indicators in completion
|
||||
set mark-symlinked-directories on # add trailing / to symlinks to dirs
|
||||
set skip-completed-text on # don't re-insert already-typed text
|
||||
|
||||
# Treat hyphens and underscores as equivalent when completing (e.g.
|
||||
# typing `foo-` matches both `foo-bar` and `foo_bar`).
|
||||
set completion-map-case on
|
||||
+225
@@ -0,0 +1,225 @@
|
||||
#!/usr/bin/env bash
|
||||
# setup-lan-access.sh — generic, host-OS-agnostic LAN reachability helper.
|
||||
#
|
||||
# THE PROBLEM
|
||||
# On macOS (OrbStack / Docker Desktop) and Docker Desktop on Windows, the
|
||||
# container runs inside a Linux VM behind the host's network stack. The
|
||||
# host's *directly-attached* LAN peers (e.g. other boxes on 192.168.1.0/24)
|
||||
# are NOT bridged into the container by default — only the host itself and
|
||||
# *routed* subnets are reachable. On native Linux Docker the default bridge
|
||||
# already NATs container egress onto the host's LAN, so LAN peers are usually
|
||||
# reachable directly and no workaround is needed.
|
||||
#
|
||||
# THE APPROACH ("detect, and on a VM-backed host use the host as a jump")
|
||||
# The one thing reachable from a container on every OS is the host itself
|
||||
# (host.docker.internal). So on VM-backed hosts we generate a writable SSH
|
||||
# config that reaches the host and lets the user ProxyJump onward to LAN
|
||||
# peers the host can reach. On native Linux we do nothing.
|
||||
#
|
||||
# We ship the MECHANISM (a generic `host` jump alias + writable config),
|
||||
# never the POLICY: the user's specific target hosts live in their own
|
||||
# bind-mounted ~/.ssh/config (add `ProxyJump host` to those entries) — which
|
||||
# is pulled in via the `Include ~/.ssh/config` line below.
|
||||
#
|
||||
# WHY A WRITABLE SIDECAR (~/.ssh-local)
|
||||
# The devbox typically bind-mounts the host's ~/.ssh READ-ONLY (so agents
|
||||
# can read keys for git but can't tamper with config/known_hosts/authorized_
|
||||
# keys). That means we cannot edit ~/.ssh/config or write ~/.ssh/known_hosts.
|
||||
# So everything generated here lives under the writable ~/.ssh-local, used
|
||||
# via `ssh -F ~/.ssh-local/config` (the `dssh`/`dscp` aliases wrap that).
|
||||
#
|
||||
# CONTROLS (env)
|
||||
# DEVBOX_LAN_ACCESS = auto (default) | jump | off
|
||||
# auto → set up the jump config only on VM-backed hosts; no-op on Linux.
|
||||
# jump → always set up (e.g. native Linux with extra_hosts host-gateway).
|
||||
# off → do nothing.
|
||||
# HOST_SSH_USER — the username to SSH into the host as. REQUIRED for the
|
||||
# jump to authenticate. If unset we still generate the config but print
|
||||
# a hint with the public key to authorize on the host.
|
||||
# DEVBOX_HOST_ALIAS — host hostname to reach (default host.docker.internal).
|
||||
# DEVBOX_LAN_AUTOJUMP_PRIVATE = 0 (default) | 1
|
||||
# 1 → also emit a catch-all that ProxyJumps *any* RFC1918 (private) IP
|
||||
# through the host. Lets bare `dssh user@<private-IP>` work on whatever
|
||||
# LAN the (roaming) host is currently joined to, without naming peers.
|
||||
# Matches by the address you TYPE, not the resolved HostName, so it never
|
||||
# overrides named hosts that already carry their own ProxyJump.
|
||||
#
|
||||
# HOST-OWNED PEER POLICY (portable; keeps this image generic)
|
||||
# Named LAN peers are facts about a *specific* host's network, not about the
|
||||
# image — a roaming laptop sees different LANs. So we never bake peer names
|
||||
# here. Instead, if the host bind-mounts ~/.config/devbox-shell/ssh-lan.conf
|
||||
# (the same devbox-shell bridge dir used for shared aliases), we Include it
|
||||
# *before* ~/.ssh/config. That file holds the host's own jump overrides, e.g.
|
||||
# Host pve pve-2 pbs-vm
|
||||
# ProxyJump host
|
||||
# First-value-wins means ProxyJump is taken from there while HostName/User/
|
||||
# IdentityFile are inherited from the matching block in ~/.ssh/config.
|
||||
#
|
||||
# SCOPING NOTE (important)
|
||||
# `Include` is scoped to the enclosing Host/Match block. So every Include
|
||||
# below is preceded by a bare `Host *` to reset the active context to
|
||||
# match-all — otherwise the included config would only apply when targeting
|
||||
# `host`/`mac` and named peers like `pve` would silently fall back to ssh
|
||||
# defaults.
|
||||
#
|
||||
# Idempotent: re-renders the config every run (cheap); never regenerates the
|
||||
# key. Always non-fatal — never blocks container startup.
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
MODE="${DEVBOX_LAN_ACCESS:-auto}"
|
||||
[ "$MODE" = "off" ] && exit 0
|
||||
|
||||
HOST_ALIAS_HOSTNAME="${DEVBOX_HOST_ALIAS:-host.docker.internal}"
|
||||
SSH_LOCAL="${HOME}/.ssh-local"
|
||||
CONFIG="${SSH_LOCAL}/config"
|
||||
KEY="${SSH_LOCAL}/devbox_jump_ed25519"
|
||||
|
||||
# ── Detection: is this a VM-backed host (macOS / Docker Desktop)? ──────
|
||||
# host.docker.internal resolves on OrbStack and Docker Desktop (mac/win) but
|
||||
# NOT on native Linux Docker (unless the user added extra_hosts: host-gateway,
|
||||
# in which case the jump is still harmless / usable, and they can force it
|
||||
# with DEVBOX_LAN_ACCESS=jump).
|
||||
is_vm_backed() {
|
||||
getent hosts "$HOST_ALIAS_HOSTNAME" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
if [ "$MODE" = "auto" ] && ! is_vm_backed; then
|
||||
# Native Linux host: LAN peers are reachable directly. Nothing to do.
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# From here: MODE=jump, or MODE=auto on a VM-backed host.
|
||||
|
||||
command -v ssh-keygen >/dev/null 2>&1 || exit 0
|
||||
|
||||
mkdir -p "${SSH_LOCAL}/cm" 2>/dev/null || true
|
||||
chmod 700 "${SSH_LOCAL}" "${SSH_LOCAL}/cm" 2>/dev/null || true
|
||||
|
||||
# ── Jump key (generated once; preserved across restarts) ──────────────
|
||||
# Persisted via a named volume on ~/.ssh-local (see compose), so a fresh key
|
||||
# is generated only on the very first start (or if the volume is wiped). When
|
||||
# we DO generate one it must be (re-)authorized on the host, so we flag it and
|
||||
# print a copy-paste authorize line below.
|
||||
KEY_JUST_GENERATED=0
|
||||
if [ ! -f "$KEY" ]; then
|
||||
ssh-keygen -t ed25519 -N '' -C "devbox-jump@${HOSTNAME:-container}" -f "$KEY" >/dev/null 2>&1 || exit 0
|
||||
chmod 600 "$KEY" 2>/dev/null || true
|
||||
KEY_JUST_GENERATED=1
|
||||
fi
|
||||
|
||||
# ── Render the writable config ────────────────────────────────────────
|
||||
USER_LINE=""
|
||||
if [ -n "${HOST_SSH_USER:-}" ]; then
|
||||
USER_LINE=" User ${HOST_SSH_USER}"
|
||||
fi
|
||||
|
||||
# Optional host-owned named-peer jump overrides (portable: lives on the host,
|
||||
# not in the image). Included BEFORE ~/.ssh/config so its ProxyJump wins.
|
||||
SSH_LAN_CONF="${HOME}/.config/devbox-shell/ssh-lan.conf"
|
||||
LAN_CONF_BLOCK=""
|
||||
if [ -r "$SSH_LAN_CONF" ]; then
|
||||
LAN_CONF_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# Host-owned named-peer jump overrides (bind-mounted; edit on the host).
|
||||
# Scope reset to match-all so the Include applies to every target host.
|
||||
Host *
|
||||
Include ~/.config/devbox-shell/ssh-lan.conf
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
|
||||
# Optional opt-in RFC1918 catch-all: ProxyJump every private IP through the
|
||||
# host. Matches the typed address, never the resolved HostName, so named hosts
|
||||
# with their own ProxyJump are unaffected. Network-agnostic → roaming-safe.
|
||||
AUTOJUMP_BLOCK=""
|
||||
if [ "${DEVBOX_LAN_AUTOJUMP_PRIVATE:-0}" = "1" ]; then
|
||||
AUTOJUMP_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# RFC1918 auto-jump (DEVBOX_LAN_AUTOJUMP_PRIVATE=1): reach any private IP on
|
||||
# the host's CURRENT LAN via bare `dssh user@<ip>`. Public IPs are unmatched
|
||||
# and go direct via the container's NAT egress. NOTE: also matches the
|
||||
# container's own bridge subnet and any private IP the host can't actually
|
||||
# reach — for non-LAN private hosts behind a different jump, use their named
|
||||
# entry (which matches first by name and keeps its own ProxyJump).
|
||||
Host 10.* 192.168.* 172.16.* 172.17.* 172.18.* 172.19.* 172.20.* 172.21.* 172.22.* 172.23.* 172.24.* 172.25.* 172.26.* 172.27.* 172.28.* 172.29.* 172.30.* 172.31.*
|
||||
ProxyJump host
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
|
||||
INCLUDE_BLOCK=""
|
||||
if [ -r "${HOME}/.ssh/config" ]; then
|
||||
INCLUDE_BLOCK=$(cat <<'EOF'
|
||||
|
||||
# Your own target hosts. Scope reset to match-all so this Include applies to
|
||||
# every target (an Include is otherwise scoped to the enclosing Host block).
|
||||
# Add 'ProxyJump host' to LAN entries here (or in ssh-lan.conf above).
|
||||
Host *
|
||||
Include ~/.ssh/config
|
||||
EOF
|
||||
)
|
||||
fi
|
||||
|
||||
cat > "$CONFIG" <<EOF
|
||||
# AUTO-GENERATED by setup-lan-access.sh on every container start. Do not edit
|
||||
# by hand — edits are overwritten. Used via: ssh -F ~/.ssh-local/config <host>
|
||||
# (or the dssh / dscp aliases). See the script header for the full rationale.
|
||||
|
||||
# ~/.ssh is typically mounted read-only, so keep our own known_hosts here.
|
||||
# Also redirect ControlPath into the writable sidecar: the bind-mounted
|
||||
# ~/.ssh/config commonly sets 'ControlPath ~/.ssh/cm/...' for CGNAT multiplexing,
|
||||
# but ~/.ssh is read-only here so the master socket can't be created and those
|
||||
# hosts fail to connect. First-value-wins: setting it here (before the Include)
|
||||
# overrides the read-only path for every host. Harmless when ControlMaster is off.
|
||||
Host *
|
||||
UserKnownHostsFile ~/.ssh-local/known_hosts
|
||||
StrictHostKeyChecking accept-new
|
||||
ControlPath ~/.ssh-local/cm/%r@%h:%p
|
||||
|
||||
# The container host (OrbStack / Docker Desktop). 'host' and 'mac' are aliases.
|
||||
Host host mac
|
||||
HostName ${HOST_ALIAS_HOSTNAME}
|
||||
${USER_LINE}
|
||||
IdentityFile ~/.ssh-local/devbox_jump_ed25519
|
||||
IdentitiesOnly yes
|
||||
ControlMaster auto
|
||||
ControlPath ~/.ssh-local/cm/%r@%h:%p
|
||||
ControlPersist 4h
|
||||
ServerAliveInterval 30
|
||||
${LAN_CONF_BLOCK}
|
||||
${AUTOJUMP_BLOCK}
|
||||
${INCLUDE_BLOCK}
|
||||
EOF
|
||||
chmod 600 "$CONFIG" 2>/dev/null || true
|
||||
|
||||
# ── Authorize hints ───────────────────────────────────────────────────
|
||||
# Print the copy-paste authorize line whenever we either (a) can't yet
|
||||
# authenticate (HOST_SSH_USER unset) or (b) just generated a NEW key that the
|
||||
# host won't recognize. With ~/.ssh-local persisted via a named volume, case
|
||||
# (b) fires only on first-ever start (or after the volume is reset) — so this
|
||||
# is normally a one-time, one-line step per machine, with no file to locate.
|
||||
PUBKEY_TEXT="$(cat "${KEY}.pub" 2>/dev/null)"
|
||||
if [ -z "${HOST_SSH_USER:-}" ]; then
|
||||
cat <<EOF
|
||||
[devbox] LAN-access jump config generated at ~/.ssh-local/config, but
|
||||
HOST_SSH_USER is unset so it can't authenticate to the host yet.
|
||||
To enable container -> host -> LAN-peer access:
|
||||
1. Set HOST_SSH_USER=<your host username> in the container env.
|
||||
2. Authorize this key on the host (run ON THE HOST, once):
|
||||
echo '${PUBKEY_TEXT}' >> ~/.ssh/authorized_keys
|
||||
3. Ensure the host's SSH server (Remote Login) is enabled.
|
||||
Then: dssh host (or add 'ProxyJump host' to targets in ~/.ssh/config)
|
||||
EOF
|
||||
elif [ "$KEY_JUST_GENERATED" = "1" ]; then
|
||||
cat <<EOF
|
||||
[devbox] Generated a NEW LAN-jump key. Authorize it on the host (${HOST_SSH_USER}@host),
|
||||
then 'dssh host' and your LAN peers will work. Run this ONCE, ON THE HOST:
|
||||
echo '${PUBKEY_TEXT}' >> ~/.ssh/authorized_keys
|
||||
(Ensure the host's SSH server / Remote Login is enabled.)
|
||||
This key is persisted in the ~/.ssh-local volume, so you won't need to
|
||||
repeat this on container updates — only if that volume is reset.
|
||||
EOF
|
||||
fi
|
||||
|
||||
exit 0
|
||||
+71
-20
@@ -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 <image>
|
||||
#
|
||||
# Verifies:
|
||||
# - pi binary present and returns a version
|
||||
# - pi binary present and (if EXPECTED_PI_VERSION set) matches CI's resolved version
|
||||
# - new v1.0.0 base additions (pandoc, graphviz, imagemagick, yq, tealdeer)
|
||||
# - tmux 0-indexing baked in /etc/tmux.conf (required for pi-studio variants)
|
||||
# - pi-toolkit cloned at /opt/pi-toolkit
|
||||
# - pi-extensions cloned at /opt/pi-extensions
|
||||
# - pi-fork + pi-observational-memory cloned with node_modules baked
|
||||
# - entrypoint deploys pi-toolkit keybindings symlink
|
||||
# - entrypoint deploys ≥4 extensions
|
||||
# - mempalace bridge symlink present
|
||||
# - settings.json bootstrapped
|
||||
# - pi-fork + pi-observational-memory registered via `pi install`
|
||||
# - image size within threshold
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
IMAGE="${1:?usage: $0 <image>}"
|
||||
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 (2750 MB), not the old standalone 2200 MB.
|
||||
SIZE_THRESHOLD_MB=2750
|
||||
# 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 <pkg>` because
|
||||
# the bare command string is identical across builds, even when `latest` would
|
||||
# resolve differently). Discovered 2026-05-23 — every pi-devbox release v0.74.0
|
||||
# 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 <pkg>` because the
|
||||
# bare command string is identical across builds, even when `latest` would
|
||||
# resolve differently. Discovered 2026-05-23 — every pi-devbox release
|
||||
# v0.74.0..v0.75.5 had been shipping the same image bytes.
|
||||
run_expect() {
|
||||
local label="$1"; local cmd="$2"; local expect="$3"
|
||||
local out
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user